From a12bd2c017182c2680e0ec09896fbfa8299c0455 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 13:39:08 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .gitattributes | 1 + .github/workflows/build-release.yml | 72 + .gitignore | 49 + .husky/commit-msg | 1 + .husky/pre-commit | 1 + .prettierignore | 35 + .prettierrc.json | 9 + .vscode/extensions.json | 3 + LICENSE | 21 + README.md | 305 + README.wehub.md | 7 + README_zh.md | 308 + app/chrome-extension/.env.example | 4 + app/chrome-extension/LICENSE | 21 + app/chrome-extension/README.md | 7 + .../_locales/de/messages.json | 446 + .../_locales/en/messages.json | 504 + .../_locales/ja/messages.json | 338 + .../_locales/ko/messages.json | 446 + .../_locales/zh_CN/messages.json | 492 + .../_locales/zh_TW/messages.json | 446 + app/chrome-extension/assets/vue.svg | 1 + app/chrome-extension/common/agent-models.ts | 270 + app/chrome-extension/common/constants.ts | 249 + .../common/element-marker-types.ts | 83 + app/chrome-extension/common/message-types.ts | 395 + app/chrome-extension/common/node-types.ts | 14 + .../common/rr-v3-keepalive-protocol.ts | 26 + app/chrome-extension/common/step-types.ts | 4 + app/chrome-extension/common/tool-handler.ts | 24 + .../common/web-editor-types.ts | 539 + .../element-marker/element-marker-storage.ts | 95 + .../background/element-marker/index.ts | 409 + .../entrypoints/background/index.ts | 87 + .../background/keepalive-manager.ts | 87 + .../entrypoints/background/native-host.ts | 627 + .../background/quick-panel/agent-handler.ts | 779 ++ .../background/quick-panel/commands.ts | 124 + .../background/quick-panel/tabs-handler.ts | 230 + .../background/record-replay-v3/bootstrap.ts | 469 + .../record-replay-v3/domain/debug.ts | 88 + .../record-replay-v3/domain/errors.ts | 92 + .../record-replay-v3/domain/events.ts | 185 + .../record-replay-v3/domain/flow.ts | 119 + .../background/record-replay-v3/domain/ids.ts | 37 + .../record-replay-v3/domain/index.ts | 31 + .../record-replay-v3/domain/json.ts | 24 + .../record-replay-v3/domain/policy.ts | 115 + .../record-replay-v3/domain/triggers.ts | 143 + .../record-replay-v3/domain/variables.ts | 98 + .../record-replay-v3/engine/index.ts | 27 + .../engine/keepalive/index.ts | 5 + .../engine/keepalive/offscreen-keepalive.ts | 451 + .../engine/kernel/artifacts.ts | 193 + .../engine/kernel/breakpoints.ts | 187 + .../engine/kernel/debug-controller.ts | 485 + .../record-replay-v3/engine/kernel/index.ts | 11 + .../record-replay-v3/engine/kernel/kernel.ts | 149 + .../engine/kernel/recovery-kernel.ts | 97 + .../record-replay-v3/engine/kernel/runner.ts | 893 ++ .../engine/kernel/traversal.ts | 226 + .../record-replay-v3/engine/plugins/index.ts | 8 + .../plugins/register-v2-replay-nodes.ts | 90 + .../engine/plugins/registry.ts | 157 + .../record-replay-v3/engine/plugins/types.ts | 181 + .../engine/plugins/v2-action-adapter.ts | 414 + .../engine/queue/enqueue-run.ts | 219 + .../record-replay-v3/engine/queue/index.ts | 8 + .../record-replay-v3/engine/queue/leasing.ts | 113 + .../record-replay-v3/engine/queue/queue.ts | 199 + .../engine/queue/scheduler.ts | 336 + .../record-replay-v3/engine/recovery/index.ts | 6 + .../engine/recovery/recovery-coordinator.ts | 260 + .../record-replay-v3/engine/storage/index.ts | 5 + .../engine/storage/storage-port.ts | 144 + .../engine/transport/events-bus.ts | 222 + .../engine/transport/index.ts | 7 + .../engine/transport/rpc-server.ts | 1168 ++ .../record-replay-v3/engine/transport/rpc.ts | 192 + .../engine/triggers/command-trigger.ts | 147 + .../engine/triggers/context-menu-trigger.ts | 217 + .../engine/triggers/cron-trigger.ts | 583 + .../engine/triggers/dom-trigger.ts | 398 + .../record-replay-v3/engine/triggers/index.ts | 12 + .../engine/triggers/interval-trigger.ts | 243 + .../engine/triggers/manual-trigger.ts | 65 + .../engine/triggers/once-trigger.ts | 290 + .../engine/triggers/trigger-handler.ts | 66 + .../engine/triggers/trigger-manager.ts | 427 + .../engine/triggers/url-trigger.ts | 261 + .../background/record-replay-v3/index.ts | 45 + .../background/record-replay-v3/storage/db.ts | 231 + .../record-replay-v3/storage/events.ts | 149 + .../record-replay-v3/storage/flows.ts | 114 + .../record-replay-v3/storage/import/index.ts | 6 + .../storage/import/v2-reader.ts | 35 + .../storage/import/v2-to-v3.ts | 671 + .../record-replay-v3/storage/index.ts | 12 + .../storage/persistent-vars.ts | 88 + .../record-replay-v3/storage/queue.ts | 526 + .../record-replay-v3/storage/runs.ts | 110 + .../record-replay-v3/storage/triggers.ts | 60 + .../record-replay/actions/adapter.ts | 513 + .../record-replay/actions/handlers/assert.ts | 356 + .../record-replay/actions/handlers/click.ts | 193 + .../record-replay/actions/handlers/common.ts | 332 + .../actions/handlers/control-flow.ts | 382 + .../record-replay/actions/handlers/delay.ts | 43 + .../record-replay/actions/handlers/dom.ts | 427 + .../record-replay/actions/handlers/drag.ts | 249 + .../record-replay/actions/handlers/extract.ts | 271 + .../record-replay/actions/handlers/fill.ts | 190 + .../record-replay/actions/handlers/http.ts | 361 + .../record-replay/actions/handlers/index.ts | 164 + .../record-replay/actions/handlers/key.ts | 196 + .../actions/handlers/navigate.ts | 104 + .../actions/handlers/screenshot.ts | 101 + .../record-replay/actions/handlers/script.ts | 237 + .../record-replay/actions/handlers/scroll.ts | 260 + .../record-replay/actions/handlers/tabs.ts | 419 + .../record-replay/actions/handlers/wait.ts | 195 + .../background/record-replay/actions/index.ts | 43 + .../record-replay/actions/registry.ts | 640 + .../background/record-replay/actions/types.ts | 944 ++ .../record-replay/engine/constants.ts | 31 + .../record-replay/engine/execution-mode.ts | 237 + .../engine/logging/run-logger.ts | 69 + .../engine/plugins/breakpoint.ts | 19 + .../record-replay/engine/plugins/manager.ts | 74 + .../record-replay/engine/plugins/types.ts | 56 + .../record-replay/engine/policies/retry.ts | 31 + .../record-replay/engine/policies/wait.ts | 94 + .../engine/runners/after-script-queue.ts | 88 + .../engine/runners/control-flow-runner.ts | 59 + .../engine/runners/step-executor.ts | 256 + .../engine/runners/step-runner.ts | 238 + .../engine/runners/subflow-runner.ts | 169 + .../record-replay/engine/scheduler.ts | 846 ++ .../record-replay/engine/state-manager.ts | 87 + .../record-replay/engine/utils/expression.ts | 227 + .../background/record-replay/flow-runner.ts | 3 + .../background/record-replay/flow-store.ts | 422 + .../background/record-replay/index.ts | 504 + .../background/record-replay/legacy-types.ts | 252 + .../background/record-replay/nodes/assert.ts | 91 + .../background/record-replay/nodes/click.ts | 108 + .../record-replay/nodes/conditional.ts | 55 + ...wnload-screenshot-attr-event-frame-loop.ts | 252 + .../background/record-replay/nodes/drag.ts | 42 + .../record-replay/nodes/execute-flow.ts | 92 + .../background/record-replay/nodes/extract.ts | 47 + .../background/record-replay/nodes/fill.ts | 116 + .../background/record-replay/nodes/http.ts | 28 + .../background/record-replay/nodes/index.ts | 65 + .../background/record-replay/nodes/key.ts | 31 + .../background/record-replay/nodes/loops.ts | 47 + .../record-replay/nodes/navigate.ts | 17 + .../background/record-replay/nodes/script.ts | 32 + .../background/record-replay/nodes/scroll.ts | 45 + .../background/record-replay/nodes/tabs.ts | 49 + .../background/record-replay/nodes/types.ts | 36 + .../background/record-replay/nodes/wait.ts | 73 + .../recording/browser-event-listener.ts | 77 + .../recording/content-injection.ts | 95 + .../recording/content-message-handler.ts | 78 + .../record-replay/recording/flow-builder.ts | 100 + .../recording/recorder-manager.ts | 307 + .../recording/session-manager.ts | 495 + .../background/record-replay/rr-utils.ts | 253 + .../record-replay/selector-engine.ts | 185 + .../storage/indexeddb-manager.ts | 174 + .../background/record-replay/trigger-store.ts | 56 + .../background/record-replay/types.ts | 178 + .../background/semantic-similarity.ts | 373 + .../entrypoints/background/storage-manager.ts | 112 + .../background/tools/base-browser.ts | 173 + .../background/tools/browser/bookmark.ts | 602 + .../background/tools/browser/common.ts | 679 + .../background/tools/browser/computer.ts | 1427 +++ .../tools/browser/console-buffer.ts | 450 + .../background/tools/browser/console.ts | 628 + .../background/tools/browser/dialog.ts | 54 + .../background/tools/browser/download.ts | 123 + .../tools/browser/element-picker.ts | 557 + .../background/tools/browser/file-upload.ts | 232 + .../tools/browser/gif-auto-capture.ts | 520 + .../tools/browser/gif-enhanced-renderer.ts | 834 ++ .../background/tools/browser/gif-recorder.ts | 1241 ++ .../background/tools/browser/history.ts | 232 + .../background/tools/browser/index.ts | 30 + .../background/tools/browser/inject-script.ts | 244 + .../background/tools/browser/interaction.ts | 269 + .../background/tools/browser/javascript.ts | 525 + .../background/tools/browser/keyboard.ts | 146 + .../tools/browser/network-capture-debugger.ts | 1000 ++ .../browser/network-capture-web-request.ts | 993 ++ .../tools/browser/network-capture.ts | 158 + .../tools/browser/network-request.ts | 85 + .../background/tools/browser/performance.ts | 545 + .../background/tools/browser/read-page.ts | 229 + .../background/tools/browser/screenshot.ts | 567 + .../background/tools/browser/userscript.ts | 758 ++ .../background/tools/browser/vector-search.ts | 308 + .../background/tools/browser/web-fetcher.ts | 243 + .../background/tools/browser/window.ts | 54 + .../entrypoints/background/tools/index.ts | 34 + .../background/tools/record-replay.ts | 61 + .../entrypoints/background/utils/sidepanel.ts | 59 + .../background/web-editor/index.ts | 1641 +++ .../entrypoints/builder/App.vue | 1262 ++ .../entrypoints/builder/index.html | 13 + .../entrypoints/builder/main.ts | 7 + app/chrome-extension/entrypoints/content.ts | 4 + .../entrypoints/element-picker.content.ts | 205 + .../entrypoints/offscreen/gif-encoder.ts | 201 + .../entrypoints/offscreen/index.html | 9 + .../entrypoints/offscreen/main.ts | 441 + .../entrypoints/offscreen/rr-keepalive.ts | 280 + .../entrypoints/options/App.vue | 398 + .../entrypoints/options/index.html | 12 + .../entrypoints/options/main.ts | 4 + .../entrypoints/popup/App.vue | 2679 ++++ .../popup/components/ConfirmDialog.vue | 287 + .../components/ElementMarkerManagement.vue | 193 + .../popup/components/LocalModelPage.vue | 745 ++ .../popup/components/ModelCacheManagement.vue | 320 + .../popup/components/ProgressIndicator.vue | 95 + .../popup/components/ScheduleDialog.vue | 233 + .../components/builder/components/Canvas.vue | 569 + .../builder/components/EdgePropertyPanel.vue | 205 + .../builder/components/KeyValueEditor.vue | 80 + .../builder/components/PropertyPanel.vue | 839 ++ .../components/builder/components/Sidebar.vue | 372 + .../builder/components/TriggerPanel.vue | 941 ++ .../builder/components/nodes/NodeCard.vue | 69 + .../builder/components/nodes/NodeIf.vue | 107 + .../builder/components/nodes/node-util.ts | 119 + .../components/properties/PropertyAssert.vue | 50 + .../components/properties/PropertyClick.vue | 15 + .../properties/PropertyCloseTab.vue | 38 + .../components/properties/PropertyDelay.vue | 16 + .../components/properties/PropertyDrag.vue | 24 + .../properties/PropertyExecuteFlow.vue | 61 + .../components/properties/PropertyExtract.vue | 37 + .../components/properties/PropertyFill.vue | 35 + .../components/properties/PropertyForeach.vue | 43 + .../properties/PropertyFormRenderer.vue | 390 + .../properties/PropertyFromSpec.vue | 36 + .../properties/PropertyHandleDownload.vue | 34 + .../components/properties/PropertyHttp.vue | 107 + .../components/properties/PropertyIf.vue | 120 + .../components/properties/PropertyKey.vue | 20 + .../properties/PropertyLoopElements.vue | 43 + .../properties/PropertyNavigate.vue | 20 + .../components/properties/PropertyOpenTab.vue | 25 + .../properties/PropertyScreenshot.vue | 33 + .../components/properties/PropertyScript.vue | 45 + .../components/properties/PropertyScroll.vue | 99 + .../properties/PropertySetAttribute.vue | 34 + .../properties/PropertySwitchFrame.vue | 27 + .../properties/PropertySwitchTab.vue | 46 + .../components/properties/PropertyTrigger.vue | 226 + .../properties/PropertyTriggerEvent.vue | 33 + .../components/properties/PropertyWait.vue | 42 + .../components/properties/PropertyWhile.vue | 68 + .../components/properties/SelectorEditor.vue | 119 + .../builder/model/form-widget-registry.ts | 25 + .../builder/model/node-spec-registry.ts | 1 + .../components/builder/model/node-spec.ts | 1 + .../builder/model/node-specs-builtin.ts | 1 + .../popup/components/builder/model/toast.ts | 14 + .../components/builder/model/transforms.ts | 147 + .../components/builder/model/ui-nodes.ts | 172 + .../components/builder/model/validation.ts | 127 + .../components/builder/model/variables.ts | 13 + .../builder/store/useBuilderStore.ts | 616 + .../components/builder/widgets/FieldCode.vue | 31 + .../builder/widgets/FieldDuration.vue | 49 + .../builder/widgets/FieldExpression.vue | 42 + .../builder/widgets/FieldKeySequence.vue | 20 + .../builder/widgets/FieldSelector.vue | 94 + .../builder/widgets/FieldTargetLocator.vue | 85 + .../components/builder/widgets/VarInput.vue | 248 + .../popup/components/icons/BoltIcon.vue | 26 + .../popup/components/icons/CheckIcon.vue | 24 + .../popup/components/icons/DatabaseIcon.vue | 26 + .../popup/components/icons/DocumentIcon.vue | 26 + .../popup/components/icons/EditIcon.vue | 26 + .../popup/components/icons/MarkerIcon.vue | 27 + .../popup/components/icons/RecordIcon.vue | 17 + .../popup/components/icons/RefreshIcon.vue | 26 + .../popup/components/icons/StopIcon.vue | 15 + .../popup/components/icons/TabIcon.vue | 26 + .../popup/components/icons/TrashIcon.vue | 26 + .../popup/components/icons/VectorIcon.vue | 26 + .../popup/components/icons/WorkflowIcon.vue | 26 + .../popup/components/icons/index.ts | 13 + .../entrypoints/popup/index.html | 13 + .../entrypoints/popup/main.ts | 16 + .../entrypoints/popup/style.css | 246 + .../entrypoints/quick-panel.content.ts | 115 + .../entrypoints/shared/composables/index.ts | 11 + .../shared/composables/useRRV3Rpc.ts | 504 + .../entrypoints/shared/utils/index.ts | 14 + .../shared/utils/rr-flow-convert.ts | 141 + .../entrypoints/sidepanel/App.vue | 1368 +++ .../sidepanel/components/AgentChat.vue | 1401 +++ .../components/SidepanelNavigator.vue | 441 + .../components/agent-chat/AgentChatShell.vue | 229 + .../components/agent-chat/AgentComposer.vue | 731 ++ .../agent-chat/AgentConversation.vue | 28 + .../agent-chat/AgentOpenProjectMenu.vue | 102 + .../agent-chat/AgentProjectMenu.vue | 368 + .../agent-chat/AgentRequestThread.vue | 222 + .../agent-chat/AgentSessionListItem.vue | 439 + .../agent-chat/AgentSessionMenu.vue | 286 + .../agent-chat/AgentSessionSettingsPanel.vue | 547 + .../agent-chat/AgentSessionsView.vue | 268 + .../agent-chat/AgentSettingsMenu.vue | 142 + .../components/agent-chat/AgentTimeline.vue | 23 + .../agent-chat/AgentTimelineItem.vue | 140 + .../components/agent-chat/AgentTopBar.vue | 205 + .../agent-chat/ApplyMessageChip.vue | 318 + .../agent-chat/AttachmentCachePanel.vue | 682 ++ .../components/agent-chat/ComposerDrawer.vue | 371 + .../components/agent-chat/ElementChip.vue | 567 + .../agent-chat/FakeCaretOverlay.vue | 410 + .../components/agent-chat/SelectionChip.vue | 137 + .../agent-chat/WebEditorChanges.vue | 568 + .../sidepanel/components/agent-chat/index.ts | 28 + .../agent-chat/timeline/ThinkingNode.vue | 303 + .../timeline/TimelineNarrativeStep.vue | 134 + .../timeline/TimelineStatusStep.vue | 123 + .../timeline/TimelineToolCallStep.vue | 134 + .../timeline/TimelineToolResultCardStep.vue | 226 + .../timeline/TimelineUserPromptStep.vue | 301 + .../timeline/markstream-thinking.ts | 21 + .../components/agent/AttachmentPreview.vue | 30 + .../sidepanel/components/agent/ChatInput.vue | 110 + .../components/agent/CliSettings.vue | 130 + .../components/agent/ConnectionStatus.vue | 41 + .../components/agent/MessageItem.vue | 39 + .../components/agent/MessageList.vue | 19 + .../components/agent/ProjectCreateForm.vue | 81 + .../components/agent/ProjectSelector.vue | 97 + .../sidepanel/components/agent/index.ts | 12 + .../components/rr-v3/DebuggerPanel.vue | 377 + .../components/workflows/WorkflowListItem.vue | 371 + .../components/workflows/WorkflowsView.vue | 747 ++ .../sidepanel/components/workflows/index.ts | 2 + .../sidepanel/composables/index.ts | 65 + .../sidepanel/composables/useAgentChat.ts | 504 + .../composables/useAgentChatViewRoute.ts | 234 + .../composables/useAgentInputPreferences.ts | 88 + .../sidepanel/composables/useAgentProjects.ts | 573 + .../sidepanel/composables/useAgentServer.ts | 276 + .../sidepanel/composables/useAgentSessions.ts | 537 + .../sidepanel/composables/useAgentTheme.ts | 171 + .../sidepanel/composables/useAgentThreads.ts | 733 ++ .../sidepanel/composables/useAttachments.ts | 257 + .../sidepanel/composables/useFakeCaret.ts | 614 + .../sidepanel/composables/useFloatingDrag.ts | 178 + .../composables/useOpenProjectPreference.ts | 137 + .../sidepanel/composables/useRRV3Debugger.ts | 383 + .../sidepanel/composables/useRRV3Rpc.ts | 11 + .../composables/useTextareaAutoResize.ts | 163 + .../composables/useWebEditorTxState.ts | 679 + .../sidepanel/composables/useWorkflowsV3.ts | 364 + .../entrypoints/sidepanel/index.html | 13 + .../entrypoints/sidepanel/main.ts | 30 + .../sidepanel/styles/agent-chat.css | 836 ++ .../sidepanel/utils/loading-texts.ts | 60 + .../entrypoints/styles/tailwind.css | 151 + .../entrypoints/web-editor-v2.ts | 47 + .../web-editor-v2/attr-ui-refactor.md | 383 + .../entrypoints/web-editor-v2/constants.ts | 124 + .../web-editor-v2/core/css-compare.ts | 327 + .../core/cssom-styles-collector.ts | 1552 +++ .../web-editor-v2/core/debug-source.ts | 285 + .../design-tokens/design-tokens-service.ts | 500 + .../web-editor-v2/core/design-tokens/index.ts | 76 + .../core/design-tokens/token-detector.ts | 447 + .../core/design-tokens/token-resolver.ts | 251 + .../web-editor-v2/core/design-tokens/types.ts | 223 + .../entrypoints/web-editor-v2/core/editor.ts | 1566 +++ .../web-editor-v2/core/element-key.ts | 371 + .../web-editor-v2/core/event-controller.ts | 954 ++ .../web-editor-v2/core/execution-tracker.ts | 368 + .../web-editor-v2/core/hmr-consistency.ts | 1184 ++ .../entrypoints/web-editor-v2/core/locator.ts | 758 ++ .../web-editor-v2/core/message-listener.ts | 345 + .../web-editor-v2/core/payload-builder.ts | 460 + .../web-editor-v2/core/perf-monitor.ts | 295 + .../web-editor-v2/core/position-tracker.ts | 363 + .../web-editor-v2/core/props-bridge.ts | 605 + .../web-editor-v2/core/snap-engine.ts | 984 ++ .../core/transaction-aggregator.ts | 504 + .../web-editor-v2/core/transaction-manager.ts | 1913 +++ .../drag/drag-reorder-controller.ts | 619 + .../web-editor-v2/overlay/canvas-overlay.ts | 769 ++ .../overlay/handles-controller.ts | 1105 ++ .../selection/selection-engine.ts | 892 ++ .../web-editor-v2/ui/breadcrumbs.ts | 356 + .../web-editor-v2/ui/floating-drag.ts | 392 + .../entrypoints/web-editor-v2/ui/icons.ts | 162 + .../ui/property-panel/class-editor.ts | 502 + .../ui/property-panel/components-tree.ts | 314 + .../components/alignment-grid.ts | 364 + .../components/icon-button-group.ts | 312 + .../components/input-container.ts | 202 + .../property-panel/components/slider-input.ts | 156 + .../property-panel/components/token-pill.ts | 280 + .../controls/appearance-control.ts | 485 + .../controls/background-control.ts | 467 + .../property-panel/controls/border-control.ts | 1151 ++ .../ui/property-panel/controls/color-field.ts | 606 + .../ui/property-panel/controls/css-helpers.ts | 234 + .../controls/effects-control.ts | 2401 ++++ .../controls/gradient-control.ts | 2715 ++++ .../ui/property-panel/controls/index.ts | 15 + .../property-panel/controls/layout-control.ts | 1536 +++ .../controls/number-stepping.ts | 267 + .../controls/position-control.ts | 1145 ++ .../property-panel/controls/size-control.ts | 561 + .../controls/spacing-control.ts | 355 + .../property-panel/controls/token-picker.ts | 422 + .../controls/typography-control.ts | 1195 ++ .../ui/property-panel/css-defaults.ts | 175 + .../ui/property-panel/css-panel.ts | 854 ++ .../web-editor-v2/ui/property-panel/index.ts | 8 + .../ui/property-panel/property-panel.ts | 819 ++ .../ui/property-panel/props-panel.ts | 1221 ++ .../web-editor-v2/ui/property-panel/types.ts | 148 + .../web-editor-v2/ui/shadow-host.ts | 3621 ++++++ .../entrypoints/web-editor-v2/ui/toolbar.ts | 876 ++ .../web-editor-v2/utils/disposables.ts | 142 + .../entrypoints/welcome/App.vue | 384 + .../entrypoints/welcome/index.html | 13 + .../entrypoints/welcome/main.ts | 7 + app/chrome-extension/env.d.ts | 8 + app/chrome-extension/eslint.config.js | 56 + .../accessibility-tree-helper.js | 1855 +++ .../inject-scripts/click-helper.js | 370 + .../inject-scripts/dom-observer.js | 87 + .../inject-scripts/element-marker.js | 2802 +++++ .../inject-scripts/element-picker.js | 679 + .../inject-scripts/fill-helper.js | 350 + .../inject-scripts/inject-bridge.js | 65 + .../interactive-elements-helper.js | 393 + .../inject-scripts/keyboard-helper.js | 291 + .../inject-scripts/network-helper.js | 268 + .../inject-scripts/props-agent.js | 2393 ++++ .../inject-scripts/recorder.js | 1950 +++ .../inject-scripts/screenshot-helper.js | 160 + .../inject-scripts/wait-helper.js | 234 + .../inject-scripts/web-editor.js | 848 ++ .../inject-scripts/web-fetcher-helper.js | 3062 +++++ app/chrome-extension/package.json | 56 + app/chrome-extension/public/icon/128.png | Bin 0 -> 210119 bytes app/chrome-extension/public/icon/16.png | Bin 0 -> 210119 bytes app/chrome-extension/public/icon/32.png | Bin 0 -> 210119 bytes app/chrome-extension/public/icon/48.png | Bin 0 -> 210119 bytes app/chrome-extension/public/icon/96.png | Bin 0 -> 210119 bytes app/chrome-extension/public/libs/ort.min.js | 2869 +++++ app/chrome-extension/public/wxt.svg | 1 + .../shared/element-picker/controller.ts | 797 ++ .../shared/element-picker/index.ts | 14 + .../shared/quick-panel/core/agent-bridge.ts | 400 + .../shared/quick-panel/core/search-engine.ts | 598 + .../shared/quick-panel/core/types.ts | 349 + .../shared/quick-panel/index.ts | 340 + .../shared/quick-panel/providers/index.ts | 11 + .../quick-panel/providers/tabs-provider.ts | 450 + .../shared/quick-panel/ui/ai-chat-panel.ts | 956 ++ .../shared/quick-panel/ui/index.ts | 69 + .../quick-panel/ui/markdown-renderer.ts | 60 + .../shared/quick-panel/ui/message-renderer.ts | 449 + .../shared/quick-panel/ui/panel-shell.ts | 314 + .../shared/quick-panel/ui/quick-entries.ts | 178 + .../shared/quick-panel/ui/search-input.ts | 386 + .../shared/quick-panel/ui/shadow-host.ts | 386 + .../shared/quick-panel/ui/styles.ts | 1010 ++ .../shared/selector/dom-path.ts | 175 + .../shared/selector/fingerprint.ts | 243 + .../shared/selector/generator.ts | 358 + app/chrome-extension/shared/selector/index.ts | 77 + .../shared/selector/locator.ts | 544 + .../shared/selector/shadow-dom.ts | 291 + .../shared/selector/stability.ts | 197 + .../selector/strategies/anchor-relpath.ts | 242 + .../shared/selector/strategies/aria.ts | 78 + .../shared/selector/strategies/css-path.ts | 46 + .../shared/selector/strategies/css-unique.ts | 94 + .../shared/selector/strategies/index.ts | 41 + .../shared/selector/strategies/testid.ts | 124 + .../shared/selector/strategies/text.ts | 50 + app/chrome-extension/shared/selector/types.ts | 227 + app/chrome-extension/tailwind.config.ts | 24 + .../tests/__mocks__/hnswlib-wasm-static.ts | 23 + .../record-replay-v3/command-trigger.test.ts | 330 + .../context-menu-trigger.test.ts | 410 + .../record-replay-v3/cron-trigger.test.ts | 513 + .../debugger.contract.test.ts | 559 + .../record-replay-v3/dom-trigger.test.ts | 504 + .../record-replay-v3/e2e.integration.test.ts | 479 + .../record-replay-v3/events.contract.test.ts | 330 + .../record-replay-v3/interval-trigger.test.ts | 243 + .../record-replay-v3/manual-trigger.test.ts | 152 + .../record-replay-v3/once-trigger.test.ts | 326 + .../record-replay-v3/queue.contract.test.ts | 525 + .../tests/record-replay-v3/recovery.test.ts | 425 + .../tests/record-replay-v3/rpc-api.test.ts | 1190 ++ .../runner.onError.contract.test.ts | 463 + .../scheduler-integration.test.ts | 633 + .../tests/record-replay-v3/scheduler.test.ts | 564 + .../tests/record-replay-v3/spec-smoke.test.ts | 388 + .../record-replay-v3/trigger-manager.test.ts | 876 ++ .../tests/record-replay-v3/triggers.test.ts | 276 + .../record-replay-v3/url-trigger.test.ts | 475 + .../v2-action-adapter.test.ts | 542 + .../v2-adapter-integration.test.ts | 509 + .../v2-to-v3-conversion.test.ts | 344 + .../tests/record-replay-v3/v3-e2e-harness.ts | 585 + .../tests/record-replay/_test-helpers.ts | 82 + .../adapter-policy.contract.test.ts | 155 + .../flow-store-strip-steps.contract.test.ts | 280 + .../high-risk-actions.integration.test.ts | 481 + .../hybrid-actions.integration.test.ts | 613 + .../script-control-flow.integration.test.ts | 693 ++ .../session-dag-sync.contract.test.ts | 314 + .../step-executor.contract.test.ts | 212 + .../tab-cursor.integration.test.ts | 423 + app/chrome-extension/tests/vitest.setup.ts | 72 + .../tests/web-editor-v2/design-tokens.test.ts | 473 + .../drag-reorder-controller.test.ts | 277 + .../web-editor-v2/event-controller.test.ts | 234 + .../tests/web-editor-v2/locator.test.ts | 506 + .../property-panel-live-sync.test.ts | 243 + .../web-editor-v2/selection-engine.test.ts | 489 + .../tests/web-editor-v2/snap-engine.test.ts | 873 ++ .../tests/web-editor-v2/test-utils/dom.ts | 325 + app/chrome-extension/tsconfig.json | 3 + app/chrome-extension/types/gifenc.d.ts | 71 + app/chrome-extension/types/icons.d.ts | 8 + .../utils/cdp-session-manager.ts | 109 + app/chrome-extension/utils/content-indexer.ts | 586 + app/chrome-extension/utils/i18n.ts | 273 + app/chrome-extension/utils/image-utils.ts | 194 + .../utils/indexeddb-client.ts | 131 + app/chrome-extension/utils/lru-cache.ts | 132 + .../utils/model-cache-manager.ts | 369 + .../utils/offscreen-manager.ts | 108 + .../utils/output-sanitizer.ts | 354 + .../utils/screenshot-context.ts | 53 + .../utils/semantic-similarity-engine.ts | 2388 ++++ .../utils/simd-math-engine.ts | 496 + app/chrome-extension/utils/text-chunker.ts | 264 + app/chrome-extension/utils/vector-database.ts | 1563 +++ app/chrome-extension/vitest.config.ts | 36 + .../workers/ort-wasm-simd-threaded.jsep.mjs | 125 + .../workers/ort-wasm-simd-threaded.jsep.wasm | Bin 0 -> 21872216 bytes .../workers/ort-wasm-simd-threaded.mjs | 70 + .../workers/ort-wasm-simd-threaded.wasm | Bin 0 -> 11210254 bytes app/chrome-extension/workers/simd_math.js | 324 + .../workers/simd_math_bg.wasm | Bin 0 -> 26195 bytes .../workers/similarity.worker.js | 384 + app/chrome-extension/wxt.config.ts | 172 + app/native-server/.npmignore | 8 + app/native-server/README.md | 183 + app/native-server/debug.sh | 64 + app/native-server/install.md | 333 + app/native-server/jest.config.js | 17 + app/native-server/package.json | 87 + .../src/agent/attachment-service.ts | 463 + app/native-server/src/agent/ccr-detector.ts | 406 + app/native-server/src/agent/chat-service.ts | 527 + app/native-server/src/agent/db/client.ts | 232 + app/native-server/src/agent/db/index.ts | 5 + app/native-server/src/agent/db/schema.ts | 146 + .../src/agent/directory-picker.ts | 160 + app/native-server/src/agent/engines/claude.ts | 1571 +++ app/native-server/src/agent/engines/codex.ts | 957 ++ app/native-server/src/agent/engines/types.ts | 135 + .../src/agent/message-service.ts | 241 + app/native-server/src/agent/open-project.ts | 536 + .../src/agent/project-service.ts | 284 + app/native-server/src/agent/project-types.ts | 30 + .../src/agent/session-service.ts | 473 + app/native-server/src/agent/storage.ts | 68 + app/native-server/src/agent/stream-manager.ts | 266 + app/native-server/src/agent/tool-bridge.ts | 82 + app/native-server/src/agent/types.ts | 21 + app/native-server/src/cli.ts | 235 + app/native-server/src/constant/index.ts | 82 + app/native-server/src/file-handler.ts | 275 + app/native-server/src/index.ts | 35 + app/native-server/src/mcp/mcp-server-stdio.ts | 125 + app/native-server/src/mcp/mcp-server.ts | 24 + app/native-server/src/mcp/register-tools.ts | 153 + app/native-server/src/mcp/stdio-config.json | 3 + .../src/native-messaging-host.ts | 334 + .../src/scripts/browser-config.ts | 270 + app/native-server/src/scripts/build.ts | 129 + app/native-server/src/scripts/constant.ts | 4 + app/native-server/src/scripts/doctor.ts | 1099 ++ app/native-server/src/scripts/postinstall.ts | 320 + app/native-server/src/scripts/register-dev.ts | 3 + app/native-server/src/scripts/register.ts | 27 + app/native-server/src/scripts/report.ts | 847 ++ app/native-server/src/scripts/run_host.bat | 194 + app/native-server/src/scripts/run_host.sh | 264 + app/native-server/src/scripts/utils.ts | 535 + app/native-server/src/server/index.ts | 366 + app/native-server/src/server/routes/agent.ts | 1264 ++ app/native-server/src/server/routes/index.ts | 4 + app/native-server/src/server/server.test.ts | 27 + app/native-server/src/shims/devtools.d.ts | 7 + app/native-server/src/trace-analyzer.ts | 86 + .../src/types/devtools-frontend.d.ts | 28 + app/native-server/src/util/logger.ts | 45 + app/native-server/tsconfig.json | 23 + commitlint.config.cjs | 3 + docs/ARCHITECTURE.md | 308 + docs/ARCHITECTURE_zh.md | 307 + docs/CHANGELOG.md | 120 + docs/CONTRIBUTING.md | 263 + docs/CONTRIBUTING_zh.md | 263 + docs/ISSUE.md | 1190 ++ docs/TOOLS.md | 599 + docs/TOOLS_zh.md | 547 + docs/TROUBLESHOOTING.md | 95 + docs/TROUBLESHOOTING_zh.md | 120 + docs/VisualEditor.md | 47 + docs/VisualEditor_zh.md | 44 + docs/WINDOWS_INSTALL_zh.md | 93 + docs/mcp-cli-config.md | 108 + eslint.config.js | 63 + package.json | 53 + packages/shared/package.json | 39 + packages/shared/src/agent-types.ts | 477 + packages/shared/src/constants.ts | 2 + packages/shared/src/index.ts | 10 + packages/shared/src/labels.ts | 10 + packages/shared/src/node-spec-registry.ts | 16 + packages/shared/src/node-spec.ts | 78 + packages/shared/src/node-specs-builtin.ts | 659 + packages/shared/src/rr-graph.ts | 336 + packages/shared/src/step-types.ts | 34 + packages/shared/src/tools.ts | 1400 +++ packages/shared/src/types.ts | 165 + packages/shared/tsconfig.json | 14 + packages/wasm-simd/.gitignore | 22 + packages/wasm-simd/BUILD.md | 70 + packages/wasm-simd/Cargo.toml | 24 + packages/wasm-simd/README.md | 61 + packages/wasm-simd/package.json | 34 + packages/wasm-simd/src/lib.rs | 245 + pnpm-lock.yaml | 10216 ++++++++++++++++ pnpm-workspace.yaml | 3 + prompt/content-analize.md | 67 + prompt/excalidraw-prompt.md | 237 + prompt/modify-web.md | 82 + releases/README.md | 62 + .../latest/chrome-mcp-server-lastest.zip | Bin 0 -> 11719770 bytes 664 files changed, 216807 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/build-release.yml create mode 100644 .gitignore create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .vscode/extensions.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 README_zh.md create mode 100644 app/chrome-extension/.env.example create mode 100644 app/chrome-extension/LICENSE create mode 100644 app/chrome-extension/README.md create mode 100644 app/chrome-extension/_locales/de/messages.json create mode 100644 app/chrome-extension/_locales/en/messages.json create mode 100644 app/chrome-extension/_locales/ja/messages.json create mode 100644 app/chrome-extension/_locales/ko/messages.json create mode 100644 app/chrome-extension/_locales/zh_CN/messages.json create mode 100644 app/chrome-extension/_locales/zh_TW/messages.json create mode 100644 app/chrome-extension/assets/vue.svg create mode 100644 app/chrome-extension/common/agent-models.ts create mode 100644 app/chrome-extension/common/constants.ts create mode 100644 app/chrome-extension/common/element-marker-types.ts create mode 100644 app/chrome-extension/common/message-types.ts create mode 100644 app/chrome-extension/common/node-types.ts create mode 100644 app/chrome-extension/common/rr-v3-keepalive-protocol.ts create mode 100644 app/chrome-extension/common/step-types.ts create mode 100644 app/chrome-extension/common/tool-handler.ts create mode 100644 app/chrome-extension/common/web-editor-types.ts create mode 100644 app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts create mode 100644 app/chrome-extension/entrypoints/background/element-marker/index.ts create mode 100644 app/chrome-extension/entrypoints/background/index.ts create mode 100644 app/chrome-extension/entrypoints/background/keepalive-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/native-host.ts create mode 100644 app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts create mode 100644 app/chrome-extension/entrypoints/background/quick-panel/commands.ts create mode 100644 app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-reader.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/persistent-vars.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/queue.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay-v3/storage/triggers.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/actions/types.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/logging/run-logger.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/plugins/breakpoint.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/plugins/manager.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/plugins/types.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/policies/retry.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/runners/control-flow-runner.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/state-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/flow-runner.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/flow-store.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/click.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/conditional.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/download-screenshot-attr-event-frame-loop.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/drag.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/extract.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/fill.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/http.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/index.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/key.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/navigate.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/script.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/scroll.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/tabs.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts create mode 100644 app/chrome-extension/entrypoints/background/record-replay/types.ts create mode 100644 app/chrome-extension/entrypoints/background/semantic-similarity.ts create mode 100644 app/chrome-extension/entrypoints/background/storage-manager.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/base-browser.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/bookmark.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/common.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/computer.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/console-buffer.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/console.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/dialog.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/download.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/gif-auto-capture.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/gif-enhanced-renderer.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/history.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/index.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/interaction.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/javascript.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/keyboard.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/network-request.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/performance.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/read-page.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/userscript.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/window.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/index.ts create mode 100644 app/chrome-extension/entrypoints/background/tools/record-replay.ts create mode 100644 app/chrome-extension/entrypoints/background/utils/sidepanel.ts create mode 100644 app/chrome-extension/entrypoints/background/web-editor/index.ts create mode 100644 app/chrome-extension/entrypoints/builder/App.vue create mode 100644 app/chrome-extension/entrypoints/builder/index.html create mode 100644 app/chrome-extension/entrypoints/builder/main.ts create mode 100644 app/chrome-extension/entrypoints/content.ts create mode 100644 app/chrome-extension/entrypoints/element-picker.content.ts create mode 100644 app/chrome-extension/entrypoints/offscreen/gif-encoder.ts create mode 100644 app/chrome-extension/entrypoints/offscreen/index.html create mode 100644 app/chrome-extension/entrypoints/offscreen/main.ts create mode 100644 app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts create mode 100644 app/chrome-extension/entrypoints/options/App.vue create mode 100644 app/chrome-extension/entrypoints/options/index.html create mode 100644 app/chrome-extension/entrypoints/options/main.ts create mode 100644 app/chrome-extension/entrypoints/popup/App.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/ConfirmDialog.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/ElementMarkerManagement.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/LocalModelPage.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/ModelCacheManagement.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/ProgressIndicator.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/ScheduleDialog.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/Canvas.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/KeyValueEditor.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/PropertyPanel.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/Sidebar.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeCard.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyClick.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFill.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFormRenderer.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyKey.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScript.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWait.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/form-widget-registry.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/node-spec-registry.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/node-spec.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/node-specs-builtin.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/toast.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/ui-nodes.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/validation.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/model/variables.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/store/useBuilderStore.ts create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldCode.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldDuration.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldExpression.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/BoltIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/CheckIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/DatabaseIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/DocumentIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/EditIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/MarkerIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/RecordIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/RefreshIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/StopIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/TabIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/TrashIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/VectorIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/WorkflowIcon.vue create mode 100644 app/chrome-extension/entrypoints/popup/components/icons/index.ts create mode 100644 app/chrome-extension/entrypoints/popup/index.html create mode 100644 app/chrome-extension/entrypoints/popup/main.ts create mode 100644 app/chrome-extension/entrypoints/popup/style.css create mode 100644 app/chrome-extension/entrypoints/quick-panel.content.ts create mode 100644 app/chrome-extension/entrypoints/shared/composables/index.ts create mode 100644 app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts create mode 100644 app/chrome-extension/entrypoints/shared/utils/index.ts create mode 100644 app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/App.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/AgentChat.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/SidepanelNavigator.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentComposer.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentConversation.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentOpenProjectMenu.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentProjectMenu.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentRequestThread.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionListItem.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionMenu.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionSettingsPanel.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionsView.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSettingsMenu.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTimeline.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTimelineItem.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTopBar.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/ApplyMessageChip.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AttachmentCachePanel.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/ComposerDrawer.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/ElementChip.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/FakeCaretOverlay.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/SelectionChip.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/WebEditorChanges.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/index.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/ThinkingNode.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineNarrativeStep.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineStatusStep.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolCallStep.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolResultCardStep.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineUserPromptStep.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/markstream-thinking.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/AttachmentPreview.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/ChatInput.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/ConnectionStatus.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/MessageItem.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/MessageList.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectCreateForm.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectSelector.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/agent/index.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/rr-v3/DebuggerPanel.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowListItem.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowsView.vue create mode 100644 app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/index.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentChatViewRoute.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentInputPreferences.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentProjects.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentServer.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentSessions.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useAttachments.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useFakeCaret.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useFloatingDrag.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Debugger.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Rpc.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useWebEditorTxState.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/index.html create mode 100644 app/chrome-extension/entrypoints/sidepanel/main.ts create mode 100644 app/chrome-extension/entrypoints/sidepanel/styles/agent-chat.css create mode 100644 app/chrome-extension/entrypoints/sidepanel/utils/loading-texts.ts create mode 100644 app/chrome-extension/entrypoints/styles/tailwind.css create mode 100644 app/chrome-extension/entrypoints/web-editor-v2.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/attr-ui-refactor.md create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/constants.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/css-compare.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/cssom-styles-collector.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/debug-source.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/design-tokens-service.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/index.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/token-detector.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/token-resolver.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/types.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/editor.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/element-key.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/event-controller.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/execution-tracker.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/hmr-consistency.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/locator.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/message-listener.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/payload-builder.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/perf-monitor.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/position-tracker.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/props-bridge.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/snap-engine.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/transaction-aggregator.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/core/transaction-manager.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/drag/drag-reorder-controller.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/overlay/canvas-overlay.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/overlay/handles-controller.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/selection/selection-engine.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/breadcrumbs.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/floating-drag.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/icons.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/class-editor.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components-tree.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/alignment-grid.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/icon-button-group.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/input-container.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/slider-input.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/token-pill.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/appearance-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/background-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/border-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/color-field.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/css-helpers.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/effects-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/gradient-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/index.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/number-stepping.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/position-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/size-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/spacing-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/token-picker.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/typography-control.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/css-defaults.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/css-panel.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/index.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/property-panel.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/props-panel.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts create mode 100644 app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts create mode 100644 app/chrome-extension/entrypoints/welcome/App.vue create mode 100644 app/chrome-extension/entrypoints/welcome/index.html create mode 100644 app/chrome-extension/entrypoints/welcome/main.ts create mode 100644 app/chrome-extension/env.d.ts create mode 100644 app/chrome-extension/eslint.config.js create mode 100644 app/chrome-extension/inject-scripts/accessibility-tree-helper.js create mode 100644 app/chrome-extension/inject-scripts/click-helper.js create mode 100644 app/chrome-extension/inject-scripts/dom-observer.js create mode 100644 app/chrome-extension/inject-scripts/element-marker.js create mode 100644 app/chrome-extension/inject-scripts/element-picker.js create mode 100644 app/chrome-extension/inject-scripts/fill-helper.js create mode 100644 app/chrome-extension/inject-scripts/inject-bridge.js create mode 100644 app/chrome-extension/inject-scripts/interactive-elements-helper.js create mode 100644 app/chrome-extension/inject-scripts/keyboard-helper.js create mode 100644 app/chrome-extension/inject-scripts/network-helper.js create mode 100644 app/chrome-extension/inject-scripts/props-agent.js create mode 100644 app/chrome-extension/inject-scripts/recorder.js create mode 100644 app/chrome-extension/inject-scripts/screenshot-helper.js create mode 100644 app/chrome-extension/inject-scripts/wait-helper.js create mode 100644 app/chrome-extension/inject-scripts/web-editor.js create mode 100644 app/chrome-extension/inject-scripts/web-fetcher-helper.js create mode 100644 app/chrome-extension/package.json create mode 100644 app/chrome-extension/public/icon/128.png create mode 100644 app/chrome-extension/public/icon/16.png create mode 100644 app/chrome-extension/public/icon/32.png create mode 100644 app/chrome-extension/public/icon/48.png create mode 100644 app/chrome-extension/public/icon/96.png create mode 100644 app/chrome-extension/public/libs/ort.min.js create mode 100644 app/chrome-extension/public/wxt.svg create mode 100644 app/chrome-extension/shared/element-picker/controller.ts create mode 100644 app/chrome-extension/shared/element-picker/index.ts create mode 100644 app/chrome-extension/shared/quick-panel/core/agent-bridge.ts create mode 100644 app/chrome-extension/shared/quick-panel/core/search-engine.ts create mode 100644 app/chrome-extension/shared/quick-panel/core/types.ts create mode 100644 app/chrome-extension/shared/quick-panel/index.ts create mode 100644 app/chrome-extension/shared/quick-panel/providers/index.ts create mode 100644 app/chrome-extension/shared/quick-panel/providers/tabs-provider.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/ai-chat-panel.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/index.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/markdown-renderer.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/message-renderer.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/panel-shell.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/quick-entries.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/search-input.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/shadow-host.ts create mode 100644 app/chrome-extension/shared/quick-panel/ui/styles.ts create mode 100644 app/chrome-extension/shared/selector/dom-path.ts create mode 100644 app/chrome-extension/shared/selector/fingerprint.ts create mode 100644 app/chrome-extension/shared/selector/generator.ts create mode 100644 app/chrome-extension/shared/selector/index.ts create mode 100644 app/chrome-extension/shared/selector/locator.ts create mode 100644 app/chrome-extension/shared/selector/shadow-dom.ts create mode 100644 app/chrome-extension/shared/selector/stability.ts create mode 100644 app/chrome-extension/shared/selector/strategies/anchor-relpath.ts create mode 100644 app/chrome-extension/shared/selector/strategies/aria.ts create mode 100644 app/chrome-extension/shared/selector/strategies/css-path.ts create mode 100644 app/chrome-extension/shared/selector/strategies/css-unique.ts create mode 100644 app/chrome-extension/shared/selector/strategies/index.ts create mode 100644 app/chrome-extension/shared/selector/strategies/testid.ts create mode 100644 app/chrome-extension/shared/selector/strategies/text.ts create mode 100644 app/chrome-extension/shared/selector/types.ts create mode 100644 app/chrome-extension/tailwind.config.ts create mode 100644 app/chrome-extension/tests/__mocks__/hnswlib-wasm-static.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/command-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/context-menu-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/cron-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/debugger.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/dom-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/e2e.integration.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/events.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/interval-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/manual-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/once-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/queue.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/recovery.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/runner.onError.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/scheduler-integration.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/scheduler.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/spec-smoke.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/trigger-manager.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/triggers.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/url-trigger.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/v2-action-adapter.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/v2-adapter-integration.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/v2-to-v3-conversion.test.ts create mode 100644 app/chrome-extension/tests/record-replay-v3/v3-e2e-harness.ts create mode 100644 app/chrome-extension/tests/record-replay/_test-helpers.ts create mode 100644 app/chrome-extension/tests/record-replay/adapter-policy.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay/flow-store-strip-steps.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay/high-risk-actions.integration.test.ts create mode 100644 app/chrome-extension/tests/record-replay/hybrid-actions.integration.test.ts create mode 100644 app/chrome-extension/tests/record-replay/script-control-flow.integration.test.ts create mode 100644 app/chrome-extension/tests/record-replay/session-dag-sync.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay/step-executor.contract.test.ts create mode 100644 app/chrome-extension/tests/record-replay/tab-cursor.integration.test.ts create mode 100644 app/chrome-extension/tests/vitest.setup.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/design-tokens.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/drag-reorder-controller.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/event-controller.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/locator.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/property-panel-live-sync.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/selection-engine.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/snap-engine.test.ts create mode 100644 app/chrome-extension/tests/web-editor-v2/test-utils/dom.ts create mode 100644 app/chrome-extension/tsconfig.json create mode 100644 app/chrome-extension/types/gifenc.d.ts create mode 100644 app/chrome-extension/types/icons.d.ts create mode 100644 app/chrome-extension/utils/cdp-session-manager.ts create mode 100644 app/chrome-extension/utils/content-indexer.ts create mode 100644 app/chrome-extension/utils/i18n.ts create mode 100644 app/chrome-extension/utils/image-utils.ts create mode 100644 app/chrome-extension/utils/indexeddb-client.ts create mode 100644 app/chrome-extension/utils/lru-cache.ts create mode 100644 app/chrome-extension/utils/model-cache-manager.ts create mode 100644 app/chrome-extension/utils/offscreen-manager.ts create mode 100644 app/chrome-extension/utils/output-sanitizer.ts create mode 100644 app/chrome-extension/utils/screenshot-context.ts create mode 100644 app/chrome-extension/utils/semantic-similarity-engine.ts create mode 100644 app/chrome-extension/utils/simd-math-engine.ts create mode 100644 app/chrome-extension/utils/text-chunker.ts create mode 100644 app/chrome-extension/utils/vector-database.ts create mode 100644 app/chrome-extension/vitest.config.ts create mode 100644 app/chrome-extension/workers/ort-wasm-simd-threaded.jsep.mjs create mode 100644 app/chrome-extension/workers/ort-wasm-simd-threaded.jsep.wasm create mode 100644 app/chrome-extension/workers/ort-wasm-simd-threaded.mjs create mode 100644 app/chrome-extension/workers/ort-wasm-simd-threaded.wasm create mode 100644 app/chrome-extension/workers/simd_math.js create mode 100644 app/chrome-extension/workers/simd_math_bg.wasm create mode 100644 app/chrome-extension/workers/similarity.worker.js create mode 100644 app/chrome-extension/wxt.config.ts create mode 100644 app/native-server/.npmignore create mode 100644 app/native-server/README.md create mode 100644 app/native-server/debug.sh create mode 100644 app/native-server/install.md create mode 100644 app/native-server/jest.config.js create mode 100644 app/native-server/package.json create mode 100644 app/native-server/src/agent/attachment-service.ts create mode 100644 app/native-server/src/agent/ccr-detector.ts create mode 100644 app/native-server/src/agent/chat-service.ts create mode 100644 app/native-server/src/agent/db/client.ts create mode 100644 app/native-server/src/agent/db/index.ts create mode 100644 app/native-server/src/agent/db/schema.ts create mode 100644 app/native-server/src/agent/directory-picker.ts create mode 100644 app/native-server/src/agent/engines/claude.ts create mode 100644 app/native-server/src/agent/engines/codex.ts create mode 100644 app/native-server/src/agent/engines/types.ts create mode 100644 app/native-server/src/agent/message-service.ts create mode 100644 app/native-server/src/agent/open-project.ts create mode 100644 app/native-server/src/agent/project-service.ts create mode 100644 app/native-server/src/agent/project-types.ts create mode 100644 app/native-server/src/agent/session-service.ts create mode 100644 app/native-server/src/agent/storage.ts create mode 100644 app/native-server/src/agent/stream-manager.ts create mode 100644 app/native-server/src/agent/tool-bridge.ts create mode 100644 app/native-server/src/agent/types.ts create mode 100644 app/native-server/src/cli.ts create mode 100644 app/native-server/src/constant/index.ts create mode 100644 app/native-server/src/file-handler.ts create mode 100644 app/native-server/src/index.ts create mode 100644 app/native-server/src/mcp/mcp-server-stdio.ts create mode 100644 app/native-server/src/mcp/mcp-server.ts create mode 100644 app/native-server/src/mcp/register-tools.ts create mode 100644 app/native-server/src/mcp/stdio-config.json create mode 100644 app/native-server/src/native-messaging-host.ts create mode 100644 app/native-server/src/scripts/browser-config.ts create mode 100644 app/native-server/src/scripts/build.ts create mode 100644 app/native-server/src/scripts/constant.ts create mode 100644 app/native-server/src/scripts/doctor.ts create mode 100644 app/native-server/src/scripts/postinstall.ts create mode 100644 app/native-server/src/scripts/register-dev.ts create mode 100644 app/native-server/src/scripts/register.ts create mode 100644 app/native-server/src/scripts/report.ts create mode 100644 app/native-server/src/scripts/run_host.bat create mode 100644 app/native-server/src/scripts/run_host.sh create mode 100644 app/native-server/src/scripts/utils.ts create mode 100644 app/native-server/src/server/index.ts create mode 100644 app/native-server/src/server/routes/agent.ts create mode 100644 app/native-server/src/server/routes/index.ts create mode 100644 app/native-server/src/server/server.test.ts create mode 100644 app/native-server/src/shims/devtools.d.ts create mode 100644 app/native-server/src/trace-analyzer.ts create mode 100644 app/native-server/src/types/devtools-frontend.d.ts create mode 100644 app/native-server/src/util/logger.ts create mode 100644 app/native-server/tsconfig.json create mode 100644 commitlint.config.cjs create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ARCHITECTURE_zh.md create mode 100644 docs/CHANGELOG.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/CONTRIBUTING_zh.md create mode 100644 docs/ISSUE.md create mode 100644 docs/TOOLS.md create mode 100644 docs/TOOLS_zh.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/TROUBLESHOOTING_zh.md create mode 100644 docs/VisualEditor.md create mode 100644 docs/VisualEditor_zh.md create mode 100644 docs/WINDOWS_INSTALL_zh.md create mode 100644 docs/mcp-cli-config.md create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/agent-types.ts create mode 100644 packages/shared/src/constants.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/labels.ts create mode 100644 packages/shared/src/node-spec-registry.ts create mode 100644 packages/shared/src/node-spec.ts create mode 100644 packages/shared/src/node-specs-builtin.ts create mode 100644 packages/shared/src/rr-graph.ts create mode 100644 packages/shared/src/step-types.ts create mode 100644 packages/shared/src/tools.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/wasm-simd/.gitignore create mode 100644 packages/wasm-simd/BUILD.md create mode 100644 packages/wasm-simd/Cargo.toml create mode 100644 packages/wasm-simd/README.md create mode 100644 packages/wasm-simd/package.json create mode 100644 packages/wasm-simd/src/lib.rs create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prompt/content-analize.md create mode 100644 prompt/excalidraw-prompt.md create mode 100644 prompt/modify-web.md create mode 100644 releases/README.md create mode 100644 releases/chrome-extension/latest/chrome-mcp-server-lastest.zip diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0bb75f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..62da9ac --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,72 @@ +# name: Build and Release Chrome Extension + +# on: +# push: +# branches: [ master, develop ] +# paths: +# - 'app/chrome-extension/**' +# pull_request: +# branches: [ master ] +# paths: +# - 'app/chrome-extension/**' +# workflow_dispatch: + +# jobs: +# build-extension: +# runs-on: ubuntu-latest + +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 + +# - name: Setup Node.js +# uses: actions/setup-node@v4 +# with: +# node-version: '18' +# cache: 'npm' +# cache-dependency-path: 'app/chrome-extension/package-lock.json' + +# - name: Install dependencies +# run: | +# cd app/chrome-extension +# npm ci + +# - name: Build extension +# run: | +# cd app/chrome-extension +# npm run build + +# - name: Create zip package +# run: | +# cd app/chrome-extension +# npm run zip + +# - name: Prepare release directory +# run: | +# mkdir -p releases/chrome-extension/latest +# mkdir -p releases/chrome-extension/$(date +%Y%m%d-%H%M%S) + +# - name: Copy release files +# run: | +# # Copy to latest +# cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/latest/chrome-mcp-server-latest.zip + +# # Copy to timestamped version +# TIMESTAMP=$(date +%Y%m%d-%H%M%S) +# cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/$TIMESTAMP/chrome-mcp-server-$TIMESTAMP.zip + +# - name: Upload build artifacts +# uses: actions/upload-artifact@v4 +# with: +# name: chrome-extension-build +# path: releases/chrome-extension/ +# retention-days: 30 + +# - name: Commit and push releases (if on main branch) +# if: github.ref == 'refs/heads/main' +# run: | +# git config --local user.email "action@github.com" +# git config --local user.name "GitHub Action" +# git add releases/ +# git diff --staged --quiet || git commit -m "Auto-build: Update Chrome extension release [skip ci]" +# git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5a9554 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.output +stats.html +stats-*.json +.wxt +web-ext.config.ts +dist + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.onnx + +# Environment variables +.env +.env.local +.env.*.local + +# Prevent npm metadata pollution +false/ +metadata-v1.3/ +registry.npmmirror.com/ +registry.npmjs.com/ + +other/ +tools_optimize.md +Agents.md +CLAUDE.md + +**/*/coverage/* + +.docs/ +.claude/ \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..990bd0b --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d0a7784 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c2978ea --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# 构建输出目录 +dist +.output +.wxt + +# 依赖 +node_modules + +# 日志 +logs +*.log + +# 缓存 +.cache +.temp + +# 编辑器配置 +.vscode +!.vscode/extensions.json +.idea + +# 系统文件 +.DS_Store +Thumbs.db + +# 打包文件 +*.zip +*.tar.gz + +# 统计文件 +stats.html +stats-*.json + +# 锁文件 +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..d9f3202 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "printWidth": 100, + "endOfLine": "auto", + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "strict" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8b96e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 hangye + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..170c0c2 --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# Chrome MCP Server 🚀 + +[![Stars](https://img.shields.io/github/stars/hangwin/mcp-chrome)](https://img.shields.io/github/stars/hangwin/mcp-chrome) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-blue.svg)](https://www.typescriptlang.org/) +[![Chrome Extension](https://img.shields.io/badge/Chrome-Extension-green.svg)](https://developer.chrome.com/docs/extensions/) +[![Release](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg)](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg) + +> 🌟 **Turn your Chrome browser into your intelligent assistant** - Let AI take control of your browser, transforming it into a powerful AI-controlled automation tool. + +**📖 Documentation**: [English](README.md) | [中文](README_zh.md) + +> The project is still in its early stages and is under intensive development. More features, stability improvements, and other enhancements will follow. + +--- + +## 🎯 What is Chrome MCP Server? + +Chrome MCP Server is a Chrome extension-based **Model Context Protocol (MCP) server** that exposes your Chrome browser functionality to AI assistants like Claude, enabling complex browser automation, content analysis, and semantic search. Unlike traditional browser automation tools (like Playwright), **Chrome MCP Server** directly uses your daily Chrome browser, leveraging existing user habits, configurations, and login states, allowing various large models or chatbots to take control of your browser and truly become your everyday assistant. + +## ✨ New Features(2025/12/30) + +- **A New Visual Editor for Claude Code & Codex**, for more detail here: [VisualEditor](docs/VisualEditor.md) + +## ✨ Core Features + +- 😁 **Chatbot/Model Agnostic**: Let any LLM or chatbot client or agent you prefer automate your browser +- ⭐️ **Use Your Original Browser**: Seamlessly integrate with your existing browser environment (your configurations, login states, etc.) +- 💻 **Fully Local**: Pure local MCP server ensuring user privacy +- 🚄 **Streamable HTTP**: Streamable HTTP connection method +- 🏎 **Cross-Tab**: Cross-tab context +- 🧠 **Semantic Search**: Built-in vector database for intelligent browser tab content discovery +- 🔍 **Smart Content Analysis**: AI-powered text extraction and similarity matching +- 🌐 **20+ Tools**: Support for screenshots, network monitoring, interactive operations, bookmark management, browsing history, and 20+ other tools +- 🚀 **SIMD-Accelerated AI**: Custom WebAssembly SIMD optimization for 4-8x faster vector operations + +## 🆚 Comparison with Similar Projects + +| Comparison Dimension | Playwright-based MCP Server | Chrome Extension-based MCP Server | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **Resource Usage** | ❌ Requires launching independent browser process, installing Playwright dependencies, downloading browser binaries, etc. | ✅ No need to launch independent browser process, directly utilizes user's already open Chrome browser | +| **User Session Reuse** | ❌ Requires re-login | ✅ Automatically uses existing login state | +| **Browser Environment** | ❌ Clean environment lacks user settings | ✅ Fully preserves user environment | +| **API Access** | ⚠️ Limited to Playwright API | ✅ Full access to Chrome native APIs | +| **Startup Speed** | ❌ Requires launching browser process | ✅ Only needs to activate extension | +| **Response Speed** | 50-200ms inter-process communication | ✅ Faster | + +## 🚀 Quick Start + +### Prerequisites + +- Node.js >= 20.0.0 and pnpm/npm +- Chrome/Chromium browser + +### Installation Steps + +1. **Download the latest Chrome extension from GitHub** + +Download link: https://github.com/hangwin/mcp-chrome/releases + +2. **Install mcp-chrome-bridge globally** + +npm + +```bash +npm install -g mcp-chrome-bridge +``` + +pnpm + +```bash +# Method 1: Enable scripts globally (recommended) +pnpm config set enable-pre-post-scripts true +pnpm install -g mcp-chrome-bridge + +# Method 2: Manual registration (if postinstall doesn't run) +pnpm install -g mcp-chrome-bridge +mcp-chrome-bridge register +``` + +> Note: pnpm v7+ disables postinstall scripts by default for security. The `enable-pre-post-scripts` setting controls whether pre/post install scripts run. If automatic registration fails, use the manual registration command above. + +3. **Load Chrome Extension** + - Open Chrome and go to `chrome://extensions/` + - Enable "Developer mode" + - Click "Load unpacked" and select `your/dowloaded/extension/folder` + - Click the extension icon to open the plugin, then click connect to see the MCP configuration + Screenshot 2025-06-09 15 52 06 + +### Usage with MCP Protocol Clients + +#### Using Streamable HTTP Connection (👍🏻 Recommended) + +Add the following configuration to your MCP client configuration (using CherryStudio as an example): + +> Streamable HTTP connection method is recommended + +```json +{ + "mcpServers": { + "chrome-mcp-server": { + "type": "streamableHttp", + "url": "http://127.0.0.1:12306/mcp" + } + } +} +``` + +#### Using STDIO Connection (Alternative) + +If your client only supports stdio connection method, please use the following approach: + +1. First, check the installation location of the npm package you just installed + +```sh +# npm check method +npm list -g mcp-chrome-bridge +# pnpm check method +pnpm list -g mcp-chrome-bridge +``` + +Assuming the command above outputs the path: /Users/xxx/Library/pnpm/global/5 +Then your final path would be: /Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js + +2. Replace the configuration below with the final path you just obtained + +```json +{ + "mcpServers": { + "chrome-mcp-stdio": { + "command": "npx", + "args": [ + "node", + "/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js" + ] + } + } +} +``` + +eg:config in augment: + +截屏2025-06-22 22 11 25 + +## 🛠️ Available Tools + +Complete tool list: [Complete Tool List](docs/TOOLS.md) + +
+📊 Browser Management (6 tools) + +- `get_windows_and_tabs` - List all browser windows and tabs +- `chrome_navigate` - Navigate to URLs and control viewport +- `chrome_switch_tab` - Switch the current active tab +- `chrome_close_tabs` - Close specific tabs or windows +- `chrome_go_back_or_forward` - Browser navigation control +- `chrome_inject_script` - Inject content scripts into web pages +- `chrome_send_command_to_inject_script` - Send commands to injected content scripts +
+ +
+📸 Screenshots & Visual (1 tool) + +- `chrome_screenshot` - Advanced screenshot capture with element targeting, full-page support, and custom dimensions +
+ +
+🌐 Network Monitoring (4 tools) + +- `chrome_network_capture_start/stop` - webRequest API network capture +- `chrome_network_debugger_start/stop` - Debugger API with response bodies +- `chrome_network_request` - Send custom HTTP requests +
+ +
+🔍 Content Analysis (4 tools) + +- `search_tabs_content` - AI-powered semantic search across browser tabs +- `chrome_get_web_content` - Extract HTML/text content from pages +- `chrome_get_interactive_elements` - Find clickable elements +- `chrome_console` - Capture and retrieve console output from browser tabs +
+ +
+🎯 Interaction (3 tools) + +- `chrome_click_element` - Click elements using CSS selectors +- `chrome_fill_or_select` - Fill forms and select options +- `chrome_keyboard` - Simulate keyboard input and shortcuts +
+ +
+📚 Data Management (5 tools) + +- `chrome_history` - Search browser history with time filters +- `chrome_bookmark_search` - Find bookmarks by keywords +- `chrome_bookmark_add` - Add new bookmarks with folder support +- `chrome_bookmark_delete` - Delete bookmarks +
+ +## 🧪 Usage Examples + +### AI helps you summarize webpage content and automatically control Excalidraw for drawing + +prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md) +Instruction: Help me summarize the current page content, then draw a diagram to aid my understanding. +https://www.youtube.com/watch?v=3fBPdUBWVz0 + +https://github.com/user-attachments/assets/fd17209b-303d-48db-9e5e-3717141df183 + +### After analyzing the content of the image, the LLM automatically controls Excalidraw to replicate the image + +prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md) +Instruction: First, analyze the content of the image, and then replicate the image by combining the analysis with the content of the image. +https://www.youtube.com/watch?v=tEPdHZBzbZk + +https://github.com/user-attachments/assets/60d12b1a-9b74-40f4-994c-95e8fa1fc8d3 + +### AI automatically injects scripts and modifies webpage styles + +prompt: [modify-web-prompt](prompt/modify-web.md) +Instruction: Help me modify the current page's style and remove advertisements. +https://youtu.be/twI6apRKHsk + +https://github.com/user-attachments/assets/69cb561c-2e1e-4665-9411-4a3185f9643e + +### AI automatically captures network requests for you + +query: I want to know what the search API for Xiaohongshu is and what the response structure looks like + +https://youtu.be/1hHKr7XKqnQ + +https://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9 + +### AI helps analyze your browsing history + +query: Analyze my browsing history from the past month + +https://youtu.be/jf2UZfrR2Vk + +https://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae + +### Web page conversation + +query: Translate and summarize the current web page +https://youtu.be/FlJKS9UQyC8 + +https://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396 + +### AI automatically takes screenshots for you (web page screenshots) + +query: Take a screenshot of Hugging Face's homepage +https://youtu.be/7ycK6iksWi4 + +https://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3 + +### AI automatically takes screenshots for you (element screenshots) + +query: Capture the icon from Hugging Face's homepage +https://youtu.be/ev8VivANIrk + +https://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c + +### AI helps manage bookmarks + +query: Add the current page to bookmarks and put it in an appropriate folder + +https://youtu.be/R_83arKmFTo + +https://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0 + +### Automatically close web pages + +query: Close all shadcn-related web pages + +https://youtu.be/2wzUT6eNVg4 + +https://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592 + +## 🤝 Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](docs/CONTRIBUTING.md) for detailed guidelines. + +## 🚧 Future Roadmap + +We have exciting plans for the future development of Chrome MCP Server: + +- [ ] Authentication +- [ ] Recording and Playback +- [ ] Workflow Automation +- [ ] Enhanced Browser Support (Firefox Extension) + +--- + +**Want to contribute to any of these features?** Check out our [Contributing Guide](docs/CONTRIBUTING.md) and join our development community! + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 📚 More Documentation + +- [Architecture Design](docs/ARCHITECTURE.md) - Detailed technical architecture documentation +- [TOOLS API](docs/TOOLS.md) - Complete tool API documentation +- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issue solutions diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..cc75fd5 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`hangwin/mcp-chrome` +- 原始仓库:https://github.com/hangwin/mcp-chrome +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..016eea4 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,308 @@ +# Chrome MCP Server 🚀 + +[![许可证: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-blue.svg)](https://www.typescriptlang.org/) +[![Chrome 扩展](https://img.shields.io/badge/Chrome-Extension-green.svg)](https://developer.chrome.com/docs/extensions/) + +> 🌟 **让chrome浏览器变成你的智能助手** - 让AI接管你的浏览器,将您的浏览器转变为强大的 AI 控制自动化工具。 + +**📖 文档**: [English](README.md) | [中文](README_zh.md) + +> 项目仍处于早期阶段,正在紧锣密鼓开发中,后续将有更多新功能,以及稳定性等的提升,如遇bug,请轻喷 + +--- + +## 🎯 什么是 Chrome MCP Server? + +Chrome MCP Server 是一个基于chrome插件的 **模型上下文协议 (MCP) 服务器**,它将您的 Chrome 浏览器功能暴露给 Claude 等 AI 助手,实现复杂的浏览器自动化、内容分析和语义搜索等。与传统的浏览器自动化工具(如playwright)不同,**Chrome MCP server**直接使用您日常使用的chrome浏览器,基于现有的用户习惯和配置、登录态,让各种大模型或者各种chatbot都可以接管你的浏览器,真正成为你的日常助手 + +## ✨ 船新的功能(2025/12/30) + +- **让Claude Code/Codex也能使用的可视化编辑器**, 更多详情请看: [VisualEditor](docs/VisualEditor_zh.md) + +## ✨ 核心特性 + +- 😁 **chatbot/模型无关**:让任意你喜欢的llm或chatbot客户端或agent来自动化操作你的浏览器 +- ⭐️ **使用你原本的浏览器**:无缝集成用户本身的浏览器环境(你的配置、登录态等) +- 💻 **完全本地运行**:纯本地运行的mcp server,保证用户隐私 +- 🚄 **Streamable http**:Streamable http的连接方式 +- 🏎 **跨标签页** 跨标签页的上下文 +- 🧠 **语义搜索**:内置向量数据库和本地小模型,智能发现浏览器标签页内容 +- 🔍 **智能内容分析**:AI 驱动的文本提取和相似度匹配 +- 🌐 **20+ 工具**:支持截图、网络监控、交互操作、书签管理、浏览历史等20多种工具 +- 🚀 **SIMD 加速 AI**:自定义 WebAssembly SIMD 优化,向量运算速度提升 4-8 倍 + +## 🆚 与同类项目对比 + +| 对比维度 | 基于Playwright的MCP Server | 基于Chrome插件的MCP Server | +| ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------- | +| **资源占用** | ❌ 需启动独立浏览器进程,需要安装Playwright依赖,下载浏览器二进制等 | ✅ 无需启动独立的浏览器进程,直接利用用户已打开的Chrome浏览器 | +| **用户会话复用** | ❌ 需重新登录 | ✅ 自动使用已登录状态 | +| **浏览器环境保持** | ❌ 干净环境缺少用户设置 | ✅ 完整保留用户环境 | +| **API访问权限** | ⚠️ 受限于Playwright API | ✅ Chrome原生API全访问 | +| **启动速度** | ❌ 需启动浏览器进程 | ✅ 只需激活插件 | +| **响应速度** | 50-200ms进程间通信 | ✅ 更快 | + +## 🚀 快速开始 + +### 环境要求 + +- Node.js >= 20.0.0 和 (npm 或 pnpm) +- Chrome/Chromium 浏览器 + +### 安装步骤 + +1. **从github上下载最新的chrome扩展** + +下载地址:https://github.com/hangwin/mcp-chrome/releases + +2. **全局安装mcp-chrome-bridge** + +npm + +```bash +npm install -g mcp-chrome-bridge +``` + +pnpm + +```bash +# 方法1:全局启用脚本(推荐) +pnpm config set enable-pre-post-scripts true +pnpm install -g mcp-chrome-bridge + +# 方法2:如果 postinstall 没有运行,手动注册 +pnpm install -g mcp-chrome-bridge +mcp-chrome-bridge register +``` + +> 注意:pnpm v7+ 默认禁用 postinstall 脚本以提高安全性。`enable-pre-post-scripts` 设置控制是否运行 pre/post 安装脚本。如果自动注册失败,请使用上述手动注册命令。 + +3. **加载 Chrome 扩展** + - 打开 Chrome 并访问 `chrome://extensions/` + - 启用"开发者模式" + - 点击"加载已解压的扩展程序",选择 `your/dowloaded/extension/folder` + - 点击插件图标打开插件,点击连接即可看到mcp的配置 + 截屏2025-06-09 15 52 06 + +### 在支持MCP协议的客户端中使用 + +#### 使用streamable http的方式连接(👍🏻推荐) + +将以下配置添加到客户端的 MCP 配置中以cherryStudio为例: + +> 推荐用streamable http的连接方式 + +```json +{ + "mcpServers": { + "chrome-mcp-server": { + "type": "streamableHttp", + "url": "http://127.0.0.1:12306/mcp" + } + } +} +``` + +#### 使用stdio的方式连接(备选) + +假设你的客户端仅支持stdio的连接方式,那么请使用下面的方法: + +1. 先查看你刚刚安装的npm包的安装位置 + +```sh +# npm 查看方式 +npm list -g mcp-chrome-bridge +# pnpm 查看方式 +pnpm list -g mcp-chrome-bridge +``` + +假设上面的命令输出的路径是:/Users/xxx/Library/pnpm/global/5 +那么你的最终路径就是:/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js + +2. 把下面的配置替换成你刚刚得到的最终路径 + +```json +{ + "mcpServers": { + "chrome-mcp-stdio": { + "command": "npx", + "args": [ + "node", + "/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js" + ] + } + } +} +``` + +比如:在augment中的配置如下: + +截屏2025-06-22 22 11 25 + +## 🛠️ 可用工具 + +完整工具列表:[完整工具列表](docs/TOOLS_zh.md) + +
+📊 浏览器管理 (6个工具) + +- `get_windows_and_tabs` - 列出所有浏览器窗口和标签页 +- `chrome_navigate` - 导航到 URL 并控制视口 +- `chrome_switch_tab` - 切换当前显示的标签页 +- `chrome_close_tabs` - 关闭特定标签页或窗口 +- `chrome_go_back_or_forward` - 浏览器导航控制 +- `chrome_inject_script` - 向网页注入内容脚本 +- `chrome_send_command_to_inject_script` - 向已注入的内容脚本发送指令 +
+ +
+📸 截图和视觉 (1个工具) + +- `chrome_screenshot` - 高级截图捕获,支持元素定位、全页面和自定义尺寸 +
+ +
+🌐 网络监控 (4个工具) + +- `chrome_network_capture_start/stop` - webRequest API 网络捕获 +- `chrome_network_debugger_start/stop` - Debugger API 包含响应体 +- `chrome_network_request` - 发送自定义 HTTP 请求 +
+ +
+🔍 内容分析 (4个工具) + +- `search_tabs_content` - AI 驱动的浏览器标签页语义搜索 +- `chrome_get_web_content` - 从页面提取 HTML/文本内容 +- `chrome_get_interactive_elements` - 查找可点击元素 +- `chrome_console` - 捕获和获取浏览器标签页的控制台输出 +
+ +
+🎯 交互操作 (3个工具) + +- `chrome_click_element` - 使用 CSS 选择器点击元素 +- `chrome_fill_or_select` - 填充表单和选择选项 +- `chrome_keyboard` - 模拟键盘输入和快捷键 +
+ +
+📚 数据管理 (5个工具) + +- `chrome_history` - 搜索浏览器历史记录,支持时间过滤 +- `chrome_bookmark_search` - 按关键词查找书签 +- `chrome_bookmark_add` - 添加新书签,支持文件夹 +- `chrome_bookmark_delete` - 删除书签 +
+ +## 🧪 使用示例 + +### ai帮你总结网页内容然后自动控制excalidraw画图 + +prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md) +指令:帮我总结当前页面内容,然后画个图帮我理解 +https://www.youtube.com/watch?v=3fBPdUBWVz0 + +https://github.com/user-attachments/assets/f14f79a6-9390-4821-8296-06d020bcfc07 + +### ai先分析图片的内容元素,然后再自动控制excalidraw把图片模仿出来 + +prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md) +指令:先看下图片是否能用excalidraw画出来,如果则列出所需的步骤和元素,然后画出来 +https://www.youtube.com/watch?v=tEPdHZBzbZk + +https://github.com/user-attachments/assets/4f0600c1-bb1e-4b57-85ab-36c8bdf71c68 + +### ai自动帮你注入脚本并修改网页的样式 + +prompt: [modify-web-prompt](prompt/modify-web.md) +指令:帮我修改当前页面的样式,去掉广告 +https://youtu.be/twI6apRKHsk + +https://github.com/user-attachments/assets/aedbe98d-e90c-4a58-a4a5-d888f7293d8e + +### ai自动帮你捕获网络请求 + +指令:我想知道小红书的搜索接口是哪个,响应体结构是什么样的 +https://youtu.be/1hHKr7XKqnQ + +https://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9 + +### ai帮你分析你的浏览记录 + +指令:分析一下我近一个月的浏览记录 +https://youtu.be/jf2UZfrR2Vk + +https://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae + +### 网页对话 + +指令:翻译并总结当前网页 +https://youtu.be/FlJKS9UQyC8 + +https://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396 + +### ai帮你自动截图(网页截图) + +指令:把huggingface的首页截个图 +https://youtu.be/7ycK6iksWi4 + +https://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3 + +### ai帮你自动截图(元素截图) + +指令:把huggingface首页的图标截取下来 +https://youtu.be/ev8VivANIrk + +https://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c + +### ai帮你管理书签 + +指令:将当前页面添加到书签中,放到合适的文件夹 +https://youtu.be/R_83arKmFTo + +https://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0 + +### 自动关闭网页 + +指令:关闭所有shadcn相关的网页 +https://youtu.be/2wzUT6eNVg4 + +https://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592 + +## 🤝 贡献指南 + +我们欢迎贡献!请查看 [CONTRIBUTING_zh.md](docs/CONTRIBUTING_zh.md) 了解详细指南。 + +## 🚧 未来发展路线图 + +我们对 Chrome MCP Server 的未来发展有着激动人心的计划: + +- [ ] 身份认证 + +- [ ] 录制与回放 + +- [ ] 工作流自动化 + +- [ ] 增强浏览器支持(Firefox 扩展) + +--- + +**想要为这些功能中的任何一个做贡献?** 查看我们的[贡献指南](docs/CONTRIBUTING_zh.md)并加入我们的开发社区! + +## 📄 许可证 + +本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。 + +## 📚 更多文档 + +- [架构设计](docs/ARCHITECTURE_zh.md) - 详细的技术架构说明 +- [工具列表](docs/TOOLS_zh.md) - 完整的工具 API 文档 +- [故障排除](docs/TROUBLESHOOTING_zh.md) - 常见问题解决方案 + +## 微信交流群 + +拉群的目的是让踩过坑的大佬们互相帮忙解答问题,因本人平时要忙着搬砖,不一定能及时解答 + +![IMG_6296](https://github.com/user-attachments/assets/ecd2e084-24d2-4038-b75f-3ab020b55594) diff --git a/app/chrome-extension/.env.example b/app/chrome-extension/.env.example new file mode 100644 index 0000000..059e92b --- /dev/null +++ b/app/chrome-extension/.env.example @@ -0,0 +1,4 @@ +# Chrome Extension Private Key +# Copy this file to .env and replace with your actual private key +# This key is used for Chrome extension packaging and should be kept secure +CHROME_EXTENSION_KEY=YOUR_PRIVATE_KEY_HERE diff --git a/app/chrome-extension/LICENSE b/app/chrome-extension/LICENSE new file mode 100644 index 0000000..680cc81 --- /dev/null +++ b/app/chrome-extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 hangwin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/chrome-extension/README.md b/app/chrome-extension/README.md new file mode 100644 index 0000000..a6fa483 --- /dev/null +++ b/app/chrome-extension/README.md @@ -0,0 +1,7 @@ +# WXT + Vue 3 + +This template should help get you started developing with Vue 3 in WXT. + +## Recommended IDE Setup + +- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar). diff --git a/app/chrome-extension/_locales/de/messages.json b/app/chrome-extension/_locales/de/messages.json new file mode 100644 index 0000000..33b486e --- /dev/null +++ b/app/chrome-extension/_locales/de/messages.json @@ -0,0 +1,446 @@ +{ + "extensionName": { + "message": "chrome-mcp-server", + "description": "Erweiterungsname" + }, + "extensionDescription": { + "message": "Stellt Browser-Funktionen mit Ihrem eigenen Chrome zur Verfügung", + "description": "Erweiterungsbeschreibung" + }, + "nativeServerConfigLabel": { + "message": "Native Server-Konfiguration", + "description": "Hauptabschnittstitel für Native Server-Einstellungen" + }, + "semanticEngineLabel": { + "message": "Semantische Engine", + "description": "Hauptabschnittstitel für semantische Engine" + }, + "embeddingModelLabel": { + "message": "Embedding-Modell", + "description": "Hauptabschnittstitel für Modellauswahl" + }, + "indexDataManagementLabel": { + "message": "Index-Datenverwaltung", + "description": "Hauptabschnittstitel für Datenverwaltung" + }, + "modelCacheManagementLabel": { + "message": "Modell-Cache-Verwaltung", + "description": "Hauptabschnittstitel für Cache-Verwaltung" + }, + "statusLabel": { + "message": "Status", + "description": "Allgemeines Statuslabel" + }, + "runningStatusLabel": { + "message": "Betriebsstatus", + "description": "Server-Betriebsstatuslabel" + }, + "connectionStatusLabel": { + "message": "Verbindungsstatus", + "description": "Verbindungsstatuslabel" + }, + "lastUpdatedLabel": { + "message": "Zuletzt aktualisiert:", + "description": "Zeitstempel der letzten Aktualisierung" + }, + "connectButton": { + "message": "Verbinden", + "description": "Verbinden-Schaltflächentext" + }, + "disconnectButton": { + "message": "Trennen", + "description": "Trennen-Schaltflächentext" + }, + "connectingStatus": { + "message": "Verbindung wird hergestellt...", + "description": "Verbindungsstatusmeldung" + }, + "connectedStatus": { + "message": "Verbunden", + "description": "Verbunden-Statusmeldung" + }, + "disconnectedStatus": { + "message": "Getrennt", + "description": "Getrennt-Statusmeldung" + }, + "detectingStatus": { + "message": "Erkennung läuft...", + "description": "Erkennungsstatusmeldung" + }, + "serviceRunningStatus": { + "message": "Service läuft (Port: $PORT$)", + "description": "Service läuft mit Portnummer", + "placeholders": { + "port": { + "content": "$1", + "example": "12306" + } + } + }, + "serviceNotConnectedStatus": { + "message": "Service nicht verbunden", + "description": "Service nicht verbunden Status" + }, + "connectedServiceNotStartedStatus": { + "message": "Verbunden, Service nicht gestartet", + "description": "Verbunden aber Service nicht gestartet Status" + }, + "mcpServerConfigLabel": { + "message": "MCP Server-Konfiguration", + "description": "MCP Server-Konfigurationsabschnittslabel" + }, + "connectionPortLabel": { + "message": "Verbindungsport", + "description": "Verbindungsport-Eingabelabel" + }, + "refreshStatusButton": { + "message": "Status aktualisieren", + "description": "Status aktualisieren Schaltflächen-Tooltip" + }, + "copyConfigButton": { + "message": "Konfiguration kopieren", + "description": "Konfiguration kopieren Schaltflächentext" + }, + "retryButton": { + "message": "Wiederholen", + "description": "Wiederholen-Schaltflächentext" + }, + "cancelButton": { + "message": "Abbrechen", + "description": "Abbrechen-Schaltflächentext" + }, + "confirmButton": { + "message": "Bestätigen", + "description": "Bestätigen-Schaltflächentext" + }, + "saveButton": { + "message": "Speichern", + "description": "Speichern-Schaltflächentext" + }, + "closeButton": { + "message": "Schließen", + "description": "Schließen-Schaltflächentext" + }, + "resetButton": { + "message": "Zurücksetzen", + "description": "Zurücksetzen-Schaltflächentext" + }, + "initializingStatus": { + "message": "Initialisierung...", + "description": "Initialisierung-Fortschrittsmeldung" + }, + "processingStatus": { + "message": "Verarbeitung...", + "description": "Verarbeitung-Fortschrittsmeldung" + }, + "loadingStatus": { + "message": "Wird geladen...", + "description": "Ladefortschrittsmeldung" + }, + "clearingStatus": { + "message": "Wird geleert...", + "description": "Leerungsfortschrittsmeldung" + }, + "cleaningStatus": { + "message": "Wird bereinigt...", + "description": "Bereinigungsfortschrittsmeldung" + }, + "downloadingStatus": { + "message": "Wird heruntergeladen...", + "description": "Download-Fortschrittsmeldung" + }, + "semanticEngineReadyStatus": { + "message": "Semantische Engine bereit", + "description": "Semantische Engine bereit Status" + }, + "semanticEngineInitializingStatus": { + "message": "Semantische Engine wird initialisiert...", + "description": "Semantische Engine Initialisierungsstatus" + }, + "semanticEngineInitFailedStatus": { + "message": "Initialisierung der semantischen Engine fehlgeschlagen", + "description": "Semantische Engine Initialisierung fehlgeschlagen Status" + }, + "semanticEngineNotInitStatus": { + "message": "Semantische Engine nicht initialisiert", + "description": "Semantische Engine nicht initialisiert Status" + }, + "initSemanticEngineButton": { + "message": "Semantische Engine initialisieren", + "description": "Semantische Engine initialisieren Schaltflächentext" + }, + "reinitializeButton": { + "message": "Neu initialisieren", + "description": "Neu initialisieren Schaltflächentext" + }, + "downloadingModelStatus": { + "message": "Modell wird heruntergeladen... $PROGRESS$%", + "description": "Modell-Download-Fortschritt mit Prozentsatz", + "placeholders": { + "progress": { + "content": "$1", + "example": "50" + } + } + }, + "switchingModelStatus": { + "message": "Modell wird gewechselt...", + "description": "Modellwechsel-Fortschrittsmeldung" + }, + "modelLoadedStatus": { + "message": "Modell geladen", + "description": "Modell erfolgreich geladen Status" + }, + "modelFailedStatus": { + "message": "Modell konnte nicht geladen werden", + "description": "Modell-Ladefehler Status" + }, + "lightweightModelDescription": { + "message": "Leichtgewichtiges mehrsprachiges Modell", + "description": "Beschreibung für leichtgewichtige Modelloption" + }, + "betterThanSmallDescription": { + "message": "Etwas größer als e5-small, aber bessere Leistung", + "description": "Beschreibung für mittlere Modelloption" + }, + "multilingualModelDescription": { + "message": "Mehrsprachiges semantisches Modell", + "description": "Beschreibung für mehrsprachige Modelloption" + }, + "fastPerformance": { + "message": "Schnell", + "description": "Schnelle Leistungsanzeige" + }, + "balancedPerformance": { + "message": "Ausgewogen", + "description": "Ausgewogene Leistungsanzeige" + }, + "accuratePerformance": { + "message": "Genau", + "description": "Genaue Leistungsanzeige" + }, + "networkErrorMessage": { + "message": "Netzwerkverbindungsfehler, bitte Netzwerk prüfen und erneut versuchen", + "description": "Netzwerkverbindungsfehlermeldung" + }, + "modelCorruptedErrorMessage": { + "message": "Modelldatei beschädigt oder unvollständig, bitte Download wiederholen", + "description": "Modell-Beschädigungsfehlermeldung" + }, + "unknownErrorMessage": { + "message": "Unbekannter Fehler, bitte prüfen Sie, ob Ihr Netzwerk auf HuggingFace zugreifen kann", + "description": "Unbekannte Fehler-Rückfallmeldung" + }, + "permissionDeniedErrorMessage": { + "message": "Zugriff verweigert", + "description": "Zugriff verweigert Fehlermeldung" + }, + "timeoutErrorMessage": { + "message": "Zeitüberschreitung", + "description": "Zeitüberschreitungsfehlermeldung" + }, + "indexedPagesLabel": { + "message": "Indizierte Seiten", + "description": "Anzahl indizierter Seiten Label" + }, + "indexSizeLabel": { + "message": "Indexgröße", + "description": "Indexgröße Label" + }, + "activeTabsLabel": { + "message": "Aktive Tabs", + "description": "Anzahl aktiver Tabs Label" + }, + "vectorDocumentsLabel": { + "message": "Vektordokumente", + "description": "Anzahl Vektordokumente Label" + }, + "cacheSizeLabel": { + "message": "Cache-Größe", + "description": "Cache-Größe Label" + }, + "cacheEntriesLabel": { + "message": "Cache-Einträge", + "description": "Anzahl Cache-Einträge Label" + }, + "clearAllDataButton": { + "message": "Alle Daten löschen", + "description": "Alle Daten löschen Schaltflächentext" + }, + "clearAllCacheButton": { + "message": "Gesamten Cache löschen", + "description": "Gesamten Cache löschen Schaltflächentext" + }, + "cleanExpiredCacheButton": { + "message": "Abgelaufenen Cache bereinigen", + "description": "Abgelaufenen Cache bereinigen Schaltflächentext" + }, + "exportDataButton": { + "message": "Daten exportieren", + "description": "Daten exportieren Schaltflächentext" + }, + "importDataButton": { + "message": "Daten importieren", + "description": "Daten importieren Schaltflächentext" + }, + "confirmClearDataTitle": { + "message": "Datenlöschung bestätigen", + "description": "Datenlöschung bestätigen Dialogtitel" + }, + "settingsTitle": { + "message": "Einstellungen", + "description": "Einstellungen Dialogtitel" + }, + "aboutTitle": { + "message": "Über", + "description": "Über Dialogtitel" + }, + "helpTitle": { + "message": "Hilfe", + "description": "Hilfe Dialogtitel" + }, + "clearDataWarningMessage": { + "message": "Diese Aktion löscht alle indizierten Webseiteninhalte und Vektordaten, einschließlich:", + "description": "Datenlöschung Warnmeldung" + }, + "clearDataList1": { + "message": "Alle Webseitentextinhaltsindizes", + "description": "Erster Punkt in Datenlöschungsliste" + }, + "clearDataList2": { + "message": "Vektor-Embedding-Daten", + "description": "Zweiter Punkt in Datenlöschungsliste" + }, + "clearDataList3": { + "message": "Suchverlauf und Cache", + "description": "Dritter Punkt in Datenlöschungsliste" + }, + "clearDataIrreversibleWarning": { + "message": "Diese Aktion ist unwiderruflich! Nach dem Löschen müssen Sie Webseiten erneut durchsuchen, um den Index neu aufzubauen.", + "description": "Unwiderrufliche Aktion Warnung" + }, + "confirmClearButton": { + "message": "Löschung bestätigen", + "description": "Löschung bestätigen Aktionsschaltfläche" + }, + "cacheDetailsLabel": { + "message": "Cache-Details", + "description": "Cache-Details Abschnittslabel" + }, + "noCacheDataMessage": { + "message": "Keine Cache-Daten vorhanden", + "description": "Keine Cache-Daten verfügbar Meldung" + }, + "loadingCacheInfoStatus": { + "message": "Cache-Informationen werden geladen...", + "description": "Cache-Informationen laden Status" + }, + "processingCacheStatus": { + "message": "Cache wird verarbeitet...", + "description": "Cache verarbeiten Status" + }, + "expiredLabel": { + "message": "Abgelaufen", + "description": "Abgelaufenes Element Label" + }, + "bookmarksBarLabel": { + "message": "Lesezeichenleiste", + "description": "Lesezeichenleiste Ordnername" + }, + "newTabLabel": { + "message": "Neuer Tab", + "description": "Neuer Tab Label" + }, + "currentPageLabel": { + "message": "Aktuelle Seite", + "description": "Aktuelle Seite Label" + }, + "menuLabel": { + "message": "Menü", + "description": "Menü Barrierefreiheitslabel" + }, + "navigationLabel": { + "message": "Navigation", + "description": "Navigation Barrierefreiheitslabel" + }, + "mainContentLabel": { + "message": "Hauptinhalt", + "description": "Hauptinhalt Barrierefreiheitslabel" + }, + "languageSelectorLabel": { + "message": "Sprache", + "description": "Sprachauswahl Label" + }, + "themeLabel": { + "message": "Design", + "description": "Design-Auswahl Label" + }, + "lightTheme": { + "message": "Hell", + "description": "Helles Design Option" + }, + "darkTheme": { + "message": "Dunkel", + "description": "Dunkles Design Option" + }, + "autoTheme": { + "message": "Automatisch", + "description": "Automatisches Design Option" + }, + "advancedSettingsLabel": { + "message": "Erweiterte Einstellungen", + "description": "Erweiterte Einstellungen Abschnittslabel" + }, + "debugModeLabel": { + "message": "Debug-Modus", + "description": "Debug-Modus Umschalter Label" + }, + "verboseLoggingLabel": { + "message": "Ausführliche Protokollierung", + "description": "Ausführliche Protokollierung Umschalter Label" + }, + "successNotification": { + "message": "Vorgang erfolgreich abgeschlossen", + "description": "Allgemeine Erfolgsmeldung" + }, + "warningNotification": { + "message": "Warnung: Bitte prüfen Sie vor dem Fortfahren", + "description": "Allgemeine Warnmeldung" + }, + "infoNotification": { + "message": "Information", + "description": "Allgemeine Informationsmeldung" + }, + "configCopiedNotification": { + "message": "Konfiguration in Zwischenablage kopiert", + "description": "Konfiguration kopiert Erfolgsmeldung" + }, + "dataClearedNotification": { + "message": "Daten erfolgreich gelöscht", + "description": "Daten gelöscht Erfolgsmeldung" + }, + "bytesUnit": { + "message": "Bytes", + "description": "Bytes Einheit" + }, + "kilobytesUnit": { + "message": "KB", + "description": "Kilobytes Einheit" + }, + "megabytesUnit": { + "message": "MB", + "description": "Megabytes Einheit" + }, + "gigabytesUnit": { + "message": "GB", + "description": "Gigabytes Einheit" + }, + "itemsUnit": { + "message": "Elemente", + "description": "Elemente Zähleinheit" + }, + "pagesUnit": { + "message": "Seiten", + "description": "Seiten Zähleinheit" + } +} \ No newline at end of file diff --git a/app/chrome-extension/_locales/en/messages.json b/app/chrome-extension/_locales/en/messages.json new file mode 100644 index 0000000..cc9b8f5 --- /dev/null +++ b/app/chrome-extension/_locales/en/messages.json @@ -0,0 +1,504 @@ +{ + "extensionName": { + "message": "chrome-mcp-server", + "description": "Extension name" + }, + "extensionDescription": { + "message": "Exposes browser capabilities with your own chrome", + "description": "Extension description" + }, + "nativeServerConfigLabel": { + "message": "Native Server Configuration", + "description": "Main section header for native server settings" + }, + "semanticEngineLabel": { + "message": "Semantic Engine", + "description": "Main section header for semantic engine" + }, + "embeddingModelLabel": { + "message": "Embedding Model", + "description": "Main section header for model selection" + }, + "indexDataManagementLabel": { + "message": "Index Data Management", + "description": "Main section header for data management" + }, + "modelCacheManagementLabel": { + "message": "Model Cache Management", + "description": "Main section header for cache management" + }, + "statusLabel": { + "message": "Status", + "description": "Generic status label" + }, + "runningStatusLabel": { + "message": "Running Status", + "description": "Server running status label" + }, + "connectionStatusLabel": { + "message": "Connection Status", + "description": "Connection status label" + }, + "lastUpdatedLabel": { + "message": "Last Updated:", + "description": "Last updated timestamp label" + }, + "connectButton": { + "message": "Connect", + "description": "Connect button text" + }, + "disconnectButton": { + "message": "Disconnect", + "description": "Disconnect button text" + }, + "connectingStatus": { + "message": "Connecting...", + "description": "Connecting status message" + }, + "connectedStatus": { + "message": "Connected", + "description": "Connected status message" + }, + "disconnectedStatus": { + "message": "Disconnected", + "description": "Disconnected status message" + }, + "detectingStatus": { + "message": "Detecting...", + "description": "Detecting status message" + }, + "serviceRunningStatus": { + "message": "Service Running (Port: $PORT$)", + "description": "Service running with port number", + "placeholders": { + "port": { + "content": "$1", + "example": "12306" + } + } + }, + "serviceNotConnectedStatus": { + "message": "Service Not Connected", + "description": "Service not connected status" + }, + "connectedServiceNotStartedStatus": { + "message": "Connected, Service Not Started", + "description": "Connected but service not started status" + }, + "mcpServerConfigLabel": { + "message": "MCP Server Configuration", + "description": "MCP server configuration section label" + }, + "connectionPortLabel": { + "message": "Connection Port", + "description": "Connection port input label" + }, + "refreshStatusButton": { + "message": "Refresh Status", + "description": "Refresh status button tooltip" + }, + "copyConfigButton": { + "message": "Copy Configuration", + "description": "Copy configuration button text" + }, + "retryButton": { + "message": "Retry", + "description": "Retry button text" + }, + "cancelButton": { + "message": "Cancel", + "description": "Cancel button text" + }, + "confirmButton": { + "message": "Confirm", + "description": "Confirm button text" + }, + "saveButton": { + "message": "Save", + "description": "Save button text" + }, + "closeButton": { + "message": "Close", + "description": "Close button text" + }, + "resetButton": { + "message": "Reset", + "description": "Reset button text" + }, + "initializingStatus": { + "message": "Initializing...", + "description": "Initializing progress message" + }, + "processingStatus": { + "message": "Processing...", + "description": "Processing progress message" + }, + "loadingStatus": { + "message": "Loading...", + "description": "Loading progress message" + }, + "clearingStatus": { + "message": "Clearing...", + "description": "Clearing progress message" + }, + "cleaningStatus": { + "message": "Cleaning...", + "description": "Cleaning progress message" + }, + "downloadingStatus": { + "message": "Downloading...", + "description": "Downloading progress message" + }, + "semanticEngineReadyStatus": { + "message": "Semantic Engine Ready", + "description": "Semantic engine ready status" + }, + "semanticEngineInitializingStatus": { + "message": "Semantic Engine Initializing...", + "description": "Semantic engine initializing status" + }, + "semanticEngineInitFailedStatus": { + "message": "Semantic Engine Initialization Failed", + "description": "Semantic engine initialization failed status" + }, + "semanticEngineNotInitStatus": { + "message": "Semantic Engine Not Initialized", + "description": "Semantic engine not initialized status" + }, + "initSemanticEngineButton": { + "message": "Initialize Semantic Engine", + "description": "Initialize semantic engine button text" + }, + "reinitializeButton": { + "message": "Reinitialize", + "description": "Reinitialize button text" + }, + "downloadingModelStatus": { + "message": "Downloading Model... $PROGRESS$%", + "description": "Model download progress with percentage", + "placeholders": { + "progress": { + "content": "$1", + "example": "50" + } + } + }, + "switchingModelStatus": { + "message": "Switching Model...", + "description": "Model switching progress message" + }, + "modelLoadedStatus": { + "message": "Model Loaded", + "description": "Model successfully loaded status" + }, + "modelFailedStatus": { + "message": "Model Failed to Load", + "description": "Model failed to load status" + }, + "lightweightModelDescription": { + "message": "Lightweight Multilingual Model", + "description": "Description for lightweight model option" + }, + "betterThanSmallDescription": { + "message": "Slightly larger than e5-small, but better performance", + "description": "Description for medium model option" + }, + "multilingualModelDescription": { + "message": "Multilingual Semantic Model", + "description": "Description for multilingual model option" + }, + "fastPerformance": { + "message": "Fast", + "description": "Fast performance indicator" + }, + "balancedPerformance": { + "message": "Balanced", + "description": "Balanced performance indicator" + }, + "accuratePerformance": { + "message": "Accurate", + "description": "Accurate performance indicator" + }, + "networkErrorMessage": { + "message": "Network connection error, please check network and retry", + "description": "Network connection error message" + }, + "modelCorruptedErrorMessage": { + "message": "Model file corrupted or incomplete, please retry download", + "description": "Model corruption error message" + }, + "unknownErrorMessage": { + "message": "Unknown error, please check if your network can access HuggingFace", + "description": "Unknown error fallback message" + }, + "permissionDeniedErrorMessage": { + "message": "Permission denied", + "description": "Permission denied error message" + }, + "timeoutErrorMessage": { + "message": "Operation timed out", + "description": "Timeout error message" + }, + "indexedPagesLabel": { + "message": "Indexed Pages", + "description": "Number of indexed pages label" + }, + "indexSizeLabel": { + "message": "Index Size", + "description": "Index size label" + }, + "activeTabsLabel": { + "message": "Active Tabs", + "description": "Number of active tabs label" + }, + "vectorDocumentsLabel": { + "message": "Vector Documents", + "description": "Number of vector documents label" + }, + "cacheSizeLabel": { + "message": "Cache Size", + "description": "Cache size label" + }, + "cacheEntriesLabel": { + "message": "Cache Entries", + "description": "Number of cache entries label" + }, + "clearAllDataButton": { + "message": "Clear All Data", + "description": "Clear all data button text" + }, + "clearAllCacheButton": { + "message": "Clear All Cache", + "description": "Clear all cache button text" + }, + "cleanExpiredCacheButton": { + "message": "Clean Expired Cache", + "description": "Clean expired cache button text" + }, + "exportDataButton": { + "message": "Export Data", + "description": "Export data button text" + }, + "importDataButton": { + "message": "Import Data", + "description": "Import data button text" + }, + "confirmClearDataTitle": { + "message": "Confirm Clear Data", + "description": "Clear data confirmation dialog title" + }, + "settingsTitle": { + "message": "Settings", + "description": "Settings dialog title" + }, + "aboutTitle": { + "message": "About", + "description": "About dialog title" + }, + "helpTitle": { + "message": "Help", + "description": "Help dialog title" + }, + "clearDataWarningMessage": { + "message": "This operation will clear all indexed webpage content and vector data, including:", + "description": "Clear data warning message" + }, + "clearDataList1": { + "message": "All webpage text content index", + "description": "First item in clear data list" + }, + "clearDataList2": { + "message": "Vector embedding data", + "description": "Second item in clear data list" + }, + "clearDataList3": { + "message": "Search history and cache", + "description": "Third item in clear data list" + }, + "clearDataIrreversibleWarning": { + "message": "This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.", + "description": "Irreversible operation warning" + }, + "confirmClearButton": { + "message": "Confirm Clear", + "description": "Confirm clear action button" + }, + "cacheDetailsLabel": { + "message": "Cache Details", + "description": "Cache details section label" + }, + "noCacheDataMessage": { + "message": "No cache data", + "description": "No cache data available message" + }, + "loadingCacheInfoStatus": { + "message": "Loading cache information...", + "description": "Loading cache information status" + }, + "processingCacheStatus": { + "message": "Processing cache...", + "description": "Processing cache status" + }, + "expiredLabel": { + "message": "Expired", + "description": "Expired item label" + }, + "bookmarksBarLabel": { + "message": "Bookmarks Bar", + "description": "Bookmarks bar folder name" + }, + "newTabLabel": { + "message": "New Tab", + "description": "New tab label" + }, + "currentPageLabel": { + "message": "Current Page", + "description": "Current page label" + }, + "menuLabel": { + "message": "Menu", + "description": "Menu accessibility label" + }, + "navigationLabel": { + "message": "Navigation", + "description": "Navigation accessibility label" + }, + "mainContentLabel": { + "message": "Main Content", + "description": "Main content accessibility label" + }, + "languageSelectorLabel": { + "message": "Language", + "description": "Language selector label" + }, + "themeLabel": { + "message": "Theme", + "description": "Theme selector label" + }, + "lightTheme": { + "message": "Light", + "description": "Light theme option" + }, + "darkTheme": { + "message": "Dark", + "description": "Dark theme option" + }, + "autoTheme": { + "message": "Auto", + "description": "Auto theme option" + }, + "advancedSettingsLabel": { + "message": "Advanced Settings", + "description": "Advanced settings section label" + }, + "debugModeLabel": { + "message": "Debug Mode", + "description": "Debug mode toggle label" + }, + "verboseLoggingLabel": { + "message": "Verbose Logging", + "description": "Verbose logging toggle label" + }, + "successNotification": { + "message": "Operation completed successfully", + "description": "Generic success notification" + }, + "warningNotification": { + "message": "Warning: Please review before proceeding", + "description": "Generic warning notification" + }, + "infoNotification": { + "message": "Information", + "description": "Generic info notification" + }, + "configCopiedNotification": { + "message": "Configuration copied to clipboard", + "description": "Configuration copied success message" + }, + "dataClearedNotification": { + "message": "Data cleared successfully", + "description": "Data cleared success message" + }, + "bytesUnit": { + "message": "bytes", + "description": "Bytes unit" + }, + "kilobytesUnit": { + "message": "KB", + "description": "Kilobytes unit" + }, + "megabytesUnit": { + "message": "MB", + "description": "Megabytes unit" + }, + "gigabytesUnit": { + "message": "GB", + "description": "Gigabytes unit" + }, + "itemsUnit": { + "message": "items", + "description": "Items count unit" + }, + "pagesUnit": { + "message": "pages", + "description": "Pages count unit" + }, + "userscriptsManagerTitle": { + "message": "Userscripts Manager", + "description": "Options page title" + }, + "emergencySwitchLabel": { "message": "Emergency Switch", "description": "Global disable switch" }, + "createRunSectionTitle": { + "message": "Create / Run", + "description": "Create & run section title" + }, + "nameLabel": { "message": "Name", "description": "Name input label" }, + "runAtLabel": { "message": "Run At", "description": "runAt select label" }, + "runAtAuto": { "message": "auto", "description": "runAt auto" }, + "runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" }, + "runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" }, + "runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" }, + "worldLabel": { "message": "World", "description": "world select label" }, + "worldAuto": { "message": "auto", "description": "world auto" }, + "worldIsolated": { "message": "ISOLATED", "description": "ISOLATED world" }, + "worldMain": { "message": "MAIN", "description": "MAIN world" }, + "modeLabel": { "message": "Mode", "description": "mode select label" }, + "modeAuto": { "message": "auto", "description": "mode auto" }, + "modePersistent": { "message": "persistent", "description": "mode persistent" }, + "modeCss": { "message": "css", "description": "mode css" }, + "modeOnce": { "message": "once", "description": "mode once" }, + "allFramesLabel": { "message": "All Frames", "description": "allFrames checkbox" }, + "persistLabel": { "message": "Persist", "description": "persist checkbox" }, + "dnrFallbackLabel": { "message": "DNR Fallback", "description": "dnr fallback checkbox" }, + "matchesInputLabel": { "message": "Matches (comma-separated)", "description": "matches input" }, + "excludesInputLabel": { + "message": "Excludes (comma-separated)", + "description": "excludes input" + }, + "tagsInputLabel": { "message": "Tags (comma-separated)", "description": "tags input" }, + "scriptLabel": { "message": "Script", "description": "script textarea label" }, + "applyButton": { "message": "Apply", "description": "apply button" }, + "runOnceButton": { "message": "Run Once (CDP)", "description": "run once button" }, + "listSectionTitle": { "message": "List", "description": "list section title" }, + "queryLabel": { "message": "Query", "description": "query input label" }, + "statusAll": { "message": "all", "description": "status all" }, + "statusEnabled": { "message": "enabled", "description": "status enabled" }, + "statusDisabled": { "message": "disabled", "description": "status disabled" }, + "domainLabel": { "message": "Domain", "description": "domain filter label" }, + "exportAllButton": { "message": "Export All", "description": "export button" }, + "tableHeaderName": { "message": "Name", "description": "table header name" }, + "tableHeaderWorld": { "message": "World", "description": "table header world" }, + "tableHeaderRunAt": { "message": "Run At", "description": "table header runAt" }, + "tableHeaderUpdated": { "message": "Updated", "description": "table header updated" }, + "deleteButton": { "message": "Delete", "description": "delete button" }, + "placeholderOptional": { "message": "optional", "description": "generic optional placeholder" }, + "placeholderMatchesExample": { + "message": "e.g. https://*.example.com/*", + "description": "matches example placeholder" + }, + "placeholderScriptHint": { + "message": "Paste JS/CSS/TM here", + "description": "script textarea placeholder" + }, + "placeholderDomainHint": { "message": "example.com", "description": "domain filter placeholder" } +} diff --git a/app/chrome-extension/_locales/ja/messages.json b/app/chrome-extension/_locales/ja/messages.json new file mode 100644 index 0000000..7f71977 --- /dev/null +++ b/app/chrome-extension/_locales/ja/messages.json @@ -0,0 +1,338 @@ +{ + "extensionName": { + "message": "Chrome MCPサーバー" + }, + "extensionDescription": { + "message": "自身のChromeブラウザの機能を外部に公開します" + }, + "nativeServerConfigLabel": { + "message": "ネイティブサーバー設定" + }, + "semanticEngineLabel": { + "message": "セマンティックエンジン" + }, + "embeddingModelLabel": { + "message": "埋め込みモデル" + }, + "indexDataManagementLabel": { + "message": "インデックスデータ管理" + }, + "modelCacheManagementLabel": { + "message": "モデルキャッシュ管理" + }, + "statusLabel": { + "message": "ステータス" + }, + "runningStatusLabel": { + "message": "実行ステータス" + }, + "connectionStatusLabel": { + "message": "接続ステータス" + }, + "lastUpdatedLabel": { + "message": "最終更新:" + }, + "connectButton": { + "message": "接続" + }, + "disconnectButton": { + "message": "切断" + }, + "connectingStatus": { + "message": "接続中..." + }, + "connectedStatus": { + "message": "接続済み" + }, + "disconnectedStatus": { + "message": "未接続" + }, + "detectingStatus": { + "message": "検出中..." + }, + "serviceRunningStatus": { + "message": "サービス実行中 (ポート: $1)", + "placeholders": { + "port": { + "content": "$1", + "example": "12306" + } + } + }, + "serviceNotConnectedStatus": { + "message": "サービス未接続" + }, + "connectedServiceNotStartedStatus": { + "message": "接続済み、サービス未起動" + }, + "mcpServerConfigLabel": { + "message": "MCPサーバー設定" + }, + "connectionPortLabel": { + "message": "接続ポート" + }, + "refreshStatusButton": { + "message": "ステータス更新" + }, + "copyConfigButton": { + "message": "設定をコピー" + }, + "retryButton": { + "message": "再試行" + }, + "cancelButton": { + "message": "キャンセル" + }, + "confirmButton": { + "message": "確認" + }, + "saveButton": { + "message": "保存" + }, + "closeButton": { + "message": "閉じる" + }, + "resetButton": { + "message": "リセット" + }, + "initializingStatus": { + "message": "初期化中..." + }, + "processingStatus": { + "message": "処理中..." + }, + "loadingStatus": { + "message": "読み込み中..." + }, + "clearingStatus": { + "message": "クリア中..." + }, + "cleaningStatus": { + "message": "クリーンアップ中..." + }, + "downloadingStatus": { + "message": "ダウンロード中..." + }, + "semanticEngineReadyStatus": { + "message": "セマンティックエンジン準備完了" + }, + "semanticEngineInitializingStatus": { + "message": "セマンティックエンジン初期化中..." + }, + "semanticEngineInitFailedStatus": { + "message": "セマンティックエンジンの初期化に失敗しました" + }, + "semanticEngineNotInitStatus": { + "message": "セマンティックエンジン未初期化" + }, + "initSemanticEngineButton": { + "message": "セマンティックエンジンを初期化" + }, + "reinitializeButton": { + "message": "再初期化" + }, + "downloadingModelStatus": { + "message": "モデルをダウンロード中... $1%", + "placeholders": { + "progress": { + "content": "$1", + "example": "50" + } + } + }, + "switchingModelStatus": { + "message": "モデルを切り替え中..." + }, + "modelLoadedStatus": { + "message": "モデル読み込み完了" + }, + "modelFailedStatus": { + "message": "モデルの読み込みに失敗しました" + }, + "lightweightModelDescription": { + "message": "軽量多言語モデル" + }, + "betterThanSmallDescription": { + "message": "e5-smallよりわずかに大きいが、性能は向上" + }, + "multilingualModelDescription": { + "message": "多言語対応セマンティックモデル" + }, + "fastPerformance": { + "message": "高速" + }, + "balancedPerformance": { + "message": "バランス" + }, + "accuratePerformance": { + "message": "高精度" + }, + "networkErrorMessage": { + "message": "ネットワーク接続エラーです。ネットワークを確認して再試行してください" + }, + "modelCorruptedErrorMessage": { + "message": "モデルファイルが破損しているか不完全です。再ダウンロードしてください" + }, + "unknownErrorMessage": { + "message": "不明なエラーです。ネットワークがHuggingFaceにアクセスできるか確認してください" + }, + "permissionDeniedErrorMessage": { + "message": "権限が拒否されました" + }, + "timeoutErrorMessage": { + "message": "操作がタイムアウトしました" + }, + "indexedPagesLabel": { + "message": "インデックス化されたページ" + }, + "indexSizeLabel": { + "message": "インデックスサイズ" + }, + "activeTabsLabel": { + "message": "アクティブなタブ" + }, + "vectorDocumentsLabel": { + "message": "ベクトルドキュメント" + }, + "cacheSizeLabel": { + "message": "キャッシュサイズ" + }, + "cacheEntriesLabel": { + "message": "キャッシュエントリ" + }, + "clearAllDataButton": { + "message": "全データをクリア" + }, + "clearAllCacheButton": { + "message": "全キャッシュをクリア" + }, + "cleanExpiredCacheButton": { + "message": "期限切れキャッシュをクリーンアップ" + }, + "exportDataButton": { + "message": "データのエクスポート" + }, + "importDataButton": { + "message": "データのインポート" + }, + "confirmClearDataTitle": { + "message": "データクリアの確認" + }, + "settingsTitle": { + "message": "設定" + }, + "aboutTitle": { + "message": "情報" + }, + "helpTitle": { + "message": "ヘルプ" + }, + "clearDataWarningMessage": { + "message": "この操作は、インデックス化されたすべてのウェブページコンテンツとベクトルデータをクリアします。これには以下が含まれます:" + }, + "clearDataList1": { + "message": "すべてのウェブページテキストコンテンツインデックス" + }, + "clearDataList2": { + "message": "ベクトル埋め込みデータ" + }, + "clearDataList3": { + "message": "検索履歴とキャッシュ" + }, + "clearDataIrreversibleWarning": { + "message": "この操作は元に戻せません!クリア後、再度ウェブページを閲覧してインデックスを再構築する必要があります。" + }, + "confirmClearButton": { + "message": "クリアを確認" + }, + "cacheDetailsLabel": { + "message": "キャッシュ詳細" + }, + "noCacheDataMessage": { + "message": "キャッシュデータがありません" + }, + "loadingCacheInfoStatus": { + "message": "キャッシュ情報を読み込み中..." + }, + "processingCacheStatus": { + "message": "キャッシュを処理中..." + }, + "expiredLabel": { + "message": "期限切れ" + }, + "bookmarksBarLabel": { + "message": "ブックマークバー" + }, + "newTabLabel": { + "message": "新しいタブ" + }, + "currentPageLabel": { + "message": "現在のページ" + }, + "menuLabel": { + "message": "メニュー" + }, + "navigationLabel": { + "message": "ナビゲーション" + }, + "mainContentLabel": { + "message": "メインコンテンツ" + }, + "languageSelectorLabel": { + "message": "言語" + }, + "themeLabel": { + "message": "テーマ" + }, + "lightTheme": { + "message": "ライト" + }, + "darkTheme": { + "message": "ダーク" + }, + "autoTheme": { + "message": "自動" + }, + "advancedSettingsLabel": { + "message": "詳細設定" + }, + "debugModeLabel": { + "message": "デバッグモード" + }, + "verboseLoggingLabel": { + "message": "詳細ロギング" + }, + "successNotification": { + "message": "操作が正常に完了しました" + }, + "warningNotification": { + "message": "警告:続行する前に確認してください" + }, + "infoNotification": { + "message": "情報" + }, + "configCopiedNotification": { + "message": "設定がクリップボードにコピーされました" + }, + "dataClearedNotification": { + "message": "データが正常にクリアされました" + }, + "bytesUnit": { + "message": "バイト" + }, + "kilobytesUnit": { + "message": "KB" + }, + "megabytesUnit": { + "message": "MB" + }, + "gigabytesUnit": { + "message": "GB" + }, + "itemsUnit": { + "message": "項目" + }, + "pagesUnit": { + "message": "ページ" + } +} \ No newline at end of file diff --git a/app/chrome-extension/_locales/ko/messages.json b/app/chrome-extension/_locales/ko/messages.json new file mode 100644 index 0000000..7bb8efa --- /dev/null +++ b/app/chrome-extension/_locales/ko/messages.json @@ -0,0 +1,446 @@ +{ + "extensionName": { + "message": "chrome-mcp-server", + "description": "확장 프로그램 이름" + }, + "extensionDescription": { + "message": "크롬 브라우저와 연동하여 브라우저 기능을 제어하는 MCP 서버입니다.", + "description": "확장 프로그램 설명" + }, + "nativeServerConfigLabel": { + "message": "네이티브 서버 설정", + "description": "네이티브 서버 설정의 주 섹션 제목" + }, + "semanticEngineLabel": { + "message": "시맨틱 엔진", + "description": "시맨틱 엔진의 주 섹션 제목" + }, + "embeddingModelLabel": { + "message": "임베딩 모델", + "description": "모델 선택의 주 섹션 제목" + }, + "indexDataManagementLabel": { + "message": "인덱스 데이터 관리", + "description": "데이터 관리의 주 섹션 제목" + }, + "modelCacheManagementLabel": { + "message": "모델 캐시 관리", + "description": "캐시 관리의 주 섹션 제목" + }, + "statusLabel": { + "message": "상태", + "description": "일반 상태 레이블" + }, + "runningStatusLabel": { + "message": "실행 상태", + "description": "서버 실행 상태 레이블" + }, + "connectionStatusLabel": { + "message": "연결 상태", + "description": "연결 상태 레이블" + }, + "lastUpdatedLabel": { + "message": "마지막 업데이트:", + "description": "마지막 업데이트 타임스탬프 레이블" + }, + "connectButton": { + "message": "연결", + "description": "연결 버튼 텍스트" + }, + "disconnectButton": { + "message": "연결 끊기", + "description": "연결 끊기 버튼 텍스트" + }, + "connectingStatus": { + "message": "연결 중...", + "description": "연결 상태 메시지" + }, + "connectedStatus": { + "message": "연결됨", + "description": "연결된 상태 메시지" + }, + "disconnectedStatus": { + "message": "연결 끊김", + "description": "연결이 끊긴 상태 메시지" + }, + "detectingStatus": { + "message": "감지 중...", + "description": "감지 상태 메시지" + }, + "serviceRunningStatus": { + "message": "서비스 실행 중 (포트: $PORT$)", + "description": "포트 번호와 함께 서비스 실행 중 상태", + "placeholders": { + "port": { + "content": "$1", + "example": "12306" + } + } + }, + "serviceNotConnectedStatus": { + "message": "서비스에 연결되지 않음", + "description": "서비스가 연결되지 않은 상태" + }, + "connectedServiceNotStartedStatus": { + "message": "연결됨, 서비스 시작되지 않음", + "description": "연결되었지만 서비스가 시작되지 않은 상태" + }, + "mcpServerConfigLabel": { + "message": "MCP 서버 설정", + "description": "MCP 서버 설정 섹션 레이블" + }, + "connectionPortLabel": { + "message": "연결 포트", + "description": "연결 포트 입력 레이블" + }, + "refreshStatusButton": { + "message": "상태 새로고침", + "description": "상태 새로고침 버튼 툴팁" + }, + "copyConfigButton": { + "message": "설정 복사", + "description": "설정 복사 버튼 텍스트" + }, + "retryButton": { + "message": "재시도", + "description": "재시도 버튼 텍스트" + }, + "cancelButton": { + "message": "취소", + "description": "취소 버튼 텍스트" + }, + "confirmButton": { + "message": "확인", + "description": "확인 버튼 텍스트" + }, + "saveButton": { + "message": "저장", + "description": "저장 버튼 텍스트" + }, + "closeButton": { + "message": "닫기", + "description": "닫기 버튼 텍스트" + }, + "resetButton": { + "message": "초기화", + "description": "초기화 버튼 텍스트" + }, + "initializingStatus": { + "message": "초기화 중...", + "description": "초기화 진행 메시지" + }, + "processingStatus": { + "message": "처리 중...", + "description": "처리 진행 메시지" + }, + "loadingStatus": { + "message": "로드 중...", + "description": "로드 진행 메시지" + }, + "clearingStatus": { + "message": "삭제 중...", + "description": "삭제 진행 메시지" + }, + "cleaningStatus": { + "message": "정리 중...", + "description": "정리 진행 메시지" + }, + "downloadingStatus": { + "message": "다운로드 중...", + "description": "다운로드 진행 메시지" + }, + "semanticEngineReadyStatus": { + "message": "시맨틱 엔진 준비 완료", + "description": "시맨틱 엔진 준비 완료 상태" + }, + "semanticEngineInitializingStatus": { + "message": "시맨틱 엔진 초기화 중...", + "description": "시맨틱 엔진 초기화 상태" + }, + "semanticEngineInitFailedStatus": { + "message": "시맨틱 엔진 초기화 실패", + "description": "시맨틱 엔진 초기화 실패 상태" + }, + "semanticEngineNotInitStatus": { + "message": "시맨틱 엔진이 초기화되지 않음", + "description": "시맨틱 엔진이 초기화되지 않은 상태" + }, + "initSemanticEngineButton": { + "message": "시맨틱 엔진 초기화", + "description": "시맨틱 엔진 초기화 버튼 텍스트" + }, + "reinitializeButton": { + "message": "재초기화", + "description": "재초기화 버튼 텍스트" + }, + "downloadingModelStatus": { + "message": "모델 다운로드 중... $PROGRESS$%", + "description": "백분율이 포함된 모델 다운로드 진행 상태", + "placeholders": { + "progress": { + "content": "$1", + "example": "50" + } + } + }, + "switchingModelStatus": { + "message": "모델 전환 중...", + "description": "모델 전환 진행 메시지" + }, + "modelLoadedStatus": { + "message": "모델 로드 완료", + "description": "모델 로드 성공 상태" + }, + "modelFailedStatus": { + "message": "모델 로드 실패", + "description": "모델 로드 실패 상태" + }, + "lightweightModelDescription": { + "message": "경량 다국어 모델", + "description": "경량 모델 옵션 설명" + }, + "betterThanSmallDescription": { + "message": "e5-small보다 약간 크지만 성능이 더 좋습니다", + "description": "중간 모델 옵션 설명" + }, + "multilingualModelDescription": { + "message": "다국어 시맨틱 모델", + "description": "다국어 모델 옵션 설명" + }, + "fastPerformance": { + "message": "빠름", + "description": "빠른 성능 표시" + }, + "balancedPerformance": { + "message": "균형", + "description": "균형 잡힌 성능 표시" + }, + "accuratePerformance": { + "message": "정확", + "description": "정확한 성능 표시" + }, + "networkErrorMessage": { + "message": "네트워크 연결 오류, 네트워크를 확인하고 다시 시도하세요", + "description": "네트워크 연결 오류 메시지" + }, + "modelCorruptedErrorMessage": { + "message": "모델 파일이 손상되었거나 불완전합니다. 다운로드를 다시 시도하세요", + "description": "모델 손상 오류 메시지" + }, + "unknownErrorMessage": { + "message": "알 수 없는 오류, 네트워크에서 HuggingFace에 접속할 수 있는지 확인하세요", + "description": "알 수 없는 오류 대체 메시지" + }, + "permissionDeniedErrorMessage": { + "message": "권한이 거부되었습니다", + "description": "권한 거부 오류 메시지" + }, + "timeoutErrorMessage": { + "message": "작업 시간 초과", + "description": "시간 초과 오류 메시지" + }, + "indexedPagesLabel": { + "message": "인덱싱된 페이지", + "description": "인덱싱된 페이지 수 레이블" + }, + "indexSizeLabel": { + "message": "인덱스 크기", + "description": "인덱스 크기 레이블" + }, + "activeTabsLabel": { + "message": "활성 탭", + "description": "활성 탭 수 레이블" + }, + "vectorDocumentsLabel": { + "message": "벡터 문서", + "description": "벡터 문서 수 레이블" + }, + "cacheSizeLabel": { + "message": "캐시 크기", + "description": "캐시 크기 레이블" + }, + "cacheEntriesLabel": { + "message": "캐시 항목", + "description": "캐시 항목 수 레이블" + }, + "clearAllDataButton": { + "message": "모든 데이터 지우기", + "description": "모든 데이터 지우기 버튼 텍스트" + }, + "clearAllCacheButton": { + "message": "모든 캐시 지우기", + "description": "모든 캐시 지우기 버튼 텍스트" + }, + "cleanExpiredCacheButton": { + "message": "만료된 캐시 정리", + "description": "만료된 캐시 정리 버튼 텍스트" + }, + "exportDataButton": { + "message": "데이터 내보내기", + "description": "데이터 내보내기 버튼 텍스트" + }, + "importDataButton": { + "message": "데이터 가져오기", + "description": "데이터 가져오기 버튼 텍스트" + }, + "confirmClearDataTitle": { + "message": "데이터 지우기 확인", + "description": "데이터 지우기 확인 대화상자 제목" + }, + "settingsTitle": { + "message": "설정", + "description": "설정 대화상자 제목" + }, + "aboutTitle": { + "message": "정보", + "description": "정보 대화상자 제목" + }, + "helpTitle": { + "message": "도움말", + "description": "도움말 대화상자 제목" + }, + "clearDataWarningMessage": { + "message": "이 작업은 다음을 포함한 모든 인덱싱된 웹페이지 콘텐츠와 벡터 데이터를 지웁니다:", + "description": "데이터 지우기 경고 메시지" + }, + "clearDataList1": { + "message": "모든 웹페이지 텍스트 콘텐츠 인덱스", + "description": "데이터 지우기 목록 첫 번째 항목" + }, + "clearDataList2": { + "message": "벡터 임베딩 데이터", + "description": "데이터 지우기 목록 두 번째 항목" + }, + "clearDataList3": { + "message": "검색 기록 및 캐시", + "description": "데이터 지우기 목록 세 번째 항목" + }, + "clearDataIrreversibleWarning": { + "message": "이 작업은 되돌릴 수 없습니다! 삭제 후에는 인덱스를 다시 생성하기 위해 웹페이지를 다시 방문해야 합니다.", + "description": "되돌릴 수 없는 작업 경고" + }, + "confirmClearButton": { + "message": "삭제 확인", + "description": "삭제 작업 확인 버튼" + }, + "cacheDetailsLabel": { + "message": "캐시 정보", + "description": "캐시 정보 섹션 레이블" + }, + "noCacheDataMessage": { + "message": "캐시 데이터 없음", + "description": "사용 가능한 캐시 데이터 없음 메시지" + }, + "loadingCacheInfoStatus": { + "message": "캐시 정보를 불러오는 중...", + "description": "캐시 정보 로드 상태" + }, + "processingCacheStatus": { + "message": "캐시 처리 중...", + "description": "캐시 처리 상태" + }, + "expiredLabel": { + "message": "만료됨", + "description": "만료된 항목 레이블" + }, + "bookmarksBarLabel": { + "message": "북마크바", + "description": "북마크바 폴더 이름" + }, + "newTabLabel": { + "message": "새 탭", + "description": "새 탭 레이블" + }, + "currentPageLabel": { + "message": "현재 페이지", + "description": "현재 페이지 레이블" + }, + "menuLabel": { + "message": "메뉴", + "description": "메뉴 접근성 레이블" + }, + "navigationLabel": { + "message": "탐색", + "description": "탐색 접근성 레이블" + }, + "mainContentLabel": { + "message": "주요 콘텐츠", + "description": "주요 콘텐츠 접근성 레이블" + }, + "languageSelectorLabel": { + "message": "언어", + "description": "언어 선택기 레이블" + }, + "themeLabel": { + "message": "테마", + "description": "테마 선택기 레이블" + }, + "lightTheme": { + "message": "라이트", + "description": "라이트 테마 옵션" + }, + "darkTheme": { + "message": "다크", + "description": "다크 테마 옵션" + }, + "autoTheme": { + "message": "자동", + "description": "자동 테마 옵션" + }, + "advancedSettingsLabel": { + "message": "고급 설정", + "description": "고급 설정 섹션 레이블" + }, + "debugModeLabel": { + "message": "디버그 모드", + "description": "디버그 모드 토글 레이블" + }, + "verboseLoggingLabel": { + "message": "상세 로깅", + "description": "상세 로깅 토글 레이블" + }, + "successNotification": { + "message": "작업이 성공적으로 완료되었습니다", + "description": "일반 성공 알림" + }, + "warningNotification": { + "message": "경고: 계속하기 전에 검토하세요", + "description": "일반 경고 알림" + }, + "infoNotification": { + "message": "정보", + "description": "일반 정보 알림" + }, + "configCopiedNotification": { + "message": "설정이 클립보드에 복사되었습니다", + "description": "설정 복사 성공 메시지" + }, + "dataClearedNotification": { + "message": "데이터가 성공적으로 삭제되었습니다", + "description": "데이터 삭제 성공 메시지" + }, + "bytesUnit": { + "message": "바이트", + "description": "바이트 단위" + }, + "kilobytesUnit": { + "message": "KB", + "description": "킬로바이트 단위" + }, + "megabytesUnit": { + "message": "MB", + "description": "메가바이트 단위" + }, + "gigabytesUnit": { + "message": "GB", + "description": "기가바이트 단위" + }, + "itemsUnit": { + "message": "개", + "description": "항목 개수 단위" + }, + "pagesUnit": { + "message": "페이지", + "description": "페이지 수 단위" + } +} diff --git a/app/chrome-extension/_locales/zh_CN/messages.json b/app/chrome-extension/_locales/zh_CN/messages.json new file mode 100644 index 0000000..a8901ab --- /dev/null +++ b/app/chrome-extension/_locales/zh_CN/messages.json @@ -0,0 +1,492 @@ +{ + "extensionName": { + "message": "chrome-mcp-server", + "description": "扩展名称" + }, + "extensionDescription": { + "message": "使用你自己的 Chrome 浏览器暴露浏览器功能", + "description": "扩展描述" + }, + "nativeServerConfigLabel": { + "message": "Native Server 配置", + "description": "本地服务器设置的主要节标题" + }, + "semanticEngineLabel": { + "message": "语义引擎", + "description": "语义引擎的主要节标题" + }, + "embeddingModelLabel": { + "message": "Embedding模型", + "description": "模型选择的主要节标题" + }, + "indexDataManagementLabel": { + "message": "索引数据管理", + "description": "数据管理的主要节标题" + }, + "modelCacheManagementLabel": { + "message": "模型缓存管理", + "description": "缓存管理的主要节标题" + }, + "statusLabel": { + "message": "状态", + "description": "通用状态标签" + }, + "runningStatusLabel": { + "message": "运行状态", + "description": "服务器运行状态标签" + }, + "connectionStatusLabel": { + "message": "连接状态", + "description": "连接状态标签" + }, + "lastUpdatedLabel": { + "message": "最后更新:", + "description": "最后更新时间戳标签" + }, + "connectButton": { + "message": "连接", + "description": "连接按钮文本" + }, + "disconnectButton": { + "message": "断开", + "description": "断开连接按钮文本" + }, + "connectingStatus": { + "message": "连接中...", + "description": "连接状态消息" + }, + "connectedStatus": { + "message": "已连接", + "description": "已连接状态消息" + }, + "disconnectedStatus": { + "message": "已断开", + "description": "已断开状态消息" + }, + "detectingStatus": { + "message": "检测中...", + "description": "检测状态消息" + }, + "serviceRunningStatus": { + "message": "服务运行中 (端口: $PORT$)", + "description": "带端口号的服务运行状态", + "placeholders": { + "port": { + "content": "$1", + "example": "12306" + } + } + }, + "serviceNotConnectedStatus": { + "message": "服务未连接", + "description": "服务未连接状态" + }, + "connectedServiceNotStartedStatus": { + "message": "已连接,服务未启动", + "description": "已连接但服务未启动状态" + }, + "mcpServerConfigLabel": { + "message": "MCP 服务器配置", + "description": "MCP 服务器配置节标签" + }, + "connectionPortLabel": { + "message": "连接端口", + "description": "连接端口输入标签" + }, + "refreshStatusButton": { + "message": "刷新状态", + "description": "刷新状态按钮提示" + }, + "copyConfigButton": { + "message": "复制配置", + "description": "复制配置按钮文本" + }, + "retryButton": { + "message": "重试", + "description": "重试按钮文本" + }, + "cancelButton": { + "message": "取消", + "description": "取消按钮文本" + }, + "confirmButton": { + "message": "确认", + "description": "确认按钮文本" + }, + "saveButton": { + "message": "保存", + "description": "保存按钮文本" + }, + "closeButton": { + "message": "关闭", + "description": "关闭按钮文本" + }, + "resetButton": { + "message": "重置", + "description": "重置按钮文本" + }, + "initializingStatus": { + "message": "初始化中...", + "description": "初始化进度消息" + }, + "processingStatus": { + "message": "处理中...", + "description": "处理进度消息" + }, + "loadingStatus": { + "message": "加载中...", + "description": "加载进度消息" + }, + "clearingStatus": { + "message": "清空中...", + "description": "清空进度消息" + }, + "cleaningStatus": { + "message": "清理中...", + "description": "清理进度消息" + }, + "downloadingStatus": { + "message": "下载中...", + "description": "下载进度消息" + }, + "semanticEngineReadyStatus": { + "message": "语义引擎已就绪", + "description": "语义引擎就绪状态" + }, + "semanticEngineInitializingStatus": { + "message": "语义引擎初始化中...", + "description": "语义引擎初始化状态" + }, + "semanticEngineInitFailedStatus": { + "message": "语义引擎初始化失败", + "description": "语义引擎初始化失败状态" + }, + "semanticEngineNotInitStatus": { + "message": "语义引擎未初始化", + "description": "语义引擎未初始化状态" + }, + "initSemanticEngineButton": { + "message": "初始化语义引擎", + "description": "初始化语义引擎按钮文本" + }, + "reinitializeButton": { + "message": "重新初始化", + "description": "重新初始化按钮文本" + }, + "downloadingModelStatus": { + "message": "下载模型中... $PROGRESS$%", + "description": "带百分比的模型下载进度", + "placeholders": { + "progress": { + "content": "$1", + "example": "50" + } + } + }, + "switchingModelStatus": { + "message": "切换模型中...", + "description": "模型切换进度消息" + }, + "modelLoadedStatus": { + "message": "模型已加载", + "description": "模型成功加载状态" + }, + "modelFailedStatus": { + "message": "模型加载失败", + "description": "模型加载失败状态" + }, + "lightweightModelDescription": { + "message": "轻量级多语言模型", + "description": "轻量级模型选项的描述" + }, + "betterThanSmallDescription": { + "message": "比e5-small稍大,但效果更好", + "description": "中等模型选项的描述" + }, + "multilingualModelDescription": { + "message": "多语言语义模型", + "description": "多语言模型选项的描述" + }, + "fastPerformance": { + "message": "快速", + "description": "快速性能指示器" + }, + "balancedPerformance": { + "message": "平衡", + "description": "平衡性能指示器" + }, + "accuratePerformance": { + "message": "精确", + "description": "精确性能指示器" + }, + "networkErrorMessage": { + "message": "网络连接错误,请检查网络连接后重试", + "description": "网络连接错误消息" + }, + "modelCorruptedErrorMessage": { + "message": "模型文件损坏或不完整,请重试下载", + "description": "模型损坏错误消息" + }, + "unknownErrorMessage": { + "message": "未知错误,请检查你的网络是否可以访问HuggingFace", + "description": "未知错误回退消息" + }, + "permissionDeniedErrorMessage": { + "message": "权限被拒绝", + "description": "权限被拒绝错误消息" + }, + "timeoutErrorMessage": { + "message": "操作超时", + "description": "超时错误消息" + }, + "indexedPagesLabel": { + "message": "已索引页面", + "description": "已索引页面数量标签" + }, + "indexSizeLabel": { + "message": "索引大小", + "description": "索引大小标签" + }, + "activeTabsLabel": { + "message": "活跃标签页", + "description": "活跃标签页数量标签" + }, + "vectorDocumentsLabel": { + "message": "向量文档", + "description": "向量文档数量标签" + }, + "cacheSizeLabel": { + "message": "缓存大小", + "description": "缓存大小标签" + }, + "cacheEntriesLabel": { + "message": "缓存条目", + "description": "缓存条目数量标签" + }, + "clearAllDataButton": { + "message": "清空所有数据", + "description": "清空所有数据按钮文本" + }, + "clearAllCacheButton": { + "message": "清空所有缓存", + "description": "清空所有缓存按钮文本" + }, + "cleanExpiredCacheButton": { + "message": "清理过期缓存", + "description": "清理过期缓存按钮文本" + }, + "exportDataButton": { + "message": "导出数据", + "description": "导出数据按钮文本" + }, + "importDataButton": { + "message": "导入数据", + "description": "导入数据按钮文本" + }, + "confirmClearDataTitle": { + "message": "确认清空数据", + "description": "清空数据确认对话框标题" + }, + "settingsTitle": { + "message": "设置", + "description": "设置对话框标题" + }, + "aboutTitle": { + "message": "关于", + "description": "关于对话框标题" + }, + "helpTitle": { + "message": "帮助", + "description": "帮助对话框标题" + }, + "clearDataWarningMessage": { + "message": "此操作将清空所有已索引的网页内容和向量数据,包括:", + "description": "清空数据警告消息" + }, + "clearDataList1": { + "message": "所有网页的文本内容索引", + "description": "清空数据列表第一项" + }, + "clearDataList2": { + "message": "向量嵌入数据", + "description": "清空数据列表第二项" + }, + "clearDataList3": { + "message": "搜索历史和缓存", + "description": "清空数据列表第三项" + }, + "clearDataIrreversibleWarning": { + "message": "此操作不可撤销!清空后需要重新浏览网页来重建索引。", + "description": "不可逆操作警告" + }, + "confirmClearButton": { + "message": "确认清空", + "description": "确认清空操作按钮" + }, + "cacheDetailsLabel": { + "message": "缓存详情", + "description": "缓存详情节标签" + }, + "noCacheDataMessage": { + "message": "暂无缓存数据", + "description": "无缓存数据可用消息" + }, + "loadingCacheInfoStatus": { + "message": "正在加载缓存信息...", + "description": "加载缓存信息状态" + }, + "processingCacheStatus": { + "message": "处理缓存中...", + "description": "处理缓存状态" + }, + "expiredLabel": { + "message": "已过期", + "description": "过期项标签" + }, + "bookmarksBarLabel": { + "message": "书签栏", + "description": "书签栏文件夹名称" + }, + "newTabLabel": { + "message": "新标签页", + "description": "新标签页标签" + }, + "currentPageLabel": { + "message": "当前页面", + "description": "当前页面标签" + }, + "menuLabel": { + "message": "菜单", + "description": "菜单辅助功能标签" + }, + "navigationLabel": { + "message": "导航", + "description": "导航辅助功能标签" + }, + "mainContentLabel": { + "message": "主要内容", + "description": "主要内容辅助功能标签" + }, + "languageSelectorLabel": { + "message": "语言", + "description": "语言选择器标签" + }, + "themeLabel": { + "message": "主题", + "description": "主题选择器标签" + }, + "lightTheme": { + "message": "浅色", + "description": "浅色主题选项" + }, + "darkTheme": { + "message": "深色", + "description": "深色主题选项" + }, + "autoTheme": { + "message": "自动", + "description": "自动主题选项" + }, + "advancedSettingsLabel": { + "message": "高级设置", + "description": "高级设置节标签" + }, + "debugModeLabel": { + "message": "调试模式", + "description": "调试模式切换标签" + }, + "verboseLoggingLabel": { + "message": "详细日志", + "description": "详细日志切换标签" + }, + "successNotification": { + "message": "操作成功完成", + "description": "通用成功通知" + }, + "warningNotification": { + "message": "警告:请在继续之前检查", + "description": "通用警告通知" + }, + "infoNotification": { + "message": "信息", + "description": "通用信息通知" + }, + "configCopiedNotification": { + "message": "配置已复制到剪贴板", + "description": "配置复制成功消息" + }, + "dataClearedNotification": { + "message": "数据清空成功", + "description": "数据清空成功消息" + }, + "bytesUnit": { + "message": "字节", + "description": "字节单位" + }, + "kilobytesUnit": { + "message": "KB", + "description": "千字节单位" + }, + "megabytesUnit": { + "message": "MB", + "description": "兆字节单位" + }, + "gigabytesUnit": { + "message": "GB", + "description": "吉字节单位" + }, + "itemsUnit": { + "message": "项", + "description": "项目计数单位" + }, + "pagesUnit": { + "message": "页", + "description": "页面计数单位" + }, + "userscriptsManagerTitle": { "message": "脚本管理器", "description": "Options 页标题" }, + "emergencySwitchLabel": { "message": "紧急开关", "description": "紧急关闭开关" }, + "createRunSectionTitle": { "message": "创建 / 运行", "description": "创建与运行分区标题" }, + "nameLabel": { "message": "名称", "description": "名称输入标签" }, + "runAtLabel": { "message": "运行时机", "description": "runAt 选择标签" }, + "runAtAuto": { "message": "自动", "description": "runAt auto" }, + "runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" }, + "runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" }, + "runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" }, + "worldLabel": { "message": "执行上下文", "description": "world 选择标签" }, + "worldAuto": { "message": "自动", "description": "world auto" }, + "worldIsolated": { "message": "隔离 (ISOLATED)", "description": "ISOLATED world" }, + "worldMain": { "message": "页面 (MAIN)", "description": "MAIN world" }, + "modeLabel": { "message": "模式", "description": "模式选择标签" }, + "modeAuto": { "message": "自动", "description": "mode auto" }, + "modePersistent": { "message": "持久", "description": "mode persistent" }, + "modeCss": { "message": "仅样式 (CSS)", "description": "mode css" }, + "modeOnce": { "message": "一次运行 (CDP)", "description": "mode once" }, + "allFramesLabel": { "message": "全部 frame", "description": "allFrames 复选框" }, + "persistLabel": { "message": "持久化", "description": "persist 复选框" }, + "dnrFallbackLabel": { "message": "DNR 回退", "description": "DNR fallback 复选框" }, + "matchesInputLabel": { "message": "匹配(逗号分隔)", "description": "matches 输入" }, + "excludesInputLabel": { "message": "排除(逗号分隔)", "description": "excludes 输入" }, + "tagsInputLabel": { "message": "标签(逗号分隔)", "description": "tags 输入" }, + "scriptLabel": { "message": "脚本", "description": "脚本文本标签" }, + "applyButton": { "message": "应用", "description": "应用按钮" }, + "runOnceButton": { "message": "一次运行(CDP)", "description": "一次运行按钮" }, + "listSectionTitle": { "message": "脚本列表", "description": "列表分区标题" }, + "queryLabel": { "message": "搜索", "description": "查询输入标签" }, + "statusAll": { "message": "全部", "description": "状态-全部" }, + "statusEnabled": { "message": "启用", "description": "状态-启用" }, + "statusDisabled": { "message": "禁用", "description": "状态-禁用" }, + "domainLabel": { "message": "域名", "description": "域名过滤标签" }, + "exportAllButton": { "message": "导出全部", "description": "导出按钮" }, + "tableHeaderName": { "message": "名称", "description": "表头-名称" }, + "tableHeaderWorld": { "message": "执行上下文", "description": "表头-World" }, + "tableHeaderRunAt": { "message": "运行时机", "description": "表头-RunAt" }, + "tableHeaderUpdated": { "message": "更新时间", "description": "表头-更新时间" }, + "deleteButton": { "message": "删除", "description": "删除按钮" }, + "placeholderOptional": { "message": "可选", "description": "通用可选占位符" }, + "placeholderMatchesExample": { + "message": "例如:https://*.example.com/*", + "description": "匹配示例占位符" + }, + "placeholderScriptHint": { "message": "在此粘贴 JS/CSS/TM", "description": "脚本文本域占位符" }, + "placeholderDomainHint": { "message": "example.com", "description": "域名筛选占位符" } +} diff --git a/app/chrome-extension/_locales/zh_TW/messages.json b/app/chrome-extension/_locales/zh_TW/messages.json new file mode 100644 index 0000000..4e5403f --- /dev/null +++ b/app/chrome-extension/_locales/zh_TW/messages.json @@ -0,0 +1,446 @@ +{ + "extensionName": { + "message": "chrome-mcp-server", + "description": "擴充功能名稱" + }, + "extensionDescription": { + "message": "使用您自己的 Chrome 瀏覽器暴露瀏覽器功能", + "description": "擴充功能描述" + }, + "nativeServerConfigLabel": { + "message": "原生伺服器設定", + "description": "本機伺服器設定的主要區段標題" + }, + "semanticEngineLabel": { + "message": "語意引擎", + "description": "語意引擎的主要區段標題" + }, + "embeddingModelLabel": { + "message": "Embedding 模型", + "description": "模型選擇的主要區段標題" + }, + "indexDataManagementLabel": { + "message": "索引資料管理", + "description": "資料管理主要區段標題" + }, + "modelCacheManagementLabel": { + "message": "模型快取管理", + "description": "快取管理主要區段標題" + }, + "statusLabel": { + "message": "狀態", + "description": "通用狀態標籤" + }, + "runningStatusLabel": { + "message": "執行狀態", + "description": "伺服器執行狀態標籤" + }, + "connectionStatusLabel": { + "message": "連線狀態", + "description": "連線狀態標籤" + }, + "lastUpdatedLabel": { + "message": "最後更新:", + "description": "最後更新時間戳標籤" + }, + "connectButton": { + "message": "連線", + "description": "連線按鈕文字" + }, + "disconnectButton": { + "message": "中斷連線", + "description": "中斷連線按鈕文字" + }, + "connectingStatus": { + "message": "連線中...", + "description": "連線狀態訊息" + }, + "connectedStatus": { + "message": "已連線", + "description": "已連線狀態訊息" + }, + "disconnectedStatus": { + "message": "已中斷", + "description": "已中斷狀態訊息" + }, + "detectingStatus": { + "message": "偵測中...", + "description": "偵測狀態訊息" + }, + "serviceRunningStatus": { + "message": "服務執行中 (連結埠: $PORT$)", + "description": "含連結埠號的服務執行狀態", + "placeholders": { + "port": { + "content": "$1", + "example": "12306" + } + } + }, + "serviceNotConnectedStatus": { + "message": "服務未連線", + "description": "服務未連線狀態" + }, + "connectedServiceNotStartedStatus": { + "message": "已連線,服務未啟動", + "description": "已連線但服務未啟動狀態" + }, + "mcpServerConfigLabel": { + "message": "MCP 伺服器設定", + "description": "MCP 伺服器設定區段標籤" + }, + "connectionPortLabel": { + "message": "連結埠", + "description": "連結埠輸入標籤" + }, + "refreshStatusButton": { + "message": "重新整理狀態", + "description": "重新整理狀態按鈕提示" + }, + "copyConfigButton": { + "message": "複製設定", + "description": "複製設定按鈕文字" + }, + "retryButton": { + "message": "重試", + "description": "重試按鈕文字" + }, + "cancelButton": { + "message": "取消", + "description": "取消按鈕文字" + }, + "confirmButton": { + "message": "確認", + "description": "確認按鈕文字" + }, + "saveButton": { + "message": "儲存", + "description": "儲存按鈕文字" + }, + "closeButton": { + "message": "關閉", + "description": "關閉按鈕文字" + }, + "resetButton": { + "message": "重設", + "description": "重設按鈕文字" + }, + "initializingStatus": { + "message": "初始化中...", + "description": "初始化進度訊息" + }, + "processingStatus": { + "message": "處理中...", + "description": "處理進度訊息" + }, + "loadingStatus": { + "message": "載入中...", + "description": "載入進度訊息" + }, + "clearingStatus": { + "message": "清除中...", + "description": "清除進度訊息" + }, + "cleaningStatus": { + "message": "清理中...", + "description": "清理進度訊息" + }, + "downloadingStatus": { + "message": "下載中...", + "description": "下載進度訊息" + }, + "semanticEngineReadyStatus": { + "message": "語意引擎已就緒", + "description": "語意引擎就緒狀態" + }, + "semanticEngineInitializingStatus": { + "message": "語意引擎初始化中...", + "description": "語意引擎初始化狀態" + }, + "semanticEngineInitFailedStatus": { + "message": "語意引擎初始化失敗", + "description": "語意引擎初始化失敗狀態" + }, + "semanticEngineNotInitStatus": { + "message": "語意引擎未初始化", + "description": "語意引擎未初始化狀態" + }, + "initSemanticEngineButton": { + "message": "初始化語意引擎", + "description": "初始化語意引擎按鈕文字" + }, + "reinitializeButton": { + "message": "重新初始化", + "description": "重新初始化按鈕文字" + }, + "downloadingModelStatus": { + "message": "正在下載模型... $PROGRESS$%", + "description": "含百分比的模型下載進度", + "placeholders": { + "progress": { + "content": "$1", + "example": "50" + } + } + }, + "switchingModelStatus": { + "message": "正在切換模型...", + "description": "模型切換進度訊息" + }, + "modelLoadedStatus": { + "message": "模型已載入", + "description": "模型成功載入狀態" + }, + "modelFailedStatus": { + "message": "模型載入失敗", + "description": "模型載入失敗狀態" + }, + "lightweightModelDescription": { + "message": "輕量級多語言模型", + "description": "輕量級模型選項描述" + }, + "betterThanSmallDescription": { + "message": "比 e5-small 稍大,但效果更佳", + "description": "中等模型選項描述" + }, + "multilingualModelDescription": { + "message": "多語言語意模型", + "description": "多語言模型選項描述" + }, + "fastPerformance": { + "message": "快速", + "description": "快速效能指標" + }, + "balancedPerformance": { + "message": "平衡", + "description": "平衡效能指標" + }, + "accuratePerformance": { + "message": "精確", + "description": "精確效能指標" + }, + "networkErrorMessage": { + "message": "網路連線錯誤,請檢查網路後再試一次", + "description": "網路連線錯誤訊息" + }, + "modelCorruptedErrorMessage": { + "message": "模型檔案毀損或不完整,請重新下載", + "description": "模型毀損錯誤訊息" + }, + "unknownErrorMessage": { + "message": "未知錯誤,請檢查您的網路是否可存取 HuggingFace", + "description": "未知錯誤回退訊息" + }, + "permissionDeniedErrorMessage": { + "message": "權限被拒絕", + "description": "權限被拒絕錯誤訊息" + }, + "timeoutErrorMessage": { + "message": "操作逾時", + "description": "逾時錯誤訊息" + }, + "indexedPagesLabel": { + "message": "已索引頁面", + "description": "已索引頁面數量標籤" + }, + "indexSizeLabel": { + "message": "索引大小", + "description": "索引大小標籤" + }, + "activeTabsLabel": { + "message": "作用中分頁", + "description": "作用中分頁數量標籤" + }, + "vectorDocumentsLabel": { + "message": "向量文件", + "description": "向量文件數量標籤" + }, + "cacheSizeLabel": { + "message": "快取大小", + "description": "快取大小標籤" + }, + "cacheEntriesLabel": { + "message": "快取項目", + "description": "快取項目數量標籤" + }, + "clearAllDataButton": { + "message": "清除所有資料", + "description": "清除所有資料按鈕文字" + }, + "clearAllCacheButton": { + "message": "清除所有快取", + "description": "清除所有快取按鈕文字" + }, + "cleanExpiredCacheButton": { + "message": "清理過期快取", + "description": "清理過期快取按鈕文字" + }, + "exportDataButton": { + "message": "匯出資料", + "description": "匯出資料按鈕文字" + }, + "importDataButton": { + "message": "匯入資料", + "description": "匯入資料按鈕文字" + }, + "confirmClearDataTitle": { + "message": "確認清除資料", + "description": "清除資料確認對話框標題" + }, + "settingsTitle": { + "message": "設定", + "description": "設定對話框標題" + }, + "aboutTitle": { + "message": "關於", + "description": "關於對話框標題" + }, + "helpTitle": { + "message": "說明", + "description": "說明對話框標題" + }, + "clearDataWarningMessage": { + "message": "此操作將清除所有已索引的網頁內容與向量資料,包括:", + "description": "清除資料警告訊息" + }, + "clearDataList1": { + "message": "所有網頁的文字內容索引", + "description": "清除資料列表第一項" + }, + "clearDataList2": { + "message": "向量嵌入資料", + "description": "清除資料列表第二項" + }, + "clearDataList3": { + "message": "搜尋歷史與快取", + "description": "清除資料列表第三項" + }, + "clearDataIrreversibleWarning": { + "message": "此操作無法復原!清除後需重新瀏覽網頁以重建索引。", + "description": "不可逆操作警告" + }, + "confirmClearButton": { + "message": "確認清除", + "description": "確認清除操作按鈕" + }, + "cacheDetailsLabel": { + "message": "快取詳細資訊", + "description": "快取詳細資訊區段標籤" + }, + "noCacheDataMessage": { + "message": "尚無快取資料", + "description": "無可用快取資料訊息" + }, + "loadingCacheInfoStatus": { + "message": "正在載入快取資訊...", + "description": "載入快取資訊狀態" + }, + "processingCacheStatus": { + "message": "正在處理快取...", + "description": "處理快取狀態" + }, + "expiredLabel": { + "message": "已過期", + "description": "過期項目標籤" + }, + "bookmarksBarLabel": { + "message": "書籤列", + "description": "書籤列資料夾名稱" + }, + "newTabLabel": { + "message": "新分頁", + "description": "新分頁標籤" + }, + "currentPageLabel": { + "message": "目前頁面", + "description": "目前頁面標籤" + }, + "menuLabel": { + "message": "功能表", + "description": "功能表無障礙標籤" + }, + "navigationLabel": { + "message": "導覽", + "description": "導覽無障礙標籤" + }, + "mainContentLabel": { + "message": "主要內容", + "description": "主要內容無障礙標籤" + }, + "languageSelectorLabel": { + "message": "語言", + "description": "語言選擇器標籤" + }, + "themeLabel": { + "message": "主題", + "description": "主題選擇器標籤" + }, + "lightTheme": { + "message": "淺色", + "description": "淺色主題選項" + }, + "darkTheme": { + "message": "深色", + "description": "深色主題選項" + }, + "autoTheme": { + "message": "自動", + "description": "自動主題選項" + }, + "advancedSettingsLabel": { + "message": "進階設定", + "description": "進階設定區段標籤" + }, + "debugModeLabel": { + "message": "偵錯模式", + "description": "偵錯模式切換標籤" + }, + "verboseLoggingLabel": { + "message": "詳細日誌", + "description": "詳細日誌切換標籤" + }, + "successNotification": { + "message": "操作已成功完成", + "description": "通用成功通知" + }, + "warningNotification": { + "message": "警告:請在繼續前檢查", + "description": "通用警告通知" + }, + "infoNotification": { + "message": "資訊", + "description": "通用資訊通知" + }, + "configCopiedNotification": { + "message": "設定已複製到剪貼簿", + "description": "設定複製成功訊息" + }, + "dataClearedNotification": { + "message": "資料清除成功", + "description": "資料清除成功訊息" + }, + "bytesUnit": { + "message": "bytes", + "description": "位元組單位" + }, + "kilobytesUnit": { + "message": "KB", + "description": "千位元組單位" + }, + "megabytesUnit": { + "message": "MB", + "description": "百萬位元組單位" + }, + "gigabytesUnit": { + "message": "GB", + "description": "十億位元組單位" + }, + "itemsUnit": { + "message": "項目", + "description": "項目計數單位" + }, + "pagesUnit": { + "message": "頁面", + "description": "頁面計數單位" + } +} diff --git a/app/chrome-extension/assets/vue.svg b/app/chrome-extension/assets/vue.svg new file mode 100644 index 0000000..ca8129c --- /dev/null +++ b/app/chrome-extension/assets/vue.svg @@ -0,0 +1 @@ + diff --git a/app/chrome-extension/common/agent-models.ts b/app/chrome-extension/common/agent-models.ts new file mode 100644 index 0000000..85b81d7 --- /dev/null +++ b/app/chrome-extension/common/agent-models.ts @@ -0,0 +1,270 @@ +/** + * Agent CLI Model Definitions. + * + * Static model definitions for each CLI type. + * Based on the pattern from Claudable (other/cweb). + */ + +import type { CodexReasoningEffort } from 'chrome-mcp-shared'; + +// ============================================================ +// Types +// ============================================================ + +export interface ModelDefinition { + id: string; + name: string; + description?: string; + supportsImages?: boolean; + /** Supported reasoning effort levels for Codex models */ + supportedReasoningEfforts?: readonly CodexReasoningEffort[]; +} + +export type AgentCliType = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm'; + +// ============================================================ +// Claude Models +// ============================================================ + +export const CLAUDE_MODELS: ModelDefinition[] = [ + { + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5', + description: 'Balanced model with large context window', + supportsImages: true, + }, + { + id: 'claude-opus-4-5-20251101', + name: 'Claude Opus 4.5', + description: 'Strongest reasoning model', + supportsImages: true, + }, + { + id: 'claude-haiku-4-5-20251001', + name: 'Claude Haiku 4.5', + description: 'Fast and cost-efficient', + supportsImages: true, + }, +]; + +export const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; + +// ============================================================ +// Codex Models +// ============================================================ + +/** Standard reasoning efforts supported by all models */ +const CODEX_STANDARD_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high']; +/** Extended reasoning efforts (includes xhigh) - only for gpt-5.2 and gpt-5.1-codex-max */ +const CODEX_EXTENDED_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high', 'xhigh']; + +export const CODEX_MODELS: ModelDefinition[] = [ + { + id: 'gpt-5.1', + name: 'GPT-5.1', + description: 'OpenAI high-quality reasoning model', + supportedReasoningEfforts: CODEX_STANDARD_EFFORTS, + }, + { + id: 'gpt-5.2', + name: 'GPT-5.2', + description: 'OpenAI flagship reasoning model with extended effort support', + supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS, + }, + { + id: 'gpt-5.1-codex', + name: 'GPT-5.1 Codex', + description: 'Coding-optimized model for agent workflows', + supportedReasoningEfforts: CODEX_STANDARD_EFFORTS, + }, + { + id: 'gpt-5.1-codex-max', + name: 'GPT-5.1 Codex Max', + description: 'Highest quality coding model with extended effort support', + supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS, + }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + description: 'Fast, cost-efficient coding model', + supportedReasoningEfforts: CODEX_STANDARD_EFFORTS, + }, +]; + +export const CODEX_DEFAULT_MODEL = 'gpt-5.1'; + +// Codex model alias normalization +const CODEX_ALIAS_MAP: Record = { + gpt5: 'gpt-5.1', + gpt_5: 'gpt-5.1', + 'gpt-5': 'gpt-5.1', + 'gpt-5.0': 'gpt-5.1', +}; + +const CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id)); + +/** + * Normalize a Codex model ID, handling aliases and falling back to default. + */ +export function normalizeCodexModelId(model?: string | null): string { + if (!model || typeof model !== 'string') { + return CODEX_DEFAULT_MODEL; + } + + const trimmed = model.trim(); + if (!trimmed) { + return CODEX_DEFAULT_MODEL; + } + + const lower = trimmed.toLowerCase(); + if (CODEX_ALIAS_MAP[lower]) { + return CODEX_ALIAS_MAP[lower]; + } + + if (CODEX_KNOWN_IDS.has(lower)) { + return lower; + } + + // If the exact casing exists, allow it + if (CODEX_KNOWN_IDS.has(trimmed)) { + return trimmed; + } + + return CODEX_DEFAULT_MODEL; +} + +/** + * Get supported reasoning efforts for a Codex model. + * Returns standard efforts (low/medium/high) for unknown models. + */ +export function getCodexReasoningEfforts(modelId?: string | null): readonly CodexReasoningEffort[] { + const normalized = normalizeCodexModelId(modelId); + const model = CODEX_MODELS.find((m) => m.id === normalized); + return model?.supportedReasoningEfforts ?? CODEX_STANDARD_EFFORTS; +} + +/** + * Check if a model supports xhigh reasoning effort. + */ +export function supportsXhighEffort(modelId?: string | null): boolean { + const efforts = getCodexReasoningEfforts(modelId); + return efforts.includes('xhigh'); +} + +// ============================================================ +// Cursor Models +// ============================================================ + +export const CURSOR_MODELS: ModelDefinition[] = [ + { + id: 'auto', + name: 'Auto', + description: 'Cursor auto-selects the best model', + }, + { + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5', + description: 'Anthropic Claude via Cursor', + supportsImages: true, + }, + { + id: 'gpt-4.1', + name: 'GPT-4.1', + description: 'OpenAI model via Cursor', + }, +]; + +export const CURSOR_DEFAULT_MODEL = 'auto'; + +// ============================================================ +// Qwen Models +// ============================================================ + +export const QWEN_MODELS: ModelDefinition[] = [ + { + id: 'qwen3-coder-plus', + name: 'Qwen3 Coder Plus', + description: 'Balanced 32k context model for coding', + }, + { + id: 'qwen3-coder-pro', + name: 'Qwen3 Coder Pro', + description: 'Larger 128k context with stronger reasoning', + }, + { + id: 'qwen3-coder', + name: 'Qwen3 Coder', + description: 'Fast iteration model', + }, +]; + +export const QWEN_DEFAULT_MODEL = 'qwen3-coder-plus'; + +// ============================================================ +// GLM Models +// ============================================================ + +export const GLM_MODELS: ModelDefinition[] = [ + { + id: 'glm-4.6', + name: 'GLM 4.6', + description: 'Zhipu GLM 4.6 agent runtime', + }, +]; + +export const GLM_DEFAULT_MODEL = 'glm-4.6'; + +// ============================================================ +// Aggregated Definitions +// ============================================================ + +export const CLI_MODEL_DEFINITIONS: Record = { + claude: CLAUDE_MODELS, + codex: CODEX_MODELS, + cursor: CURSOR_MODELS, + qwen: QWEN_MODELS, + glm: GLM_MODELS, +}; + +export const CLI_DEFAULT_MODELS: Record = { + claude: CLAUDE_DEFAULT_MODEL, + codex: CODEX_DEFAULT_MODEL, + cursor: CURSOR_DEFAULT_MODEL, + qwen: QWEN_DEFAULT_MODEL, + glm: GLM_DEFAULT_MODEL, +}; + +// ============================================================ +// Helper Functions +// ============================================================ + +/** + * Get model definitions for a specific CLI type. + */ +export function getModelsForCli(cli: string | null | undefined): ModelDefinition[] { + if (!cli) return []; + const key = cli.toLowerCase() as AgentCliType; + return CLI_MODEL_DEFINITIONS[key] || []; +} + +/** + * Get the default model for a CLI type. + */ +export function getDefaultModelForCli(cli: string | null | undefined): string { + if (!cli) return ''; + const key = cli.toLowerCase() as AgentCliType; + return CLI_DEFAULT_MODELS[key] || ''; +} + +/** + * Get display name for a model ID. + */ +export function getModelDisplayName( + cli: string | null | undefined, + modelId: string | null | undefined, +): string { + if (!cli || !modelId) return modelId || ''; + const models = getModelsForCli(cli); + const model = models.find((m) => m.id === modelId); + return model?.name || modelId; +} diff --git a/app/chrome-extension/common/constants.ts b/app/chrome-extension/common/constants.ts new file mode 100644 index 0000000..bae7ff0 --- /dev/null +++ b/app/chrome-extension/common/constants.ts @@ -0,0 +1,249 @@ +/** + * Chrome Extension Constants + * Centralized configuration values and magic constants + */ + +// Native Host Configuration +export const NATIVE_HOST = { + NAME: 'com.chromemcp.nativehost', + DEFAULT_PORT: 12306, +} as const; + +// Chrome Extension Icons +export const ICONS = { + NOTIFICATION: 'icon/48.png', +} as const; + +// Timeouts and Delays (in milliseconds) +export const TIMEOUTS = { + DEFAULT_WAIT: 1000, + NETWORK_CAPTURE_MAX: 30000, + NETWORK_CAPTURE_IDLE: 3000, + SCREENSHOT_DELAY: 100, + KEYBOARD_DELAY: 50, + CLICK_DELAY: 100, +} as const; + +// Limits and Thresholds +export const LIMITS = { + MAX_NETWORK_REQUESTS: 100, + MAX_SEARCH_RESULTS: 50, + MAX_BOOKMARK_RESULTS: 100, + MAX_HISTORY_RESULTS: 100, + SIMILARITY_THRESHOLD: 0.1, + VECTOR_DIMENSIONS: 384, +} as const; + +// Error Messages +export const ERROR_MESSAGES = { + NATIVE_CONNECTION_FAILED: 'Failed to connect to native host', + NATIVE_DISCONNECTED: 'Native connection disconnected', + SERVER_STATUS_LOAD_FAILED: 'Failed to load server status', + SERVER_STATUS_SAVE_FAILED: 'Failed to save server status', + TOOL_EXECUTION_FAILED: 'Tool execution failed', + INVALID_PARAMETERS: 'Invalid parameters provided', + PERMISSION_DENIED: 'Permission denied', + TAB_NOT_FOUND: 'Tab not found', + ELEMENT_NOT_FOUND: 'Element not found', + NETWORK_ERROR: 'Network error occurred', +} as const; + +// Success Messages +export const SUCCESS_MESSAGES = { + TOOL_EXECUTED: 'Tool executed successfully', + CONNECTION_ESTABLISHED: 'Connection established', + SERVER_STARTED: 'Server started successfully', + SERVER_STOPPED: 'Server stopped successfully', +} as const; + +// External Links +export const LINKS = { + TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md', +} as const; + +// File Extensions and MIME Types +export const FILE_TYPES = { + STATIC_EXTENSIONS: [ + '.css', + '.js', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.svg', + '.ico', + '.woff', + '.woff2', + '.ttf', + ], + FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'], + IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const, +} as const; + +// Network Filtering +export const NETWORK_FILTERS = { + // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr' + EXCLUDED_DOMAINS: [ + // Google + 'google-analytics.com', + 'googletagmanager.com', + 'analytics.google.com', + 'doubleclick.net', + 'googlesyndication.com', + 'googleads.g.doubleclick.net', + 'stats.g.doubleclick.net', + 'adservice.google.com', + 'pagead2.googlesyndication.com', + // Amazon + 'amazon-adsystem.com', + // Microsoft + 'bat.bing.com', + 'clarity.ms', + // Facebook + 'connect.facebook.net', + 'facebook.com/tr', + // Twitter + 'analytics.twitter.com', + 'ads-twitter.com', + // Other ad networks + 'ads.yahoo.com', + 'adroll.com', + 'adnxs.com', + 'criteo.com', + 'quantserve.com', + 'scorecardresearch.com', + // Analytics & session recording + 'segment.io', + 'amplitude.com', + 'mixpanel.com', + 'optimizely.com', + 'static.hotjar.com', + 'script.hotjar.com', + 'crazyegg.com', + 'clicktale.net', + 'mouseflow.com', + 'fullstory.com', + // LinkedIn (tracking pixels) + 'linkedin.com/px', + ], + // Static resource extensions (used when includeStatic=false) + STATIC_RESOURCE_EXTENSIONS: [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.svg', + '.webp', + '.ico', + '.bmp', + '.cur', + '.css', + '.scss', + '.less', + '.js', + '.jsx', + '.ts', + '.tsx', + '.map', + '.woff', + '.woff2', + '.ttf', + '.eot', + '.otf', + '.mp3', + '.mp4', + '.avi', + '.mov', + '.wmv', + '.flv', + '.webm', + '.ogg', + '.wav', + '.pdf', + '.zip', + '.rar', + '.7z', + '.iso', + '.dmg', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + ], + // MIME types treated as static/binary (filtered when includeStatic=false) + STATIC_MIME_TYPES_TO_FILTER: [ + 'image/', + 'font/', + 'audio/', + 'video/', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/x-javascript', + 'application/pdf', + 'application/zip', + 'application/octet-stream', + ], + // API-like MIME types (never filtered by MIME) + API_MIME_TYPES: [ + 'application/json', + 'application/xml', + 'text/xml', + 'text/plain', + 'text/event-stream', + 'application/x-www-form-urlencoded', + 'application/graphql', + 'application/grpc', + 'application/protobuf', + 'application/x-protobuf', + 'application/x-json', + 'application/ld+json', + 'application/problem+json', + 'application/problem+xml', + 'application/soap+xml', + 'application/vnd.api+json', + ], + STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'], +} as const; + +// Semantic Similarity Configuration +export const SEMANTIC_CONFIG = { + DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2', + CHUNK_SIZE: 512, + CHUNK_OVERLAP: 50, + BATCH_SIZE: 32, + CACHE_SIZE: 1000, +} as const; + +// Storage Keys +export const STORAGE_KEYS = { + SERVER_STATUS: 'serverStatus', + NATIVE_SERVER_PORT: 'nativeServerPort', + NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled', + SEMANTIC_MODEL: 'selectedModel', + USER_PREFERENCES: 'userPreferences', + VECTOR_INDEX: 'vectorIndex', + USERSCRIPTS: 'userscripts', + USERSCRIPTS_DISABLED: 'userscripts_disabled', + // Record & Replay storage keys + RR_FLOWS: 'rr_flows', + RR_RUNS: 'rr_runs', + RR_PUBLISHED: 'rr_published_flows', + RR_SCHEDULES: 'rr_schedules', + RR_TRIGGERS: 'rr_triggers', + // Persistent recording state (guards resume across navigations/service worker restarts) + RR_RECORDING_STATE: 'rr_recording_state', +} as const; + +// Notification Configuration +export const NOTIFICATIONS = { + PRIORITY: 2, + TYPE: 'basic' as const, +} as const; + +export enum ExecutionWorld { + ISOLATED = 'ISOLATED', + MAIN = 'MAIN', +} diff --git a/app/chrome-extension/common/element-marker-types.ts b/app/chrome-extension/common/element-marker-types.ts new file mode 100644 index 0000000..30403fe --- /dev/null +++ b/app/chrome-extension/common/element-marker-types.ts @@ -0,0 +1,83 @@ +// Element marker types shared across background, content scripts, and popup + +export type UrlMatchType = 'exact' | 'prefix' | 'host'; + +export interface ElementMarker { + id: string; + // Original URL where the marker was created + url: string; + // Normalized pieces to support matching + origin: string; // scheme + host + port + host: string; // hostname + path: string; // pathname part only + matchType: UrlMatchType; // default: 'prefix' + + name: string; // Human-friendly name, e.g., "Login Button" + selector: string; // Selector string + selectorType?: 'css' | 'xpath'; // Default: css + listMode?: boolean; // Whether this marker was created in list mode (allows multiple matches) + action?: 'click' | 'fill' | 'custom'; // Intended action hint (optional) + + createdAt: number; + updatedAt: number; +} + +export interface UpsertMarkerRequest { + id?: string; + url: string; + name: string; + selector: string; + selectorType?: 'css' | 'xpath'; + listMode?: boolean; + matchType?: UrlMatchType; + action?: 'click' | 'fill' | 'custom'; +} + +// Validation actions for MCP-integrated verification +export enum MarkerValidationAction { + Hover = 'hover', + LeftClick = 'left_click', + RightClick = 'right_click', + DoubleClick = 'double_click', + TypeText = 'type_text', + PressKeys = 'press_keys', + Scroll = 'scroll', +} + +export interface MarkerValidationRequest { + selector: string; + selectorType?: 'css' | 'xpath'; + action: MarkerValidationAction; + // Optional payload for certain actions + text?: string; // for type_text + keys?: string; // for press_keys + // Event options for click-like actions + button?: 'left' | 'right' | 'middle'; + bubbles?: boolean; + cancelable?: boolean; + modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }; + // Targeting options + coordinates?: { x: number; y: number }; // absolute viewport coords + offsetX?: number; // relative to element center if relativeTo = 'element' + offsetY?: number; + relativeTo?: 'element' | 'viewport'; + // Navigation options for click-like actions + waitForNavigation?: boolean; + timeoutMs?: number; + // Scroll options + scrollDirection?: 'up' | 'down' | 'left' | 'right'; + scrollAmount?: number; // pixels per tick +} + +export interface MarkerValidationResponse { + success: boolean; + resolved?: boolean; + ref?: string; + center?: { x: number; y: number }; + tool?: { name: string; ok: boolean; error?: string }; + error?: string; +} + +export interface MarkerQuery { + url?: string; // If present, query by URL match; otherwise list all +} diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts new file mode 100644 index 0000000..9698185 --- /dev/null +++ b/app/chrome-extension/common/message-types.ts @@ -0,0 +1,395 @@ +/** + * Consolidated message type constants for Chrome extension communication + * Note: Native message types are imported from the shared package + */ + +import type { RealtimeEvent } from 'chrome-mcp-shared'; + +// Message targets for routing +export enum MessageTarget { + Offscreen = 'offscreen', + ContentScript = 'content_script', + Background = 'background', +} + +// Background script message types +export const BACKGROUND_MESSAGE_TYPES = { + SWITCH_SEMANTIC_MODEL: 'switch_semantic_model', + GET_MODEL_STATUS: 'get_model_status', + UPDATE_MODEL_STATUS: 'update_model_status', + GET_STORAGE_STATS: 'get_storage_stats', + CLEAR_ALL_DATA: 'clear_all_data', + GET_SERVER_STATUS: 'get_server_status', + REFRESH_SERVER_STATUS: 'refresh_server_status', + SERVER_STATUS_CHANGED: 'server_status_changed', + INITIALIZE_SEMANTIC_ENGINE: 'initialize_semantic_engine', + // Record & Replay background control and queries + RR_START_RECORDING: 'rr_start_recording', + RR_STOP_RECORDING: 'rr_stop_recording', + RR_PAUSE_RECORDING: 'rr_pause_recording', + RR_RESUME_RECORDING: 'rr_resume_recording', + RR_GET_RECORDING_STATUS: 'rr_get_recording_status', + RR_LIST_FLOWS: 'rr_list_flows', + RR_FLOWS_CHANGED: 'rr_flows_changed', + RR_GET_FLOW: 'rr_get_flow', + RR_DELETE_FLOW: 'rr_delete_flow', + RR_PUBLISH_FLOW: 'rr_publish_flow', + RR_UNPUBLISH_FLOW: 'rr_unpublish_flow', + RR_RUN_FLOW: 'rr_run_flow', + RR_SAVE_FLOW: 'rr_save_flow', + RR_EXPORT_FLOW: 'rr_export_flow', + RR_EXPORT_ALL: 'rr_export_all', + RR_IMPORT_FLOW: 'rr_import_flow', + RR_LIST_RUNS: 'rr_list_runs', + // Triggers + RR_LIST_TRIGGERS: 'rr_list_triggers', + RR_SAVE_TRIGGER: 'rr_save_trigger', + RR_DELETE_TRIGGER: 'rr_delete_trigger', + RR_REFRESH_TRIGGERS: 'rr_refresh_triggers', + // Scheduling + RR_SCHEDULE_FLOW: 'rr_schedule_flow', + RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow', + RR_LIST_SCHEDULES: 'rr_list_schedules', + // Element marker management + ELEMENT_MARKER_LIST_ALL: 'element_marker_list_all', + ELEMENT_MARKER_LIST_FOR_URL: 'element_marker_list_for_url', + ELEMENT_MARKER_SAVE: 'element_marker_save', + ELEMENT_MARKER_UPDATE: 'element_marker_update', + ELEMENT_MARKER_DELETE: 'element_marker_delete', + ELEMENT_MARKER_VALIDATE: 'element_marker_validate', + ELEMENT_MARKER_START: 'element_marker_start_from_popup', + // Element picker (human-in-the-loop element selection) + ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event', + ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event', + // Web editor (in-page visual editing) + WEB_EDITOR_TOGGLE: 'web_editor_toggle', + WEB_EDITOR_APPLY: 'web_editor_apply', + WEB_EDITOR_STATUS_QUERY: 'web_editor_status_query', + // Web editor <-> AgentChat integration (Phase 1.1) + WEB_EDITOR_APPLY_BATCH: 'web_editor_apply_batch', + WEB_EDITOR_TX_CHANGED: 'web_editor_tx_changed', + WEB_EDITOR_HIGHLIGHT_ELEMENT: 'web_editor_highlight_element', + // Web editor <-> AgentChat integration (Phase 2 - Revert) + WEB_EDITOR_REVERT_ELEMENT: 'web_editor_revert_element', + // Web editor <-> AgentChat integration - Selection sync + WEB_EDITOR_SELECTION_CHANGED: 'web_editor_selection_changed', + // Web editor <-> AgentChat integration - Clear selection (sidepanel -> web-editor) + WEB_EDITOR_CLEAR_SELECTION: 'web_editor_clear_selection', + // Web editor <-> AgentChat integration - Cancel execution + WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution', + // Web editor props (Phase 7.1.6 early injection) + WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection', + // Web editor props - open source file in VSCode + WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source', + // Quick Panel <-> AgentChat integration + QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai', + QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai', + // Quick Panel Search - Tabs bridge + QUICK_PANEL_TABS_QUERY: 'quick_panel_tabs_query', + QUICK_PANEL_TAB_ACTIVATE: 'quick_panel_tab_activate', + QUICK_PANEL_TAB_CLOSE: 'quick_panel_tab_close', +} as const; + +// Offscreen message types +export const OFFSCREEN_MESSAGE_TYPES = { + SIMILARITY_ENGINE_INIT: 'similarityEngineInit', + SIMILARITY_ENGINE_COMPUTE: 'similarityEngineCompute', + SIMILARITY_ENGINE_BATCH_COMPUTE: 'similarityEngineBatchCompute', + SIMILARITY_ENGINE_STATUS: 'similarityEngineStatus', + // GIF encoding + GIF_ADD_FRAME: 'gifAddFrame', + GIF_FINISH: 'gifFinish', + GIF_RESET: 'gifReset', +} as const; + +// Content script message types +export const CONTENT_MESSAGE_TYPES = { + WEB_FETCHER_GET_TEXT_CONTENT: 'webFetcherGetTextContent', + WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent', + NETWORK_CAPTURE_PING: 'network_capture_ping', + CLICK_HELPER_PING: 'click_helper_ping', + FILL_HELPER_PING: 'fill_helper_ping', + KEYBOARD_HELPER_PING: 'keyboard_helper_ping', + SCREENSHOT_HELPER_PING: 'screenshot_helper_ping', + INTERACTIVE_ELEMENTS_HELPER_PING: 'interactive_elements_helper_ping', + ACCESSIBILITY_TREE_HELPER_PING: 'chrome_read_page_ping', + WAIT_HELPER_PING: 'wait_helper_ping', + DOM_OBSERVER_PING: 'dom_observer_ping', +} as const; + +// Tool action message types (for chrome.runtime.sendMessage) +export const TOOL_MESSAGE_TYPES = { + // Screenshot related + SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE: 'preparePageForCapture', + SCREENSHOT_GET_PAGE_DETAILS: 'getPageDetails', + SCREENSHOT_GET_ELEMENT_DETAILS: 'getElementDetails', + SCREENSHOT_SCROLL_PAGE: 'scrollPage', + SCREENSHOT_RESET_PAGE_AFTER_CAPTURE: 'resetPageAfterCapture', + + // Web content fetching + WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent', + WEB_FETCHER_GET_TEXT_CONTENT: 'getTextContent', + + // User interactions + CLICK_ELEMENT: 'clickElement', + FILL_ELEMENT: 'fillElement', + SIMULATE_KEYBOARD: 'simulateKeyboard', + + // Interactive elements + GET_INTERACTIVE_ELEMENTS: 'getInteractiveElements', + + // Accessibility tree + GENERATE_ACCESSIBILITY_TREE: 'generateAccessibilityTree', + RESOLVE_REF: 'resolveRef', + ENSURE_REF_FOR_SELECTOR: 'ensureRefForSelector', + VERIFY_FINGERPRINT: 'verifyFingerprint', + DISPATCH_HOVER_FOR_REF: 'dispatchHoverForRef', + + // Network requests + NETWORK_SEND_REQUEST: 'sendPureNetworkRequest', + + // Wait helper + WAIT_FOR_TEXT: 'waitForText', + + // Semantic similarity engine + SIMILARITY_ENGINE_INIT: 'similarityEngineInit', + SIMILARITY_ENGINE_COMPUTE_BATCH: 'similarityEngineComputeBatch', + // Record & Replay content script bridge + RR_RECORDER_CONTROL: 'rr_recorder_control', + RR_RECORDER_EVENT: 'rr_recorder_event', + // Record & Replay timeline feed (background -> content overlay) + RR_TIMELINE_UPDATE: 'rr_timeline_update', + // Quick Panel AI streaming events (background -> content script) + QUICK_PANEL_AI_EVENT: 'quick_panel_ai_event', + // DOM observer trigger bridge + SET_DOM_TRIGGERS: 'set_dom_triggers', + DOM_TRIGGER_FIRED: 'dom_trigger_fired', + // Record & Replay overlay: variable collection + COLLECT_VARIABLES: 'collectVariables', + // Element marker overlay control (content-side) + ELEMENT_MARKER_START: 'element_marker_start', + // Element picker (tool-driven, background <-> content scripts) + ELEMENT_PICKER_START: 'elementPickerStart', + ELEMENT_PICKER_STOP: 'elementPickerStop', + ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest', + ELEMENT_PICKER_UI_PING: 'elementPickerUiPing', + ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow', + ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate', + ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide', +} as const; + +// Type unions for type safety +export type BackgroundMessageType = + (typeof BACKGROUND_MESSAGE_TYPES)[keyof typeof BACKGROUND_MESSAGE_TYPES]; +export type OffscreenMessageType = + (typeof OFFSCREEN_MESSAGE_TYPES)[keyof typeof OFFSCREEN_MESSAGE_TYPES]; +export type ContentMessageType = (typeof CONTENT_MESSAGE_TYPES)[keyof typeof CONTENT_MESSAGE_TYPES]; +export type ToolMessageType = (typeof TOOL_MESSAGE_TYPES)[keyof typeof TOOL_MESSAGE_TYPES]; + +// Legacy enum for backward compatibility (will be deprecated) +export enum SendMessageType { + // Screenshot related message types + ScreenshotPreparePageForCapture = 'preparePageForCapture', + ScreenshotGetPageDetails = 'getPageDetails', + ScreenshotGetElementDetails = 'getElementDetails', + ScreenshotScrollPage = 'scrollPage', + ScreenshotResetPageAfterCapture = 'resetPageAfterCapture', + + // Web content fetching related message types + WebFetcherGetHtmlContent = 'getHtmlContent', + WebFetcherGetTextContent = 'getTextContent', + + // Click related message types + ClickElement = 'clickElement', + + // Input filling related message types + FillElement = 'fillElement', + + // Interactive elements related message types + GetInteractiveElements = 'getInteractiveElements', + + // Network request capture related message types + NetworkSendRequest = 'sendPureNetworkRequest', + + // Keyboard event related message types + SimulateKeyboard = 'simulateKeyboard', + + // Semantic similarity engine related message types + SimilarityEngineInit = 'similarityEngineInit', + SimilarityEngineComputeBatch = 'similarityEngineComputeBatch', +} + +// ============================================================ +// Quick Panel <-> AgentChat Message Contracts +// ============================================================ + +/** + * Context information that can be attached to a Quick Panel AI request. + * Allows passing page-specific data to enhance the AI's understanding. + */ +export interface QuickPanelAIContext { + /** Current page URL */ + pageUrl?: string; + /** User's text selection on the page */ + selectedText?: string; + /** + * Optional element metadata from the page. + * Kept as unknown to avoid tight coupling with specific element types. + */ + elementInfo?: unknown; +} + +/** + * Payload for sending a message to AI via Quick Panel. + */ +export interface QuickPanelSendToAIPayload { + /** The user's instruction/question for the AI */ + instruction: string; + /** Optional contextual information from the page */ + context?: QuickPanelAIContext; +} + +/** + * Response from QUICK_PANEL_SEND_TO_AI message handler. + */ +export type QuickPanelSendToAIResponse = + | { success: true; requestId: string; sessionId: string } + | { success: false; error: string }; + +/** + * Message structure for sending to AI. + */ +export interface QuickPanelSendToAIMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI; + payload: QuickPanelSendToAIPayload; +} + +/** + * Payload for cancelling an active AI request. + */ +export interface QuickPanelCancelAIPayload { + /** The request ID to cancel */ + requestId: string; + /** + * Optional session ID for fallback when background state is missing. + * This can happen after MV3 Service Worker restarts. + */ + sessionId?: string; +} + +/** + * Response from QUICK_PANEL_CANCEL_AI message handler. + */ +export type QuickPanelCancelAIResponse = { success: true } | { success: false; error: string }; + +/** + * Message structure for cancelling AI request. + */ +export interface QuickPanelCancelAIMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI; + payload: QuickPanelCancelAIPayload; +} + +/** + * Message pushed from background to content script with AI streaming events. + * Uses the same RealtimeEvent type as AgentChat for consistency. + */ +export interface QuickPanelAIEventMessage { + action: typeof TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT; + requestId: string; + sessionId: string; + event: RealtimeEvent; +} + +// ============================================================ +// Quick Panel Search - Tabs Bridge Contracts +// ============================================================ + +/** + * Payload for querying open tabs. + */ +export interface QuickPanelTabsQueryPayload { + /** + * When true (default), query tabs across all windows. + * When false, restrict results to the sender's window. + */ + includeAllWindows?: boolean; +} + +/** + * Summary of a single tab returned from the background. + */ +export interface QuickPanelTabSummary { + tabId: number; + windowId: number; + title: string; + url: string; + favIconUrl?: string; + active: boolean; + pinned: boolean; + audible: boolean; + muted: boolean; + index: number; + lastAccessed?: number; +} + +/** + * Response from QUICK_PANEL_TABS_QUERY message handler. + */ +export type QuickPanelTabsQueryResponse = + | { + success: true; + tabs: QuickPanelTabSummary[]; + currentTabId: number | null; + currentWindowId: number | null; + } + | { success: false; error: string }; + +/** + * Message structure for querying tabs. + */ +export interface QuickPanelTabsQueryMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY; + payload?: QuickPanelTabsQueryPayload; +} + +/** + * Payload for activating a tab. + */ +export interface QuickPanelActivateTabPayload { + tabId: number; + windowId?: number; +} + +/** + * Response from QUICK_PANEL_TAB_ACTIVATE message handler. + */ +export type QuickPanelActivateTabResponse = { success: true } | { success: false; error: string }; + +/** + * Message structure for activating a tab. + */ +export interface QuickPanelActivateTabMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE; + payload: QuickPanelActivateTabPayload; +} + +/** + * Payload for closing a tab. + */ +export interface QuickPanelCloseTabPayload { + tabId: number; +} + +/** + * Response from QUICK_PANEL_TAB_CLOSE message handler. + */ +export type QuickPanelCloseTabResponse = { success: true } | { success: false; error: string }; + +/** + * Message structure for closing a tab. + */ +export interface QuickPanelCloseTabMessage { + type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE; + payload: QuickPanelCloseTabPayload; +} diff --git a/app/chrome-extension/common/node-types.ts b/app/chrome-extension/common/node-types.ts new file mode 100644 index 0000000..38c828c --- /dev/null +++ b/app/chrome-extension/common/node-types.ts @@ -0,0 +1,14 @@ +// node-types.ts — centralized node type constants for Builder/UI layer +// Combines all executable Step types with UI-only nodes (e.g., trigger, delay) + +import { STEP_TYPES } from './step-types'; + +export const NODE_TYPES = { + // Executable step types (spread from STEP_TYPES) + ...STEP_TYPES, + // UI-only nodes + TRIGGER: 'trigger', + DELAY: 'delay', +} as const; + +export type NodeTypeConst = (typeof NODE_TYPES)[keyof typeof NODE_TYPES]; diff --git a/app/chrome-extension/common/rr-v3-keepalive-protocol.ts b/app/chrome-extension/common/rr-v3-keepalive-protocol.ts new file mode 100644 index 0000000..cf33f7d --- /dev/null +++ b/app/chrome-extension/common/rr-v3-keepalive-protocol.ts @@ -0,0 +1,26 @@ +/** + * @fileoverview RR V3 Keepalive Protocol Constants + * @description Shared protocol constants for Background-Offscreen keepalive communication + */ + +/** Keepalive Port 名称 */ +export const RR_V3_KEEPALIVE_PORT_NAME = 'rr_v3_keepalive' as const; + +/** Keepalive 消息类型 */ +export type KeepaliveMessageType = + | 'keepalive.ping' + | 'keepalive.pong' + | 'keepalive.start' + | 'keepalive.stop'; + +/** Keepalive 消息 */ +export interface KeepaliveMessage { + type: KeepaliveMessageType; + timestamp: number; +} + +/** 默认心跳间隔(毫秒) - Offscreen 每隔这个间隔发送 ping */ +export const DEFAULT_KEEPALIVE_PING_INTERVAL_MS = 20_000; + +/** 最大心跳间隔(毫秒)- Chrome MV3 SW 约 30s 空闲后终止 */ +export const MAX_KEEPALIVE_PING_INTERVAL_MS = 25_000; diff --git a/app/chrome-extension/common/step-types.ts b/app/chrome-extension/common/step-types.ts new file mode 100644 index 0000000..16d5680 --- /dev/null +++ b/app/chrome-extension/common/step-types.ts @@ -0,0 +1,4 @@ +// step-types.ts — re-export shared constants to keep single source of truth +export { STEP_TYPES } from 'chrome-mcp-shared'; +export type StepTypeConst = + (typeof import('chrome-mcp-shared'))['STEP_TYPES'][keyof (typeof import('chrome-mcp-shared'))['STEP_TYPES']]; diff --git a/app/chrome-extension/common/tool-handler.ts b/app/chrome-extension/common/tool-handler.ts new file mode 100644 index 0000000..65909e2 --- /dev/null +++ b/app/chrome-extension/common/tool-handler.ts @@ -0,0 +1,24 @@ +import type { CallToolResult, TextContent, ImageContent } from '@modelcontextprotocol/sdk/types.js'; + +export interface ToolResult extends CallToolResult { + content: (TextContent | ImageContent)[]; + isError: boolean; +} + +export interface ToolExecutor { + execute(args: any): Promise; +} + +export const createErrorResponse = ( + message: string = 'Unknown error, please try again', +): ToolResult => { + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +}; diff --git a/app/chrome-extension/common/web-editor-types.ts b/app/chrome-extension/common/web-editor-types.ts new file mode 100644 index 0000000..815dbeb --- /dev/null +++ b/app/chrome-extension/common/web-editor-types.ts @@ -0,0 +1,539 @@ +/** + * Web Editor V2 - Shared Type Definitions + * + * This module defines types shared between: + * - Background script (injection control) + * - Inject script (web-editor-v2.ts) + * - Future: UI panels + */ + +// ============================================================================= +// Editor State +// ============================================================================= + +/** Current state of the web editor */ +export interface WebEditorState { + /** Whether the editor is currently active */ + active: boolean; + /** Editor version for compatibility checks */ + version: 2; +} + +// ============================================================================= +// Message Protocol (Background <-> Inject Script) +// ============================================================================= + +/** + * Action types for web editor V2 messages + * + * IMPORTANT: V2 uses versioned action names (suffix _v2) to avoid + * conflicts with V1 when both scripts might be injected in the same tab. + * This prevents double-response race conditions. + * + * V1 uses: web_editor_ping, web_editor_toggle, etc. + * V2 uses: web_editor_ping_v2, web_editor_toggle_v2, etc. + */ +export const WEB_EDITOR_V2_ACTIONS = { + /** Check if V2 editor is injected and get status */ + PING: 'web_editor_ping_v2', + /** Toggle V2 editor on/off */ + TOGGLE: 'web_editor_toggle_v2', + /** Start V2 editor */ + START: 'web_editor_start_v2', + /** Stop V2 editor */ + STOP: 'web_editor_stop_v2', + /** Highlight an element (from sidepanel hover) */ + HIGHLIGHT_ELEMENT: 'web_editor_highlight_element_v2', + /** Revert an element to its original state (Phase 2 - Selective Undo) */ + REVERT_ELEMENT: 'web_editor_revert_element_v2', + /** Clear selection (from sidepanel after send) */ + CLEAR_SELECTION: 'web_editor_clear_selection_v2', +} as const; + +/** + * Legacy V1 action types (for reference and background compatibility) + * These are used when USE_WEB_EDITOR_V2 is false + */ +export const WEB_EDITOR_V1_ACTIONS = { + PING: 'web_editor_ping', + TOGGLE: 'web_editor_toggle', + START: 'web_editor_start', + STOP: 'web_editor_stop', + APPLY: 'web_editor_apply', +} as const; + +export type WebEditorV2Action = (typeof WEB_EDITOR_V2_ACTIONS)[keyof typeof WEB_EDITOR_V2_ACTIONS]; +export type WebEditorV1Action = (typeof WEB_EDITOR_V1_ACTIONS)[keyof typeof WEB_EDITOR_V1_ACTIONS]; + +/** Editor version literal type */ +export type WebEditorVersion = 1 | 2; + +/** Ping request (V2) */ +export interface WebEditorV2PingRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.PING; +} + +/** Ping response (V2) */ +export interface WebEditorV2PingResponse { + status: 'pong'; + active: boolean; + version: 2; +} + +/** Toggle request (V2) */ +export interface WebEditorV2ToggleRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.TOGGLE; +} + +/** Toggle response (V2) */ +export interface WebEditorV2ToggleResponse { + active: boolean; +} + +/** Start request (V2) */ +export interface WebEditorV2StartRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.START; +} + +/** Start response (V2) */ +export interface WebEditorV2StartResponse { + active: boolean; +} + +/** Stop request (V2) */ +export interface WebEditorV2StopRequest { + action: typeof WEB_EDITOR_V2_ACTIONS.STOP; +} + +/** Stop response (V2) */ +export interface WebEditorV2StopResponse { + active: boolean; +} + +/** Union types for V2 type-safe message handling */ +export type WebEditorV2Request = + | WebEditorV2PingRequest + | WebEditorV2ToggleRequest + | WebEditorV2StartRequest + | WebEditorV2StopRequest; + +export type WebEditorV2Response = + | WebEditorV2PingResponse + | WebEditorV2ToggleResponse + | WebEditorV2StartResponse + | WebEditorV2StopResponse; + +// ============================================================================= +// Element Locator (Phase 1 - Basic Structure) +// ============================================================================= + +/** + * Framework debug source information + * Extracted from React Fiber or Vue component instance + */ +export interface DebugSource { + /** Source file path */ + file: string; + /** Line number (1-based) */ + line?: number; + /** Column number (1-based) */ + column?: number; + /** Component name (if available) */ + componentName?: string; +} + +/** + * Element Locator - Primary key for element identification + * + * Uses multiple strategies to locate elements, supporting: + * - HMR/DOM changes recovery + * - Cross-session persistence + * - Framework-agnostic identification + */ +export interface ElementLocator { + /** CSS selector candidates (ordered by specificity) */ + selectors: string[]; + /** Structural fingerprint for similarity matching */ + fingerprint: string; + /** Framework debug information (React/Vue) */ + debugSource?: DebugSource; + /** DOM tree path (child indices from root) */ + path: number[]; + /** iframe selector chain (from top to target frame) - Phase 4 */ + frameChain?: string[]; + /** Shadow DOM host selector chain - Phase 2 */ + shadowHostChain?: string[]; +} + +// ============================================================================= +// Transaction System (Phase 1 - Basic Structure, Low Priority) +// ============================================================================= + +/** Transaction operation types */ +export type TransactionType = 'style' | 'text' | 'class' | 'move' | 'structure'; + +/** + * Transaction snapshot for undo/redo + * Captures element state before/after changes + */ +export interface TransactionSnapshot { + /** Element locator for re-identification */ + locator: ElementLocator; + /** innerHTML snapshot (for structure changes) */ + html?: string; + /** Changed style properties */ + styles?: Record; + /** Class list tokens (from `class` attribute) */ + classes?: string[]; + /** Text content */ + text?: string; +} + +/** + * Move position data + * Captures a concrete insertion point under a parent element + */ +export interface MoveOperationData { + /** Target parent element locator */ + parentLocator: ElementLocator; + /** Insert position index (among element children) */ + insertIndex: number; + /** Anchor sibling element locator (for stable positioning) */ + anchorLocator?: ElementLocator; + /** Position relative to anchor */ + anchorPosition: 'before' | 'after'; +} + +/** + * Move transaction data + * Captures both source and destination for undo/redo + */ +export interface MoveTransactionData { + /** Original location before move */ + from: MoveOperationData; + /** Target location after move */ + to: MoveOperationData; +} + +/** + * Structure operation data + * For wrap/unwrap/delete/duplicate operations (Phase 5.5) + */ +export interface StructureOperationData { + /** Structure action type */ + action: 'wrap' | 'unwrap' | 'delete' | 'duplicate'; + /** Wrapper tag for wrap/unwrap actions */ + wrapperTag?: string; + /** Wrapper inline styles for wrap/unwrap actions */ + wrapperStyles?: Record; + /** + * Deterministic insertion position for undo/redo. + * Required for delete (restore) and duplicate (re-create). + */ + position?: MoveOperationData; + /** + * Serialized element HTML for undo/redo. + * Must be a single-root element outerHTML string. + * Used by delete (restore original) and duplicate (re-create clone). + */ + html?: string; +} + +/** + * Transaction record for undo/redo system + */ +export interface Transaction { + /** Unique transaction ID */ + id: string; + /** Operation type */ + type: TransactionType; + /** Target element locator */ + targetLocator: ElementLocator; + /** + * Stable element identifier for cross-transaction grouping. + * Used by AgentChat integration for element chips aggregation. + * Optional for backward compatibility with existing transactions. + */ + elementKey?: string; + /** State before change */ + before: TransactionSnapshot; + /** State after change */ + after: TransactionSnapshot; + /** Move-specific data */ + moveData?: MoveTransactionData; + /** Structure-specific data */ + structureData?: StructureOperationData; + /** Timestamp */ + timestamp: number; + /** Whether merged with previous transaction */ + merged: boolean; +} + +// ============================================================================= +// AgentChat Integration Types (Phase 1.1) +// ============================================================================= + +/** Stable element identifier for aggregating transactions across UI contexts */ +export type WebEditorElementKey = string; + +/** + * Net effect payload for a single element aggregated from the undo stack. + * Designed to be directly consumable by prompt builders. + */ +export interface NetEffectPayload { + /** Stable element key */ + elementKey: WebEditorElementKey; + /** Locator snapshot for element re-identification */ + locator: ElementLocator; + /** + * Aggregated style changes (first before -> last after). + * Contains ONLY the affected properties, not a full style snapshot. + * Empty string value means the property was removed/unset. + */ + styleChanges?: { + before: Record; + after: Record; + }; + /** Aggregated text change (first before -> last after) */ + textChange?: { + before: string; + after: string; + }; + /** Aggregated class changes (first before -> last after) */ + classChanges?: { + before: string[]; + after: string[]; + }; +} + +/** High-level change category for UI display */ +export type ElementChangeType = 'style' | 'text' | 'class' | 'mixed'; + +/** + * Element change summary for Chips rendering in AgentChat. + * Aggregates multiple transactions for the same element. + */ +export interface ElementChangeSummary { + /** Stable element identifier */ + elementKey: WebEditorElementKey; + /** Short label for Chips display (e.g., "button#submit") */ + label: string; + /** Full label for tooltips with more context */ + fullLabel: string; + /** Locator snapshot for highlighting and element recovery */ + locator: ElementLocator; + /** High-level change category */ + type: ElementChangeType; + /** Detailed change statistics for UI tooltips */ + changes: { + style?: { + /** Number of new style properties added */ + added: number; + /** Number of style properties removed */ + removed: number; + /** Number of style properties modified */ + modified: number; + /** List of affected style property names */ + details: string[]; + }; + text?: { + /** Truncated preview of original text */ + beforePreview: string; + /** Truncated preview of new text */ + afterPreview: string; + }; + class?: { + /** Classes added */ + added: string[]; + /** Classes removed */ + removed: string[]; + }; + }; + /** Contributing transaction IDs in chronological order */ + transactionIds: string[]; + /** Net effect payload for batch Apply */ + netEffect: NetEffectPayload; + /** Timestamp of the most recent transaction */ + updatedAt: number; + /** Debug source information if available */ + debugSource?: DebugSource; +} + +/** Action types for TX change events */ +export type WebEditorTxChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback'; + +/** + * TX change broadcast payload sent to Sidepanel/AgentChat. + * Emitted when the undo stack changes (push, undo, redo, clear). + */ +export interface WebEditorTxChangedPayload { + /** Source tab ID for multi-tab isolation */ + tabId: number; + /** Action that triggered this change (for UI animations/incremental updates) */ + action: WebEditorTxChangeAction; + /** Aggregated element-level summaries from the current undo stack */ + elements: ElementChangeSummary[]; + /** Current undo stack size */ + undoCount: number; + /** Current redo stack size */ + redoCount: number; + /** Whether there are applicable changes (style/text/class) */ + hasApplicableChanges: boolean; + /** Page URL for context */ + pageUrl?: string; +} + +/** + * Batch Apply payload sent from web-editor to background. + */ +export interface WebEditorApplyBatchPayload { + /** Source tab ID */ + tabId: number; + /** Element changes to apply */ + elements: ElementChangeSummary[]; + /** Element keys excluded by user */ + excludedKeys: WebEditorElementKey[]; + /** Page URL for context */ + pageUrl?: string; +} + +/** + * Highlight element request sent from AgentChat to the active tab. + */ +export interface WebEditorHighlightElementPayload { + /** Target tab ID */ + tabId: number; + /** Element key to highlight */ + elementKey: WebEditorElementKey; + /** Locator for element identification */ + locator: ElementLocator; + /** Highlight mode: 'hover' to show, 'clear' to hide */ + mode: 'hover' | 'clear'; +} + +/** + * Revert element request sent from AgentChat to the active tab. + * Used for Phase 2 - Selective Undo (reverting individual element changes). + */ +export interface WebEditorRevertElementPayload { + /** Target tab ID */ + tabId: number; + /** Element key to revert */ + elementKey: WebEditorElementKey; +} + +/** + * Revert element response from content script. + */ +export interface WebEditorRevertElementResponse { + /** Whether the revert was successful */ + success: boolean; + /** What was reverted (for UI feedback) */ + reverted?: { + style?: boolean; + text?: boolean; + class?: boolean; + }; + /** Error message if revert failed */ + error?: string; +} + +// ============================================================================= +// Selection Sync Types +// ============================================================================= + +/** + * Summary of currently selected element. + * Lightweight payload for selection sync (no transaction data). + */ +export interface SelectedElementSummary { + /** Stable element identifier */ + elementKey: WebEditorElementKey; + /** Locator for element identification and highlighting */ + locator: ElementLocator; + /** Short display label (e.g., "div#app") */ + label: string; + /** Full label with context (e.g., "body > div#app") */ + fullLabel: string; + /** Tag name of the element */ + tagName: string; + /** Timestamp for deduplication */ + updatedAt: number; +} + +/** + * Selection change broadcast payload. + * Sent immediately when user selects/deselects elements (no debounce). + */ +export interface WebEditorSelectionChangedPayload { + /** Source tab ID (filled by background from sender.tab.id) */ + tabId: number; + /** Currently selected element, or null if deselected */ + selected: SelectedElementSummary | null; + /** Page URL for context */ + pageUrl?: string; +} + +// ============================================================================= +// Execution Cancel Types +// ============================================================================= + +/** + * Payload for canceling an ongoing Apply execution. + * Sent from web-editor toolbar or sidepanel to background. + */ +export interface WebEditorCancelExecutionPayload { + /** Session ID of the execution to cancel */ + sessionId: string; + /** Request ID of the execution to cancel */ + requestId: string; +} + +/** + * Response from cancel execution request. + */ +export interface WebEditorCancelExecutionResponse { + /** Whether the cancel request was successful */ + success: boolean; + /** Error message if cancellation failed */ + error?: string; +} + +// ============================================================================= +// Public API Interface +// ============================================================================= + +/** + * Web Editor V2 Public API + * Exposed on window.__MCP_WEB_EDITOR_V2__ + */ +export interface WebEditorV2Api { + /** Start the editor */ + start: () => void; + /** Stop the editor */ + stop: () => void; + /** Toggle editor on/off, returns new state */ + toggle: () => boolean; + /** Get current state */ + getState: () => WebEditorState; + /** + * Revert a specific element to its original state (Phase 2 - Selective Undo). + * Creates a compensating transaction that can be undone. + */ + revertElement: (elementKey: WebEditorElementKey) => Promise; + /** + * Clear current selection (called from sidepanel after send). + * Triggers deselect and broadcasts null selection. + */ + clearSelection: () => void; +} + +// ============================================================================= +// Global Declaration +// ============================================================================= + +declare global { + interface Window { + __MCP_WEB_EDITOR_V2__?: WebEditorV2Api; + } +} diff --git a/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts b/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts new file mode 100644 index 0000000..7a90956 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts @@ -0,0 +1,95 @@ +// IndexedDB storage for element markers (URL -> marked selectors) +// Uses the shared IndexedDbClient for robust transaction handling. + +import { IndexedDbClient } from '@/utils/indexeddb-client'; +import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types'; + +const DB_NAME = 'element_marker_storage'; +const DB_VERSION = 1; +const STORE = 'markers'; + +const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => { + switch (oldVersion) { + case 0: { + const store = db.createObjectStore(STORE, { keyPath: 'id' }); + // Useful indexes for lookups + store.createIndex('by_host', 'host', { unique: false }); + store.createIndex('by_origin', 'origin', { unique: false }); + store.createIndex('by_path', 'path', { unique: false }); + } + } +}); + +function normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } { + try { + const u = new URL(raw); + return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname }; + } catch { + return { url: raw, origin: '', host: '', path: '' }; + } +} + +function now(): number { + return Date.now(); +} + +export async function listAllMarkers(): Promise { + return idb.getAll(STORE); +} + +export async function listMarkersForUrl(url: string): Promise { + const { origin, path, host } = normalizeUrl(url); + const all = await idb.getAll(STORE); + // Simple matching policy: + // - exact: origin + path must match exactly + // - prefix: origin matches and marker.path is a prefix of current path + // - host: host matches regardless of path + return all.filter((m) => { + if (!m) return false; + if (m.matchType === 'exact') return m.origin === origin && m.path === path; + if (m.matchType === 'host') return !!m.host && m.host === host; + // default 'prefix' + return m.origin === origin && (m.path ? path.startsWith(m.path) : true); + }); +} + +export async function saveMarker(req: UpsertMarkerRequest): Promise { + const { url: rawUrl, selector } = req; + if (!rawUrl || !selector) throw new Error('url and selector are required'); + const { url, origin, host, path } = normalizeUrl(rawUrl); + const ts = now(); + const marker: ElementMarker = { + id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`), + url, + origin, + host, + path, + matchType: req.matchType || 'prefix', + name: req.name || selector, + selector, + selectorType: req.selectorType || 'css', + listMode: req.listMode || false, + action: req.action || 'custom', + createdAt: ts, + updatedAt: ts, + }; + await idb.put(STORE, marker); + return marker; +} + +export async function updateMarker(marker: ElementMarker): Promise { + const existing = await idb.get(STORE, marker.id); + if (!existing) throw new Error('marker not found'); + + // Preserve createdAt from existing record, only update updatedAt + const updated: ElementMarker = { + ...marker, + createdAt: existing.createdAt, // Never overwrite createdAt + updatedAt: now(), + }; + await idb.put(STORE, updated); +} + +export async function deleteMarker(id: string): Promise { + await idb.delete(STORE, id); +} diff --git a/app/chrome-extension/entrypoints/background/element-marker/index.ts b/app/chrome-extension/entrypoints/background/element-marker/index.ts new file mode 100644 index 0000000..01d4a1b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/element-marker/index.ts @@ -0,0 +1,409 @@ +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import type { + UpsertMarkerRequest, + ElementMarker, + MarkerValidationRequest, + MarkerValidationAction, +} from '@/common/element-marker-types'; +import { + deleteMarker, + listAllMarkers, + listMarkersForUrl, + saveMarker, + updateMarker, +} from './element-marker-storage'; +import { computerTool } from '@/entrypoints/background/tools/browser/computer'; +import { clickTool } from '@/entrypoints/background/tools/browser/interaction'; +import { keyboardTool } from '@/entrypoints/background/tools/browser/keyboard'; + +const CONTEXT_MENU_ID = 'element_marker_mark'; + +/** + * Extract error message from MCP tool result + */ +function extractToolError(result: any): string | undefined { + if (!result) return undefined; + + // Check for error in result content array + if (Array.isArray(result.content)) { + for (const item of result.content) { + if (item?.text) { + try { + const parsed = JSON.parse(item.text); + if (parsed?.error) return parsed.error; + if (parsed?.message) return parsed.message; + } catch { + // Not JSON, use as-is + return item.text; + } + } + } + } + + // Fallback to direct error field + return result.error || (result.isError ? 'unknown tool error' : undefined); +} + +async function ensureContextMenu() { + try { + // Guard: contextMenus permission may be missing + if (!(chrome as any).contextMenus?.create) return; + // Remove and re-create our single menu to avoid duplication + try { + await chrome.contextMenus.remove(CONTEXT_MENU_ID); + } catch {} + await chrome.contextMenus.create({ + id: CONTEXT_MENU_ID, + title: '标注元素', + contexts: ['all'], + }); + } catch (e) { + console.warn('ElementMarker: ensureContextMenu failed:', e); + } +} + +/** + * Check if element-marker.js is already injected in the tab + * Uses a short timeout to avoid hanging on unresponsive tabs + */ +async function isMarkerInjected(tabId: number): Promise { + try { + const response = await Promise.race([ + chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }), + new Promise((resolve) => setTimeout(() => resolve(null), 300)), + ]); + return response?.status === 'pong'; + } catch { + return false; + } +} + +/** + * Inject element-marker.js into the tab if not already injected + */ +async function injectMarkerHelper(tabId: number) { + // Check if already injected via ping + const alreadyInjected = await isMarkerInjected(tabId); + + if (!alreadyInjected) { + try { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['inject-scripts/element-marker.js'], + world: 'ISOLATED', + } as any); + } catch (e) { + // Script injection may fail on some pages (e.g., chrome:// URLs) + console.warn('ElementMarker: script injection failed:', e); + } + } + + try { + await chrome.tabs.sendMessage(tabId, { action: 'element_marker_start' } as any); + } catch (e) { + console.warn('ElementMarker: start overlay failed:', e); + } +} + +export function initElementMarkerListeners() { + // Ensure context menu on startup + ensureContextMenu().catch(() => {}); + + // Respond to RR triggers refresh by re-ensuring our menu a bit later + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + try { + switch (message?.type) { + // Handle element marker start from popup + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START: { + const tabId = message.tabId; + if (typeof tabId !== 'number') { + sendResponse({ success: false, error: 'invalid tabId' }); + return true; + } + injectMarkerHelper(tabId) + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL: { + listAllMarkers() + .then((markers) => sendResponse({ success: true, markers })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL: { + const url = String(message.url || ''); + listMarkersForUrl(url) + .then((markers) => sendResponse({ success: true, markers })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE: { + const req = message.marker as UpsertMarkerRequest; + saveMarker(req) + .then((marker) => sendResponse({ success: true, marker })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE: { + const marker = message.marker as ElementMarker; + updateMarker(marker) + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE: { + const id = String(message.id || ''); + if (!id) { + sendResponse({ success: false, error: 'invalid id' }); + return true; + } + deleteMarker(id) + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE: { + // Validate via MCP tool chain + (async () => { + const req = message as { + selector: string; + selectorType?: 'css' | 'xpath'; + action: MarkerValidationAction; + listMode?: boolean; + text?: string; + keys?: string; + button?: 'left' | 'right' | 'middle'; + bubbles?: boolean; + cancelable?: boolean; + modifiers?: any; + coordinates?: { x: number; y: number }; + offsetX?: number; + offsetY?: number; + relativeTo?: 'element' | 'viewport'; + }; + // enrich typing with optional nav + scroll params + (req as any).waitForNavigation = (message as any).waitForNavigation; + (req as any).timeoutMs = (message as any).timeoutMs; + (req as any).scrollDirection = (message as any).scrollDirection; + (req as any).scrollAmount = (message as any).scrollAmount; + const selector = String(req.selector || '').trim(); + const selectorType = (req.selectorType || 'css') as 'css' | 'xpath'; + const action = req.action as MarkerValidationAction; + if (!selector) return sendResponse({ success: false, error: 'selector is required' }); + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + if (!tab?.id) return sendResponse({ success: false, error: 'active tab not found' }); + + // 1) Ensure helper + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id, allFrames: true }, + files: ['inject-scripts/accessibility-tree-helper.js'], + world: 'ISOLATED', + } as any); + } catch {} + + // 2) Resolve selector -> ref/center via helper (same as tools) + let ensured: any; + try { + ensured = await chrome.tabs.sendMessage(tab.id, { + action: 'ensureRefForSelector', + selector, + isXPath: selectorType === 'xpath', + allowMultiple: !!req.listMode, + } as any); + } catch (e) { + return sendResponse({ + success: false, + error: String(e instanceof Error ? e.message : e), + }); + } + if (!ensured || !ensured.success || !ensured.ref) { + return sendResponse({ + success: false, + error: ensured?.error || 'failed to resolve selector', + }); + } + + const base = { + success: true, + resolved: true, + ref: ensured.ref, + center: ensured.center, + } as any; + + // Compute optional coordinates from offsets + let coords: { x: number; y: number } | undefined = undefined; + if ( + req.coordinates && + typeof req.coordinates.x === 'number' && + typeof req.coordinates.y === 'number' + ) { + coords = { x: Math.round(req.coordinates.x), y: Math.round(req.coordinates.y) }; + } else if ( + req.relativeTo === 'element' && + ensured.center && + (typeof req.offsetX === 'number' || typeof req.offsetY === 'number') + ) { + const dx = Number.isFinite(req.offsetX as any) ? (req.offsetX as number) : 0; + const dy = Number.isFinite(req.offsetY as any) ? (req.offsetY as number) : 0; + coords = { x: ensured.center.x + dx, y: ensured.center.y + dy }; + } + + // 3) Dispatch to appropriate tool for end-to-end validation + try { + switch (action) { + case 'hover': { + const r = await computerTool.execute( + coords + ? { action: 'hover', coordinates: coords } + : ({ action: 'hover', ref: ensured.ref } as any), + ); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'computer.hover', ok: !r.isError, error }; + break; + } + case 'left_click': { + const r = await clickTool.execute({ + ...(coords ? { coordinates: coords } : { ref: ensured.ref }), + waitForNavigation: !!req.waitForNavigation, + timeout: Number.isFinite(req.timeoutMs as any) + ? (req.timeoutMs as number) + : 3000, + button: (req.button || 'left') as any, + modifiers: req.modifiers || {}, + } as any); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'interaction.click', ok: !r.isError, error }; + break; + } + case 'double_click': { + const r = await clickTool.execute({ + ...(coords ? { coordinates: coords } : { ref: ensured.ref }), + double: true, + waitForNavigation: !!req.waitForNavigation, + timeout: Number.isFinite(req.timeoutMs as any) + ? (req.timeoutMs as number) + : 3000, + button: (req.button || 'left') as any, + modifiers: req.modifiers || {}, + } as any); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'interaction.click(double)', ok: !r.isError, error }; + break; + } + case 'right_click': { + const r = await clickTool.execute({ + ...(coords ? { coordinates: coords } : { ref: ensured.ref }), + waitForNavigation: !!req.waitForNavigation, + timeout: Number.isFinite(req.timeoutMs as any) + ? (req.timeoutMs as number) + : 3000, + button: 'right', + modifiers: req.modifiers || {}, + } as any); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'interaction.click(right)', ok: !r.isError, error }; + break; + } + case 'scroll': { + const direction = (req as any).scrollDirection || 'down'; + const amount = Number.isFinite((req as any).scrollAmount) + ? Number((req as any).scrollAmount) + : 300; + const payload = coords + ? { + action: 'scroll', + scrollDirection: direction, + scrollAmount: amount, + coordinates: coords, + } + : ({ + action: 'scroll', + scrollDirection: direction, + scrollAmount: amount, + ref: ensured.ref, + } as any); + const r = await computerTool.execute(payload as any); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'computer.scroll', ok: !r.isError, error }; + break; + } + case 'type_text': { + const text = String(req.text || ''); + const r = await computerTool.execute({ action: 'type', ref: ensured.ref, text }); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'computer.type', ok: !r.isError, error }; + break; + } + case 'press_keys': { + const keys = String(req.keys || ''); + // Focus first by ref to ensure key target + try { + await clickTool.execute({ + ref: ensured.ref, + waitForNavigation: false, + timeout: 2000, + }); + } catch {} + const r = await keyboardTool.execute({ keys, delay: 0 } as any); + const error = r.isError ? extractToolError(r) : undefined; + base.tool = { name: 'keyboard.simulate', ok: !r.isError, error }; + break; + } + default: { + base.tool = { name: 'noop', ok: true }; + } + } + } catch (e) { + console.warn('[ElementMarker] Validation failed before tool execution', e); + base.tool = { + name: 'unknown', + ok: false, + error: String(e instanceof Error ? e.message : e), + }; + } + + // Log tool failures for debugging + if (base.tool && base.tool.ok === false) { + console.warn('[ElementMarker] Tool validation failure', { + action, + toolName: base.tool.name, + error: base.tool.error, + selector, + selectorType, + }); + } + + return sendResponse(base); + })(); + return true; + } + // When RR refresh (or similar) happens, re-add our menu + case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS: + case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER: + case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: { + setTimeout(() => ensureContextMenu().catch(() => {}), 300); + break; + } + } + } catch (e) { + sendResponse({ success: false, error: (e as any)?.message || String(e) }); + } + return false; + }); + + // Context menu click routing + if ((chrome as any).contextMenus?.onClicked?.addListener) { + chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) { + await injectMarkerHelper(tab.id); + } + } catch (e) { + console.warn('ElementMarker: context menu click failed:', e); + } + }); + } +} diff --git a/app/chrome-extension/entrypoints/background/index.ts b/app/chrome-extension/entrypoints/background/index.ts new file mode 100644 index 0000000..fcac7e3 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/index.ts @@ -0,0 +1,87 @@ +import { initNativeHostListener } from './native-host'; +import { + initSemanticSimilarityListener, + initializeSemanticEngineIfCached, +} from './semantic-similarity'; +import { initStorageManagerListener } from './storage-manager'; +import { cleanupModelCache } from '@/utils/semantic-similarity-engine'; +import { initRecordReplayListeners } from './record-replay'; +import { initElementMarkerListeners } from './element-marker'; +import { initWebEditorListeners } from './web-editor'; +import { initQuickPanelAgentHandler } from './quick-panel/agent-handler'; +import { initQuickPanelCommands } from './quick-panel/commands'; +import { initQuickPanelTabsHandler } from './quick-panel/tabs-handler'; + +// Record-Replay V3 (feature flag) +import { bootstrapV3 } from './record-replay-v3/bootstrap'; + +/** + * Feature flag for RR-V3 + * Set to true to enable the new Record-Replay V3 engine + */ +const ENABLE_RR_V3 = true; + +/** + * Background script entry point + * Initializes all background services and listeners + */ +export default defineBackground(() => { + // Open welcome page on first install + chrome.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + // Open the welcome/onboarding page for new installations + chrome.tabs.create({ + url: chrome.runtime.getURL('/welcome.html'), + }); + } + }); + + // Initialize core services + initNativeHostListener(); + initSemanticSimilarityListener(); + initStorageManagerListener(); + // Record & Replay V1/V2 listeners + initRecordReplayListeners(); + + // Record & Replay V3 (new engine) + if (ENABLE_RR_V3) { + bootstrapV3() + .then((runtime) => { + console.log(`[RR-V3] Bootstrap complete, ownerId: ${runtime.ownerId}`); + }) + .catch((error) => { + console.error('[RR-V3] Bootstrap failed:', error); + }); + } + + // Element marker: context menu + CRUD listeners + initElementMarkerListeners(); + // Web editor: toggle edit-mode overlay + initWebEditorListeners(); + // Quick Panel: send messages to AgentChat via background-stream bridge + initQuickPanelAgentHandler(); + // Quick Panel: tabs search bridge for content script UI + initQuickPanelTabsHandler(); + // Quick Panel: keyboard shortcut handler + initQuickPanelCommands(); + + // Conditionally initialize semantic similarity engine if model cache exists + initializeSemanticEngineIfCached() + .then((initialized) => { + if (initialized) { + console.log('Background: Semantic similarity engine initialized from cache'); + } else { + console.log( + 'Background: Semantic similarity engine initialization skipped (no cache found)', + ); + } + }) + .catch((error) => { + console.warn('Background: Failed to conditionally initialize semantic engine:', error); + }); + + // Initial cleanup on startup + cleanupModelCache().catch((error) => { + console.warn('Background: Initial cache cleanup failed:', error); + }); +}); diff --git a/app/chrome-extension/entrypoints/background/keepalive-manager.ts b/app/chrome-extension/entrypoints/background/keepalive-manager.ts new file mode 100644 index 0000000..ca1c20f --- /dev/null +++ b/app/chrome-extension/entrypoints/background/keepalive-manager.ts @@ -0,0 +1,87 @@ +/** + * @fileoverview Keepalive Manager + * @description Global singleton service for managing Service Worker keepalive. + * + * This module provides a unified interface for acquiring and releasing keepalive + * references. Multiple modules can acquire keepalive independently using tags, + * and the underlying keepalive mechanism will remain active as long as at least + * one reference is held. + */ + +import { + createOffscreenKeepaliveController, + type KeepaliveController, +} from './record-replay-v3/engine/keepalive/offscreen-keepalive'; + +const LOG_PREFIX = '[KeepaliveManager]'; + +/** + * Singleton keepalive controller instance. + * Created lazily to avoid initialization issues during module loading. + */ +let controller: KeepaliveController | null = null; + +/** + * Get or create the singleton keepalive controller. + */ +function getController(): KeepaliveController { + if (!controller) { + controller = createOffscreenKeepaliveController({ logger: console }); + console.debug(`${LOG_PREFIX} Controller initialized`); + } + return controller; +} + +/** + * Acquire a keepalive reference with a tag. + * + * @param tag - Identifier for the reference (e.g., 'native-host', 'rr-engine') + * @returns A release function to call when keepalive is no longer needed + * + * @example + * ```typescript + * const release = acquireKeepalive('native-host'); + * // ... do work that needs SW to stay alive ... + * release(); // Release when done + * ``` + */ +export function acquireKeepalive(tag: string): () => void { + try { + const release = getController().acquire(tag); + console.debug(`${LOG_PREFIX} Acquired keepalive for tag: ${tag}`); + return () => { + try { + release(); + console.debug(`${LOG_PREFIX} Released keepalive for tag: ${tag}`); + } catch (error) { + console.warn(`${LOG_PREFIX} Failed to release keepalive for ${tag}:`, error); + } + }; + } catch (error) { + console.warn(`${LOG_PREFIX} Failed to acquire keepalive for ${tag}:`, error); + return () => {}; + } +} + +/** + * Check if keepalive is currently active (any references held). + */ +export function isKeepaliveActive(): boolean { + try { + return getController().isActive(); + } catch { + return false; + } +} + +/** + * Get the current keepalive reference count. + * Useful for debugging. + */ +export function getKeepaliveRefCount(): number { + try { + return getController().getRefCount(); + } catch { + return 0; + } +} diff --git a/app/chrome-extension/entrypoints/background/native-host.ts b/app/chrome-extension/entrypoints/background/native-host.ts new file mode 100644 index 0000000..6cc79e1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/native-host.ts @@ -0,0 +1,627 @@ +import { NativeMessageType } from 'chrome-mcp-shared'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import { NATIVE_HOST, STORAGE_KEYS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/common/constants'; +import { handleCallTool } from './tools'; +import { listPublished, getFlow } from './record-replay/flow-store'; +import { acquireKeepalive } from './keepalive-manager'; + +const LOG_PREFIX = '[NativeHost]'; + +let nativePort: chrome.runtime.Port | null = null; +export const HOST_NAME = NATIVE_HOST.NAME; + +// ==================== Reconnect Configuration ==================== + +const RECONNECT_BASE_DELAY_MS = 500; +const RECONNECT_MAX_DELAY_MS = 60_000; +const RECONNECT_MAX_FAST_ATTEMPTS = 8; +const RECONNECT_COOLDOWN_DELAY_MS = 5 * 60_000; + +// ==================== Auto-connect State ==================== + +let keepaliveRelease: (() => void) | null = null; +let autoConnectEnabled = true; +let autoConnectLoaded = false; +let ensurePromise: Promise | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempts = 0; +let manualDisconnect = false; + +/** + * Server status management interface + */ +interface ServerStatus { + isRunning: boolean; + port?: number; + lastUpdated: number; +} + +let currentServerStatus: ServerStatus = { + isRunning: false, + lastUpdated: Date.now(), +}; + +/** + * Save server status to chrome.storage + */ +async function saveServerStatus(status: ServerStatus): Promise { + try { + await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status }); + } catch (error) { + console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error); + } +} + +/** + * Load server status from chrome.storage + */ +async function loadServerStatus(): Promise { + try { + const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]); + if (result[STORAGE_KEYS.SERVER_STATUS]) { + return result[STORAGE_KEYS.SERVER_STATUS]; + } + } catch (error) { + console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error); + } + return { + isRunning: false, + lastUpdated: Date.now(), + }; +} + +/** + * Broadcast server status change to all listeners + */ +function broadcastServerStatusChange(status: ServerStatus): void { + chrome.runtime + .sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED, + payload: status, + }) + .catch(() => { + // Ignore errors if no listeners are present + }); +} + +// ==================== Port Normalization ==================== + +/** + * Normalize a port value to a valid port number or null. + */ +function normalizePort(value: unknown): number | null { + const n = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN; + if (!Number.isFinite(n)) return null; + const port = Math.floor(n); + if (port <= 0 || port > 65535) return null; + return port; +} + +// ==================== Reconnect Utilities ==================== + +/** + * Add jitter to a delay value to avoid thundering herd. + */ +function withJitter(ms: number): number { + const ratio = 0.7 + Math.random() * 0.6; + return Math.max(0, Math.round(ms * ratio)); +} + +/** + * Calculate reconnect delay based on attempt number. + * Uses exponential backoff with jitter, then switches to cooldown interval. + */ +function getReconnectDelayMs(attempt: number): number { + if (attempt >= RECONNECT_MAX_FAST_ATTEMPTS) { + return withJitter(RECONNECT_COOLDOWN_DELAY_MS); + } + const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS); + return withJitter(delay); +} + +/** + * Clear the reconnect timer if active. + */ +function clearReconnectTimer(): void { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; +} + +/** + * Reset reconnect state after successful connection. + */ +function resetReconnectState(): void { + reconnectAttempts = 0; + clearReconnectTimer(); +} + +// ==================== Keepalive Management ==================== + +/** + * Sync keepalive hold based on autoConnectEnabled state. + * When auto-connect is enabled, we hold a keepalive reference to keep SW alive. + */ +function syncKeepaliveHold(): void { + if (autoConnectEnabled) { + if (!keepaliveRelease) { + keepaliveRelease = acquireKeepalive('native-host'); + console.debug(`${LOG_PREFIX} Acquired keepalive`); + } + return; + } + if (keepaliveRelease) { + try { + keepaliveRelease(); + console.debug(`${LOG_PREFIX} Released keepalive`); + } catch { + // Ignore + } + keepaliveRelease = null; + } +} + +// ==================== Auto-connect Settings ==================== + +/** + * Load the nativeAutoConnectEnabled setting from storage. + */ +async function loadNativeAutoConnectEnabled(): Promise { + try { + const result = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]); + const raw = result[STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]; + if (typeof raw === 'boolean') return raw; + } catch (error) { + console.warn(`${LOG_PREFIX} Failed to load nativeAutoConnectEnabled`, error); + } + return true; // Default to enabled +} + +/** + * Set the nativeAutoConnectEnabled setting and persist to storage. + */ +async function setNativeAutoConnectEnabled(enabled: boolean): Promise { + autoConnectEnabled = enabled; + autoConnectLoaded = true; + try { + await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]: enabled }); + console.debug(`${LOG_PREFIX} Set nativeAutoConnectEnabled=${enabled}`); + } catch (error) { + console.warn(`${LOG_PREFIX} Failed to persist nativeAutoConnectEnabled`, error); + } + syncKeepaliveHold(); +} + +// ==================== Port Preference ==================== + +/** + * Get the preferred port for connecting to native server. + * Priority: explicit override > user preference > last known port > default + */ +async function getPreferredPort(override?: unknown): Promise { + const explicit = normalizePort(override); + if (explicit) return explicit; + + try { + const result = await chrome.storage.local.get([ + STORAGE_KEYS.NATIVE_SERVER_PORT, + STORAGE_KEYS.SERVER_STATUS, + ]); + + const userPort = normalizePort(result[STORAGE_KEYS.NATIVE_SERVER_PORT]); + if (userPort) return userPort; + + const status = result[STORAGE_KEYS.SERVER_STATUS] as Partial | undefined; + const statusPort = normalizePort(status?.port); + if (statusPort) return statusPort; + } catch (error) { + console.warn(`${LOG_PREFIX} Failed to read preferred port`, error); + } + + const inMemoryPort = normalizePort(currentServerStatus.port); + if (inMemoryPort) return inMemoryPort; + + return NATIVE_HOST.DEFAULT_PORT; +} + +// ==================== Reconnect Scheduling ==================== + +/** + * Schedule a reconnect attempt with exponential backoff. + */ +function scheduleReconnect(reason: string): void { + if (nativePort) return; + if (manualDisconnect) return; + if (!autoConnectEnabled) return; + if (reconnectTimer) return; + + const delay = getReconnectDelayMs(reconnectAttempts); + console.debug( + `${LOG_PREFIX} Reconnect scheduled in ${delay}ms (attempt=${reconnectAttempts}, reason=${reason})`, + ); + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (nativePort) return; + if (manualDisconnect || !autoConnectEnabled) return; + + reconnectAttempts += 1; + void ensureNativeConnected(`reconnect:${reason}`).catch(() => {}); + }, delay); +} + +// ==================== Server Status Update ==================== + +/** + * Mark server as stopped and broadcast the change. + */ +async function markServerStopped(reason: string): Promise { + currentServerStatus = { + isRunning: false, + port: currentServerStatus.port, + lastUpdated: Date.now(), + }; + try { + await saveServerStatus(currentServerStatus); + } catch { + // Ignore + } + broadcastServerStatusChange(currentServerStatus); + console.debug(`${LOG_PREFIX} Server marked stopped (${reason})`); +} + +// ==================== Core Ensure Function ==================== + +/** + * Ensure native connection is established. + * This is the main entry point for auto-connect logic. + * + * @param trigger - Description of what triggered this call (for logging) + * @param portOverride - Optional explicit port to use + * @returns Whether the connection is now established + */ +async function ensureNativeConnected(trigger: string, portOverride?: unknown): Promise { + // Concurrency protection: only one ensure flow at a time + if (ensurePromise) return ensurePromise; + + ensurePromise = (async () => { + // Load auto-connect setting if not yet loaded + if (!autoConnectLoaded) { + autoConnectEnabled = await loadNativeAutoConnectEnabled(); + autoConnectLoaded = true; + syncKeepaliveHold(); + } + + // If auto-connect is disabled, do nothing + if (!autoConnectEnabled) { + console.debug(`${LOG_PREFIX} Auto-connect disabled, skipping ensure (trigger=${trigger})`); + return false; + } + + // Sync keepalive hold + syncKeepaliveHold(); + + // Already connected + if (nativePort) { + console.debug(`${LOG_PREFIX} Already connected (trigger=${trigger})`); + return true; + } + + // Get the port to use + const port = await getPreferredPort(portOverride); + console.debug(`${LOG_PREFIX} Attempting connection on port ${port} (trigger=${trigger})`); + + // Attempt connection + const ok = connectNativeHost(port); + if (!ok) { + console.warn(`${LOG_PREFIX} Connection failed (trigger=${trigger})`); + scheduleReconnect(`connect_failed:${trigger}`); + return false; + } + + console.debug(`${LOG_PREFIX} Connection initiated successfully (trigger=${trigger})`); + // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation. + // Chrome may return a Port but disconnect immediately if native host is missing. + return true; + })().finally(() => { + ensurePromise = null; + }); + + return ensurePromise; +} + +/** + * Connect to the native messaging host + * @returns Whether the connection was initiated successfully + */ +export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): boolean { + if (nativePort) { + return true; + } + + try { + nativePort = chrome.runtime.connectNative(HOST_NAME); + + nativePort.onMessage.addListener(async (message) => { + if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) { + const requestId = message.requestId; + const requestPayload = message.payload; + + nativePort?.postMessage({ + responseToRequestId: requestId, + payload: { + status: 'success', + message: SUCCESS_MESSAGES.TOOL_EXECUTED, + data: requestPayload, + }, + }); + } else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) { + const requestId = message.requestId; + try { + const result = await handleCallTool(message.payload); + nativePort?.postMessage({ + responseToRequestId: requestId, + payload: { + status: 'success', + message: SUCCESS_MESSAGES.TOOL_EXECUTED, + data: result, + }, + }); + } catch (error) { + nativePort?.postMessage({ + responseToRequestId: requestId, + payload: { + status: 'error', + message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } else if (message.type === 'rr_list_published_flows' && message.requestId) { + const requestId = message.requestId; + try { + const published = await listPublished(); + const items = [] as any[]; + for (const p of published) { + const flow = await getFlow(p.id); + if (!flow) continue; + items.push({ + id: p.id, + slug: p.slug, + version: p.version, + name: p.name, + description: p.description || flow.description || '', + variables: flow.variables || [], + meta: flow.meta || {}, + }); + } + nativePort?.postMessage({ + responseToRequestId: requestId, + payload: { status: 'success', items }, + }); + } catch (error: any) { + nativePort?.postMessage({ + responseToRequestId: requestId, + payload: { status: 'error', error: error?.message || String(error) }, + }); + } + } else if (message.type === NativeMessageType.SERVER_STARTED) { + const port = message.payload?.port; + currentServerStatus = { + isRunning: true, + port: port, + lastUpdated: Date.now(), + }; + await saveServerStatus(currentServerStatus); + broadcastServerStatusChange(currentServerStatus); + // Server is confirmed running - now we can reset reconnect state + resetReconnectState(); + console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`); + } else if (message.type === NativeMessageType.SERVER_STOPPED) { + currentServerStatus = { + isRunning: false, + port: currentServerStatus.port, // Keep last known port for reconnection + lastUpdated: Date.now(), + }; + await saveServerStatus(currentServerStatus); + broadcastServerStatusChange(currentServerStatus); + console.log(SUCCESS_MESSAGES.SERVER_STOPPED); + } else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) { + console.error('Error from native host:', message.payload?.message || 'Unknown error'); + } else if (message.type === 'file_operation_response') { + // Forward file operation response back to the requesting tool + chrome.runtime.sendMessage(message).catch(() => { + // Ignore if no listeners + }); + } + }); + + nativePort.onDisconnect.addListener(() => { + console.warn(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError); + nativePort = null; + + // Mark server as stopped since native host disconnection means server is down + void markServerStopped('native_port_disconnected'); + + // Handle reconnection based on disconnect reason + if (manualDisconnect) { + manualDisconnect = false; + return; + } + if (!autoConnectEnabled) return; + scheduleReconnect('native_port_disconnected'); + }); + + nativePort.postMessage({ type: NativeMessageType.START, payload: { port } }); + // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation. + // Chrome may return a Port but disconnect immediately if native host is missing. + return true; + } catch (error) { + console.warn(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error); + nativePort = null; + return false; + } +} + +/** + * Initialize native host listeners and load initial state + */ +export const initNativeHostListener = () => { + // Initialize server status from storage + loadServerStatus() + .then((status) => { + currentServerStatus = status; + }) + .catch((error) => { + console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error); + }); + + // Auto-connect on SW activation (covers SW restart after idle termination) + void ensureNativeConnected('sw_startup').catch(() => {}); + + // Auto-connect on Chrome browser startup + chrome.runtime.onStartup.addListener(() => { + void ensureNativeConnected('onStartup').catch(() => {}); + }); + + // Auto-connect on extension install/update + chrome.runtime.onInstalled.addListener(() => { + void ensureNativeConnected('onInstalled').catch(() => {}); + }); + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + // Allow UI to call tools directly + if (message && message.type === 'call_tool' && message.name) { + handleCallTool({ name: message.name, args: message.args }) + .then((res) => sendResponse({ success: true, result: res })) + .catch((err) => + sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }), + ); + return true; + } + + const msgType = typeof message === 'string' ? message : message?.type; + + // ENSURE_NATIVE: Trigger ensure without changing autoConnectEnabled + if (msgType === NativeMessageType.ENSURE_NATIVE) { + const portOverride = typeof message === 'object' ? message.port : undefined; + ensureNativeConnected('ui_ensure', portOverride) + .then((connected) => { + sendResponse({ success: true, connected, autoConnectEnabled }); + }) + .catch((e) => { + sendResponse({ success: false, connected: nativePort !== null, error: String(e) }); + }); + return true; + } + + // CONNECT_NATIVE: Explicit user connect, re-enables auto-connect + if (msgType === NativeMessageType.CONNECT_NATIVE) { + const portOverride = typeof message === 'object' ? message.port : undefined; + const normalized = normalizePort(portOverride); + + (async () => { + // Explicit user connect: re-enable auto-connect + await setNativeAutoConnectEnabled(true); + + if (normalized) { + // Best-effort: persist preferred port + try { + await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_SERVER_PORT]: normalized }); + } catch { + // Ignore + } + } + + return ensureNativeConnected('ui_connect', normalized ?? undefined); + })() + .then((connected) => { + sendResponse({ success: true, connected }); + }) + .catch((e) => { + sendResponse({ success: false, connected: nativePort !== null, error: String(e) }); + }); + return true; + } + + if (msgType === NativeMessageType.PING_NATIVE) { + const connected = nativePort !== null; + sendResponse({ connected, autoConnectEnabled }); + return true; + } + + // DISCONNECT_NATIVE: Explicit user disconnect, disables auto-connect + if (msgType === NativeMessageType.DISCONNECT_NATIVE) { + (async () => { + // Explicit user disconnect: disable auto-connect and stop reconnect loop + await setNativeAutoConnectEnabled(false); + clearReconnectTimer(); + reconnectAttempts = 0; + syncKeepaliveHold(); + + if (nativePort) { + // Only set manualDisconnect if we actually have a port to disconnect. + // This prevents the flag from persisting when there's no active connection. + manualDisconnect = true; + try { + nativePort.disconnect(); + } catch { + // Ignore + } + nativePort = null; + } + await markServerStopped('manual_disconnect'); + })() + .then(() => { + sendResponse({ success: true }); + }) + .catch((e) => { + sendResponse({ success: false, error: String(e) }); + }); + return true; + } + + if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) { + sendResponse({ + success: true, + serverStatus: currentServerStatus, + connected: nativePort !== null, + }); + return true; + } + + if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) { + loadServerStatus() + .then((storedStatus) => { + currentServerStatus = storedStatus; + sendResponse({ + success: true, + serverStatus: currentServerStatus, + connected: nativePort !== null, + }); + }) + .catch((error) => { + console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error); + sendResponse({ + success: false, + error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, + serverStatus: currentServerStatus, + connected: nativePort !== null, + }); + }); + return true; + } + + // Forward file operation messages to native host + if (message.type === 'forward_to_native' && message.message) { + if (nativePort) { + nativePort.postMessage(message.message); + sendResponse({ success: true }); + } else { + sendResponse({ success: false, error: 'Native host not connected' }); + } + return true; + } + }); +}; diff --git a/app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts new file mode 100644 index 0000000..c6fec7f --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts @@ -0,0 +1,779 @@ +/** + * Quick Panel Agent Handler + * + * Background service that bridges Quick Panel (content script) with the native-server Agent. + * Handles message routing, SSE streaming, and lifecycle management for AI chat requests. + * + * Architecture: + * - Quick Panel sends QUICK_PANEL_SEND_TO_AI via chrome.runtime.sendMessage + * - This handler subscribes to SSE first, then fires POST /act + * - Incoming RealtimeEvents are filtered by requestId and forwarded to the originating tab + * - Keepalive is explicitly managed to prevent MV3 Service Worker suspension during streaming + * + * @see https://developer.chrome.com/docs/extensions/mv3/service_workers/ + */ + +import type { AgentActRequest, RealtimeEvent } from 'chrome-mcp-shared'; +import { NativeMessageType } from 'chrome-mcp-shared'; + +import { NATIVE_HOST, STORAGE_KEYS } from '@/common/constants'; +import { + BACKGROUND_MESSAGE_TYPES, + TOOL_MESSAGE_TYPES, + type QuickPanelAIEventMessage, + type QuickPanelCancelAIMessage, + type QuickPanelCancelAIResponse, + type QuickPanelSendToAIMessage, + type QuickPanelSendToAIResponse, +} from '@/common/message-types'; +import { acquireKeepalive } from '../keepalive-manager'; +import { openAgentChatSidepanel } from '../utils/sidepanel'; + +// ============================================================ +// Constants +// ============================================================ + +const LOG_PREFIX = '[QuickPanelAgent]'; +const KEEPALIVE_TAG = 'quick-panel-ai'; + +/** Storage key for AgentChat selected session ID (owned by sidepanel composables) */ +const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id'; + +/** Timeout for initial SSE connection establishment */ +const SSE_CONNECT_TIMEOUT_MS = 3000; + +/** Safety timeout for entire request lifecycle (15 minutes) */ +const REQUEST_TIMEOUT_MS = 15 * 60 * 1000; + +/** Flag indicating SSE connection was successful */ +const SSE_CONNECTED = Symbol('SSE_CONNECTED'); + +/** Flag indicating SSE connection timed out but we should continue */ +const SSE_TIMEOUT = Symbol('SSE_TIMEOUT'); + +// ============================================================ +// Types +// ============================================================ + +/** + * Represents an active streaming request from Quick Panel. + * + * Background maintains this state to: + * 1. Route SSE events to the correct tab + * 2. Manage keepalive lifecycle + * 3. Handle cancellation and cleanup + */ +interface ActiveRequest { + readonly requestId: string; + readonly sessionId: string; + readonly instruction: string; + readonly tabId: number; + readonly windowId?: number; + readonly frameId?: number; + readonly port: number; + readonly createdAt: number; + readonly abortController: AbortController; + readonly releaseKeepalive: () => void; + readonly timeoutId: ReturnType; +} + +// ============================================================ +// State +// ============================================================ + +/** Active streaming requests indexed by requestId */ +const activeRequests = new Map(); + +/** Initialization flag to prevent duplicate listeners */ +let initialized = false; + +// ============================================================ +// Utility Functions +// ============================================================ + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function normalizePort(value: unknown): number | null { + const num = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN; + + if (!Number.isFinite(num)) return null; + + const port = Math.floor(num); + if (port <= 0 || port > 65535) return null; + + return port; +} + +function createRequestId(): string { + // Prefer crypto.randomUUID for proper UUID format + try { + const id = crypto?.randomUUID?.(); + if (id) return id; + } catch { + // Fallback for environments without crypto.randomUUID + } + return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isTerminalStatus(status: string): boolean { + return status === 'completed' || status === 'error' || status === 'cancelled'; +} + +// ============================================================ +// Event Factories +// ============================================================ + +function createErrorEvent(sessionId: string, requestId: string, error: string): RealtimeEvent { + return { + type: 'error', + error: error || 'Unknown error', + data: { sessionId, requestId }, + }; +} + +function createCancelledStatusEvent( + sessionId: string, + requestId: string, + message?: string, +): RealtimeEvent { + return { + type: 'status', + data: { + sessionId, + status: 'cancelled', + requestId, + message: message || 'Cancelled by user', + }, + }; +} + +// ============================================================ +// Event Forwarding +// ============================================================ + +/** + * Forward a RealtimeEvent to the Quick Panel in the originating tab. + * Handles receiver unavailability gracefully by cleaning up the request. + */ +function forwardEventToQuickPanel(request: ActiveRequest, event: RealtimeEvent): void { + const message: QuickPanelAIEventMessage = { + action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT, + requestId: request.requestId, + sessionId: request.sessionId, + event, + }; + + const sendOptions = + typeof request.frameId === 'number' ? { frameId: request.frameId } : undefined; + + const sendPromise = sendOptions + ? chrome.tabs.sendMessage(request.tabId, message, sendOptions) + : chrome.tabs.sendMessage(request.tabId, message); + + sendPromise.catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + + // Detect receiver unavailability (tab closed, navigated, Quick Panel closed) + const receiverGone = + msg.includes('Receiving end does not exist') || + msg.includes('No tab with id') || + msg.includes('The message port closed'); + + if (receiverGone) { + cleanupRequest(request.requestId, 'receiver_unavailable'); + } + }); +} + +// ============================================================ +// Request Lifecycle Management +// ============================================================ + +/** + * Clean up an active request and release all associated resources. + * Idempotent - safe to call multiple times. + */ +function cleanupRequest(requestId: string, reason: string): void { + const request = activeRequests.get(requestId); + if (!request) return; + + activeRequests.delete(requestId); + + // Clear timeout + try { + clearTimeout(request.timeoutId); + } catch { + // Ignore + } + + // Abort SSE connection + try { + request.abortController.abort(); + } catch { + // Ignore + } + + // Release keepalive + try { + request.releaseKeepalive(); + } catch { + // Ignore + } + + console.debug(`${LOG_PREFIX} Cleaned up request ${requestId} (${reason})`); +} + +// ============================================================ +// Session Validation +// ============================================================ + +/** + * Validate that the selected session exists on the native server. + * Returns false if the session is invalid or server is unreachable. + */ +async function validateSession(port: number, sessionId: string): Promise { + const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}`; + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +// ============================================================ +// SSE Event Filtering +// ============================================================ + +/** + * Determine if a RealtimeEvent should be forwarded for a specific requestId. + * + * Events without requestId (connected, heartbeat) are session-level signals + * and are not forwarded to avoid confusion with request-specific events. + */ +function shouldForwardEvent(event: RealtimeEvent, requestId: string): boolean { + switch (event.type) { + case 'message': + return event.data?.requestId === requestId; + case 'status': + return event.data?.requestId === requestId; + case 'usage': + return event.data?.requestId === requestId; + case 'error': + return event.data?.requestId === requestId; + case 'connected': + case 'heartbeat': + // Session-level signals, not request-scoped + return false; + default: + return false; + } +} + +// ============================================================ +// SSE Subscription +// ============================================================ + +interface SseSubscription { + /** + * Resolves with true when SSE connection is established. + * Resolves with false if connection failed (request was cleaned up). + */ + ready: Promise; + /** Resolves when SSE stream ends (normally or due to error/abort) */ + done: Promise; +} + +/** + * Create an SSE subscription for the request's session. + * + * The subscription: + * 1. Connects to the session's /stream endpoint + * 2. Filters events by requestId + * 3. Forwards matching events to Quick Panel + * 4. Triggers cleanup on terminal status + * + * @returns SseSubscription with ready promise that resolves to: + * - true: SSE connected successfully + * - false: SSE failed (request was cleaned up, don't send /act) + */ +function createSseSubscription(request: ActiveRequest): SseSubscription { + // Track whether ready has been resolved + let readySettled = false; + let readyResolve: (connected: boolean) => void; + + const ready = new Promise((resolve) => { + readyResolve = resolve; + }); + + // Helper to resolve ready exactly once + const settleReady = (connected: boolean): void => { + if (readySettled) return; + readySettled = true; + readyResolve(connected); + }; + + const done = (async () => { + const sseUrl = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/stream`; + + try { + const response = await fetch(sseUrl, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + signal: request.abortController.signal, + }); + + if (!response.ok || !response.body) { + throw new Error(`SSE stream unavailable (HTTP ${response.status})`); + } + + // Signal that SSE is connected successfully + settleReady(true); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + // Read and parse SSE stream + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const raw = line.slice(5).trim(); + if (!raw) continue; + + try { + const event = JSON.parse(raw) as RealtimeEvent; + + // Filter by requestId to prevent cross-request leakage + if (!shouldForwardEvent(event, request.requestId)) { + continue; + } + + forwardEventToQuickPanel(request, event); + + // Cleanup on terminal status + if (event.type === 'status' && event.data?.requestId === request.requestId) { + if (isTerminalStatus(event.data.status)) { + cleanupRequest(request.requestId, `terminal_status:${event.data.status}`); + return; + } + } + } catch { + // Ignore parse errors (best-effort stream processing) + } + } + } + } catch (err) { + // AbortError is intentional (cancellation or cleanup) + if (err instanceof Error && err.name === 'AbortError') { + // Signal not connected if aborted before connecting + settleReady(false); + return; + } + + // Surface error to UI and cleanup if request is still active + if (activeRequests.has(request.requestId)) { + const msg = err instanceof Error ? err.message : String(err); + forwardEventToQuickPanel( + request, + createErrorEvent(request.sessionId, request.requestId, msg), + ); + cleanupRequest(request.requestId, 'sse_error'); + } + + // Signal failed connection + settleReady(false); + } + })(); + + return { ready, done }; +} + +// ============================================================ +// Agent API +// ============================================================ + +/** + * Send the act request to native-server. + * The server will emit events via SSE which are already being subscribed. + * + * @param request - Active request context + * @throws Error if request was cancelled/aborted or HTTP request fails + */ +async function postActRequest(request: ActiveRequest): Promise { + // Check if request was cancelled before sending + if (request.abortController.signal.aborted) { + throw new Error('Request was cancelled'); + } + + const url = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/act`; + + const payload: AgentActRequest = { + instruction: request.instruction, + // Ensures session-level config is loaded (engine, model, options, project binding) + dbSessionId: request.sessionId, + // Enables SSE-first flow and requestId filtering on session-scoped streams + requestId: request.requestId, + }; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: request.abortController.signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } +} + +/** + * Cancel an active request on the native-server. + */ +async function cancelRequestOnServer( + port: number, + sessionId: string, + requestId: string, +): Promise { + const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`; + try { + await fetch(url, { method: 'DELETE' }); + } catch { + // Best-effort: cancellation might still succeed if request already ended + } +} + +// ============================================================ +// Request Orchestration +// ============================================================ + +/** + * Check if the request is still active and not cancelled. + * Used as a guard before each async operation to handle race conditions. + */ +function isRequestStillActive(request: ActiveRequest): boolean { + return activeRequests.has(request.requestId) && !request.abortController.signal.aborted; +} + +/** + * Main orchestration function for starting a Quick Panel AI request. + * + * Flow: + * 1. Ensure native server is running + * 2. Validate session exists + * 3. Open sidepanel (best-effort) + * 4. Start SSE subscription (wait for connection) + * 5. Fire act request + * 6. Let SSE handle event forwarding and cleanup + * + * @remarks + * Guards are placed after each async operation to handle cancellation races. + */ +async function startRequest(request: ActiveRequest): Promise { + try { + // Best-effort: ensure native server is running + await chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => null); + + // Guard: check if cancelled during ENSURE_NATIVE + if (!isRequestStillActive(request)) return; + + // Validate session still exists + const sessionValid = await validateSession(request.port, request.sessionId); + + // Guard: check if cancelled during validation + if (!isRequestStillActive(request)) return; + + if (!sessionValid) { + forwardEventToQuickPanel( + request, + createErrorEvent( + request.sessionId, + request.requestId, + 'Selected Agent session is not available. Please open AgentChat and select a valid session.', + ), + ); + // Open sidepanel without deep-linking to invalid session + openAgentChatSidepanel(request.tabId, request.windowId).catch(() => {}); + cleanupRequest(request.requestId, 'session_invalid'); + return; + } + + // Best-effort: open sidepanel deep-linked to current session + openAgentChatSidepanel(request.tabId, request.windowId, request.sessionId).catch(() => {}); + + // Start SSE subscription BEFORE sending act request to avoid missing early events + const sse = createSseSubscription(request); + + // Wait for SSE connection with timeout + // The race returns either: + // - boolean from sse.ready (true=connected, false=failed) + // - undefined from timeout (treat as "proceed with caution") + const sseResult = await Promise.race([ + sse.ready, + sleep(SSE_CONNECT_TIMEOUT_MS).then(() => SSE_TIMEOUT), + ]); + + // Guard: check if cancelled during SSE connection + if (!isRequestStillActive(request)) return; + + // If SSE explicitly failed (returned false), don't send /act + // The SSE subscription already cleaned up and sent error to UI + if (sseResult === false) { + console.debug(`${LOG_PREFIX} SSE failed for ${request.requestId}, not sending /act`); + return; + } + + // If SSE timed out, log warning but continue (degraded experience) + if (sseResult === SSE_TIMEOUT) { + console.warn( + `${LOG_PREFIX} SSE connection timed out for ${request.requestId}, proceeding anyway`, + ); + } + + // Fire the act request + await postActRequest(request); + + // SSE subscription continues running and will handle cleanup on terminal status + void sse.done; + } catch (err) { + // Abort errors are expected during cancellation + if (err instanceof Error && err.name === 'AbortError') { + return; + } + + // Request may have been cleaned up already + if (!activeRequests.has(request.requestId)) return; + + const msg = err instanceof Error ? err.message : String(err); + forwardEventToQuickPanel(request, createErrorEvent(request.sessionId, request.requestId, msg)); + cleanupRequest(request.requestId, 'start_failed'); + } +} + +// ============================================================ +// Message Handlers +// ============================================================ + +/** + * Handle QUICK_PANEL_SEND_TO_AI message. + * Creates a new streaming request and starts the orchestration flow. + */ +async function handleSendToAI( + message: QuickPanelSendToAIMessage, + sender: chrome.runtime.MessageSender, +): Promise { + const tabId = sender?.tab?.id; + const windowId = sender?.tab?.windowId; + const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined; + + if (typeof tabId !== 'number') { + return { success: false, error: 'Quick Panel request must originate from a tab.' }; + } + + const instruction = normalizeString(message?.payload?.instruction).trim(); + if (!instruction) { + return { success: false, error: 'instruction is required' }; + } + + // Read server port and selected session from storage + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.NATIVE_SERVER_PORT, + STORAGE_KEY_SELECTED_SESSION, + ]); + + const port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT; + const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim(); + + if (!sessionId) { + // No session selected: open sidepanel for user to select/create one + openAgentChatSidepanel(tabId, windowId).catch(() => {}); + return { + success: false, + error: + 'No Agent session selected. Please open AgentChat, select or create a session, then try again.', + }; + } + + // Create request state + const requestId = createRequestId(); + const releaseKeepalive = acquireKeepalive(KEEPALIVE_TAG); + const abortController = new AbortController(); + + // Safety timeout to prevent infinite streaming + const timeoutId = setTimeout(() => { + const activeRequest = activeRequests.get(requestId); + if (!activeRequest) return; + + forwardEventToQuickPanel( + activeRequest, + createErrorEvent( + activeRequest.sessionId, + activeRequest.requestId, + 'Quick Panel stream timed out. Please continue in AgentChat sidepanel.', + ), + ); + cleanupRequest(requestId, 'timeout'); + }, REQUEST_TIMEOUT_MS); + + const request: ActiveRequest = { + requestId, + sessionId, + instruction, + tabId, + windowId: typeof windowId === 'number' ? windowId : undefined, + frameId, + port, + createdAt: Date.now(), + abortController, + releaseKeepalive, + timeoutId, + }; + + activeRequests.set(requestId, request); + + // Start the request asynchronously (don't await) + void startRequest(request); + + return { success: true, requestId, sessionId }; +} + +/** + * Handle QUICK_PANEL_CANCEL_AI message. + * Cancels an active request both locally and on the server. + */ +async function handleCancelAI( + message: QuickPanelCancelAIMessage, + sender: chrome.runtime.MessageSender, +): Promise { + const tabId = sender?.tab?.id; + const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined; + + if (typeof tabId !== 'number') { + return { success: false, error: 'Cancel request must originate from a tab.' }; + } + + const requestId = normalizeString(message?.payload?.requestId).trim(); + const fallbackSessionId = normalizeString(message?.payload?.sessionId).trim(); + + if (!requestId) { + return { success: false, error: 'requestId is required' }; + } + + const activeRequest = activeRequests.get(requestId); + const sessionId = activeRequest?.sessionId || fallbackSessionId; + + if (!sessionId) { + return { + success: false, + error: 'Unknown sessionId for this request. Please cancel from AgentChat sidepanel.', + }; + } + + // Abort SSE immediately for responsive UX + if (activeRequest) { + try { + activeRequest.abortController.abort(); + } catch { + // Ignore + } + } + + // Determine port + let port = activeRequest?.port; + if (!port) { + const stored = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_SERVER_PORT]); + port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT; + } + + // Cancel on server (async, don't await) + void cancelRequestOnServer(port, sessionId, requestId); + + // Send synthetic cancelled status to UI + const cancelledEvent = createCancelledStatusEvent(sessionId, requestId); + const eventMessage: QuickPanelAIEventMessage = { + action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT, + requestId, + sessionId, + event: cancelledEvent, + }; + + const sendOptions = typeof frameId === 'number' ? { frameId } : undefined; + const sendPromise = sendOptions + ? chrome.tabs.sendMessage(tabId, eventMessage, sendOptions) + : chrome.tabs.sendMessage(tabId, eventMessage); + + sendPromise + .catch(() => {}) + .finally(() => { + cleanupRequest(requestId, 'cancelled_by_user'); + }); + + return { success: true }; +} + +// ============================================================ +// Initialization +// ============================================================ + +/** + * Initialize the Quick Panel Agent Handler. + * Sets up message listeners and tab cleanup handlers. + */ +export function initQuickPanelAgentHandler(): void { + if (initialized) return; + initialized = true; + + // Message listener for Quick Panel messages + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Handle QUICK_PANEL_SEND_TO_AI + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI) { + handleSendToAI(message as QuickPanelSendToAIMessage, sender) + .then(sendResponse) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + sendResponse({ success: false, error: msg || 'Unknown error' }); + }); + return true; // Async response + } + + // Handle QUICK_PANEL_CANCEL_AI + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI) { + handleCancelAI(message as QuickPanelCancelAIMessage, sender) + .then(sendResponse) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + sendResponse({ success: false, error: msg || 'Unknown error' }); + }); + return true; // Async response + } + + return false; + }); + + // Clean up requests when their tab is closed + chrome.tabs.onRemoved.addListener((tabId) => { + for (const [requestId, request] of activeRequests) { + if (request.tabId === tabId) { + cleanupRequest(requestId, 'tab_removed'); + } + } + }); + + console.debug(`${LOG_PREFIX} Initialized`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/commands.ts b/app/chrome-extension/entrypoints/background/quick-panel/commands.ts new file mode 100644 index 0000000..a9425e2 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/commands.ts @@ -0,0 +1,124 @@ +/** + * Quick Panel Commands Handler + * + * Handles keyboard shortcuts for Quick Panel functionality. + * Listens for the 'toggle_quick_panel' command and sends toggle message + * to the content script in the active tab. + */ + +// ============================================================ +// Constants +// ============================================================ + +const COMMAND_KEY = 'toggle_quick_panel'; +const LOG_PREFIX = '[QuickPanelCommands]'; + +// ============================================================ +// Helpers +// ============================================================ + +/** + * Get the ID of the currently active tab + */ +async function getActiveTabId(): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab?.id ?? null; + } catch (err) { + console.warn(`${LOG_PREFIX} Failed to get active tab:`, err); + return null; + } +} + +/** + * Check if a tab can receive content scripts + */ +function isValidTabUrl(url?: string): boolean { + if (!url) return false; + + // Cannot inject into browser internal pages + const invalidPrefixes = [ + 'chrome://', + 'chrome-extension://', + 'edge://', + 'about:', + 'moz-extension://', + 'devtools://', + 'view-source:', + 'data:', + // 'file://', + ]; + + return !invalidPrefixes.some((prefix) => url.startsWith(prefix)); +} + +// ============================================================ +// Main Handler +// ============================================================ + +/** + * Toggle Quick Panel in the active tab + */ +async function toggleQuickPanelInActiveTab(): Promise { + const tabId = await getActiveTabId(); + if (tabId === null) { + console.warn(`${LOG_PREFIX} No active tab found`); + return; + } + + // Get tab info to check URL validity + try { + const tab = await chrome.tabs.get(tabId); + if (!isValidTabUrl(tab.url)) { + console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`); + return; + } + } catch (err) { + console.warn(`${LOG_PREFIX} Failed to get tab info:`, err); + return; + } + + // Send toggle message to content script + try { + const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' }); + if (response?.success) { + console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`); + } else { + console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error); + } + } catch (err) { + // Content script may not be loaded yet; this is expected on some pages + console.warn( + `${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`, + err, + ); + } +} + +// ============================================================ +// Initialization +// ============================================================ + +/** + * Initialize Quick Panel keyboard command listener + */ +export function initQuickPanelCommands(): void { + console.log(`${LOG_PREFIX} initQuickPanelCommands called`); + chrome.commands.onCommand.addListener(async (command) => { + console.log(`${LOG_PREFIX} onCommand received:`, command); + if (command !== COMMAND_KEY) { + console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY); + return; + } + console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`); + + try { + await toggleQuickPanelInActiveTab(); + console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`); + } catch (err) { + console.error(`${LOG_PREFIX} Command handler error:`, err); + } + }); + + console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`); +} diff --git a/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts new file mode 100644 index 0000000..1a248ca --- /dev/null +++ b/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts @@ -0,0 +1,230 @@ +/** + * Quick Panel Tabs Handler + * + * Background service worker bridge for Quick Panel (content script) to: + * - Enumerate tabs for search suggestions + * - Activate a selected tab + * - Close a tab + * + * Note: Content scripts cannot access chrome.tabs.* directly. + */ + +import { + BACKGROUND_MESSAGE_TYPES, + type QuickPanelActivateTabMessage, + type QuickPanelActivateTabResponse, + type QuickPanelCloseTabMessage, + type QuickPanelCloseTabResponse, + type QuickPanelTabSummary, + type QuickPanelTabsQueryMessage, + type QuickPanelTabsQueryResponse, +} from '@/common/message-types'; + +// ============================================================ +// Constants +// ============================================================ + +const LOG_PREFIX = '[QuickPanelTabs]'; + +// ============================================================ +// Helpers +// ============================================================ + +function isValidTabId(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function isValidWindowId(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function normalizeBoolean(value: unknown): boolean { + return value === true; +} + +function getLastAccessed(tab: chrome.tabs.Tab): number | undefined { + const anyTab = tab as unknown as { lastAccessed?: unknown }; + const value = anyTab.lastAccessed; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function safeErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message || String(err); + } + return String(err); +} + +/** + * Convert a chrome.tabs.Tab to our summary format. + * Returns null if tab is invalid. + */ +function toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null { + if (!isValidTabId(tab.id)) return null; + + const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null; + if (windowId === null) return null; + + return { + tabId: tab.id, + windowId, + title: tab.title ?? '', + url: tab.url ?? '', + favIconUrl: tab.favIconUrl ?? undefined, + active: normalizeBoolean(tab.active), + pinned: normalizeBoolean(tab.pinned), + audible: normalizeBoolean(tab.audible), + muted: normalizeBoolean(tab.mutedInfo?.muted), + index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0, + lastAccessed: getLastAccessed(tab), + }; +} + +// ============================================================ +// Message Handlers +// ============================================================ + +async function handleTabsQuery( + message: QuickPanelTabsQueryMessage, + sender: chrome.runtime.MessageSender, +): Promise { + try { + const includeAllWindows = message.payload?.includeAllWindows ?? true; + + // Extract current context from sender + const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null; + const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null; + + // Quick Panel should only be called from content scripts (which have sender.tab) + // Reject requests without valid sender tab context for security + if (!includeAllWindows && currentWindowId === null) { + return { + success: false, + error: 'Invalid request: sender tab context required for window-scoped queries', + }; + } + + // Build query info based on scope + const queryInfo: chrome.tabs.QueryInfo = includeAllWindows + ? {} + : { windowId: currentWindowId! }; + + const tabs = await chrome.tabs.query(queryInfo); + + // Convert to summaries, filtering out invalid tabs + const summaries: QuickPanelTabSummary[] = []; + for (const tab of tabs) { + const summary = toTabSummary(tab); + if (summary) { + summaries.push(summary); + } + } + + return { + success: true, + tabs: summaries, + currentTabId, + currentWindowId, + }; + } catch (err) { + console.warn(`${LOG_PREFIX} Error querying tabs:`, err); + return { + success: false, + error: safeErrorMessage(err) || 'Failed to query tabs', + }; + } +} + +async function handleActivateTab( + message: QuickPanelActivateTabMessage, +): Promise { + try { + const tabId = message.payload?.tabId; + const windowId = message.payload?.windowId; + + if (!isValidTabId(tabId)) { + return { success: false, error: 'Invalid tabId' }; + } + + // Focus the window first if provided + if (isValidWindowId(windowId)) { + try { + await chrome.windows.update(windowId, { focused: true }); + } catch { + // Best-effort: tab activation may still succeed without focusing window. + } + } + + // Activate the tab + await chrome.tabs.update(tabId, { active: true }); + + return { success: true }; + } catch (err) { + console.warn(`${LOG_PREFIX} Error activating tab:`, err); + return { + success: false, + error: safeErrorMessage(err) || 'Failed to activate tab', + }; + } +} + +async function handleCloseTab( + message: QuickPanelCloseTabMessage, +): Promise { + try { + const tabId = message.payload?.tabId; + + if (!isValidTabId(tabId)) { + return { success: false, error: 'Invalid tabId' }; + } + + await chrome.tabs.remove(tabId); + + return { success: true }; + } catch (err) { + console.warn(`${LOG_PREFIX} Error closing tab:`, err); + return { + success: false, + error: safeErrorMessage(err) || 'Failed to close tab', + }; + } +} + +// ============================================================ +// Initialization +// ============================================================ + +let initialized = false; + +/** + * Initialize the Quick Panel Tabs handler. + * Safe to call multiple times - subsequent calls are no-ops. + */ +export function initQuickPanelTabsHandler(): void { + if (initialized) return; + initialized = true; + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Tabs query + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) { + handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse); + return true; // Will respond asynchronously + } + + // Tab activate + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) { + handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse); + return true; + } + + // Tab close + if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) { + handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse); + return true; + } + + return false; // Not handled by this listener + }); + + console.debug(`${LOG_PREFIX} Initialized`); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts new file mode 100644 index 0000000..75704c4 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts @@ -0,0 +1,469 @@ +/** + * @fileoverview Record-Replay V3 composition root (bootstrap) + * @description + * Wires storage, events, scheduler, triggers and RPC for the MV3 background service worker. + * + * 设计说明: + * - 必须先执行 recoverFromCrash() 再启动 scheduler.start() + * - 使用全局单例 keepalive-manager 避免多个控制器冲突 + * - RunExecutor 使用 RunRunner 执行实际的 Flow + */ + +import type { UnixMillis } from './domain/json'; +import type { RunId } from './domain/ids'; +import { RR_ERROR_CODES, createRRError, type RRError } from './domain/errors'; + +import type { StoragePort } from './engine/storage/storage-port'; +import { StorageBackedEventsBus, type EventsBus } from './engine/transport/events-bus'; + +import { DEFAULT_QUEUE_CONFIG, type RunQueueItem } from './engine/queue/queue'; +import { createLeaseManager, generateOwnerId, type LeaseManager } from './engine/queue/leasing'; +import { createRunScheduler, type RunExecutor, type RunScheduler } from './engine/queue/scheduler'; +import { recoverFromCrash } from './engine/recovery/recovery-coordinator'; + +import { RpcServer } from './engine/transport/rpc-server'; + +import { createTriggerManager, type TriggerManager } from './engine/triggers/trigger-manager'; +import { createUrlTriggerHandlerFactory } from './engine/triggers/url-trigger'; +import { createCommandTriggerHandlerFactory } from './engine/triggers/command-trigger'; +import { createContextMenuTriggerHandlerFactory } from './engine/triggers/context-menu-trigger'; +import { createDomTriggerHandlerFactory } from './engine/triggers/dom-trigger'; +import { createCronTriggerHandlerFactory } from './engine/triggers/cron-trigger'; +import { createIntervalTriggerHandlerFactory } from './engine/triggers/interval-trigger'; +import { createOnceTriggerHandlerFactory } from './engine/triggers/once-trigger'; +import { createManualTriggerHandlerFactory } from './engine/triggers/manual-trigger'; + +import { createChromeArtifactService } from './engine/kernel/artifacts'; +import { createRunRunnerFactory, type RunRunnerFactory } from './engine/kernel/runner'; +import { + createDebugController, + createRunnerRegistry, + type DebugController, + type RunnerRegistry, +} from './engine/kernel/debug-controller'; + +import { PluginRegistry } from './engine/plugins/registry'; +import { + registerV2ReplayNodesAsV3Nodes, + DEFAULT_V2_EXCLUDE_LIST, +} from './engine/plugins/register-v2-replay-nodes'; + +import { acquireKeepalive } from '../keepalive-manager'; +import { createStoragePort } from './index'; + +// ==================== Types ==================== + +type Logger = Pick; + +/** + * V3 运行时句柄 + */ +export interface V3Runtime { + ownerId: string; + storage: StoragePort; + events: EventsBus; + leaseManager: LeaseManager; + scheduler: RunScheduler; + runners: RunnerRegistry; + debugController: DebugController; + triggers: TriggerManager; + rpcServer: RpcServer; + stop(): Promise; +} + +// ==================== Singleton State ==================== + +let runtime: V3Runtime | null = null; +let bootstrapPromise: Promise | null = null; + +// ==================== Utilities ==================== + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === 'object' && 'message' in err) + return String((err as { message: unknown }).message); + return String(err); +} + +function isFiniteNumber(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +async function tabExists(tabId: number): Promise { + try { + await chrome.tabs.get(tabId); + return true; + } catch { + return false; + } +} + +async function createEphemeralTab(logger: Logger): Promise { + const tab = await chrome.tabs.create({ url: 'about:blank', active: false }); + if (tab.id === undefined) { + throw new Error('chrome.tabs.create returned a tab without id'); + } + logger.debug(`[RR-V3] Allocated ephemeral tab ${tab.id}`); + return tab.id; +} + +async function safeRemoveTab(tabId: number, logger: Logger): Promise { + try { + await chrome.tabs.remove(tabId); + } catch (e) { + logger.debug(`[RR-V3] Failed to close tab ${tabId}:`, e); + } +} + +/** + * 解析运行 Run 所需的 Tab ID + * 优先级: run.tabId > queue.tabId > trigger.sourceTabId > 创建新 Tab + */ +async function resolveRunTab(input: { + runTabId?: number; + queueTabId?: number; + triggerTabId?: number; + logger: Logger; +}): Promise<{ tabId: number; shouldClose: boolean }> { + const candidates = [input.runTabId, input.queueTabId, input.triggerTabId].filter( + (x): x is number => isFiniteNumber(x), + ); + + for (const tabId of candidates) { + if (await tabExists(tabId)) { + return { tabId, shouldClose: false }; + } + } + + const tabId = await createEphemeralTab(input.logger); + return { tabId, shouldClose: true }; +} + +/** + * 将 Run 标记为失败 + * 注意:会重新读取最新的 RunRecord 以获取正确的 startedAt + */ +async function failRun( + deps: { storage: StoragePort; events: EventsBus; now: () => UnixMillis; logger: Logger }, + runId: RunId, + error: RRError, +): Promise { + const finishedAt = deps.now(); + + // 重新获取最新的 run 记录以获取正确的 startedAt + let startedAt = finishedAt; + try { + const latestRun = await deps.storage.runs.get(runId); + if (latestRun?.startedAt !== undefined) { + startedAt = latestRun.startedAt; + } + } catch { + // ignore - use finishedAt as startedAt + } + + const tookMs = Math.max(0, finishedAt - startedAt); + + try { + await deps.storage.runs.patch(runId, { + status: 'failed', + finishedAt, + tookMs, + error, + }); + } catch (e) { + deps.logger.error(`[RR-V3] Failed to patch run "${runId}" as failed:`, e); + return; + } + + try { + await deps.events.append({ runId, type: 'run.failed', error }); + } catch (e) { + deps.logger.warn(`[RR-V3] Failed to append run.failed for "${runId}":`, e); + } +} + +// ==================== Run Executor ==================== + +/** + * 创建默认的 RunExecutor + * 使用 RunRunner 执行 Flow + */ +function createDefaultRunExecutor(deps: { + storage: StoragePort; + events: EventsBus; + runnerFactory: RunRunnerFactory; + runners: RunnerRegistry; + now: () => UnixMillis; + logger: Logger; +}): RunExecutor { + return async (item: RunQueueItem): Promise => { + const runId = item.id; + + // 1. 获取 RunRecord + const run = await deps.storage.runs.get(runId); + if (!run) { + deps.logger.warn(`[RR-V3] RunRecord not found for queue item "${runId}", skipping execution`); + return; + } + + // 2. 获取 Flow + const flow = await deps.storage.flows.get(item.flowId); + if (!flow) { + await failRun( + deps, + runId, + createRRError(RR_ERROR_CODES.VALIDATION_ERROR, `Flow "${item.flowId}" not found`), + ); + return; + } + + // 3. 解析 Tab ID + const { tabId, shouldClose } = await resolveRunTab({ + runTabId: run.tabId, + queueTabId: item.tabId, + triggerTabId: item.trigger?.sourceTabId, + logger: deps.logger, + }); + + // 4. 同步 attempt 到 RunRecord + try { + await deps.storage.runs.patch(runId, { + attempt: item.attempt, + maxAttempts: item.maxAttempts, + tabId, + }); + } catch (e) { + deps.logger.debug(`[RR-V3] Failed to patch run "${runId}" attempt/tabId:`, e); + } + + // 5. 执行 Run + let runner; + try { + runner = deps.runnerFactory.create(runId, { + flow, + tabId, + args: item.args, + startNodeId: run.startNodeId, + debug: item.debug, + }); + + // 注册到 RunnerRegistry,供 DebugController 和 RPC 使用 + deps.runners.register(runId, runner); + + await runner.start(); + } catch (e) { + await failRun( + deps, + runId, + createRRError(RR_ERROR_CODES.INTERNAL, `Executor crashed: ${errorMessage(e)}`), + ); + } finally { + // 6. 注销 Runner + if (runner) { + deps.runners.unregister(runId); + } + + // 7. 清理临时 Tab + if (shouldClose) { + await safeRemoveTab(tabId, deps.logger); + } + } + }; +} + +// ==================== Bootstrap ==================== + +/** + * 启动 RR-V3 运行时 + * @returns 运行时句柄 + */ +export async function bootstrapV3(): Promise { + if (runtime) return runtime; + if (bootstrapPromise) return bootstrapPromise; + + bootstrapPromise = (async () => { + const logger: Logger = console; + const now = (): UnixMillis => Date.now(); + + logger.info('[RR-V3] Bootstrapping...'); + + // 1) Storage + const storage = createStoragePort(); + + // 2) EventsBus + const events: EventsBus = new StorageBackedEventsBus(storage.events); + + // 3) Lease owner identity (per SW instance) + const ownerId = generateOwnerId(); + logger.debug(`[RR-V3] Owner ID: ${ownerId}`); + + // 4) LeaseManager + const leaseManager = createLeaseManager(storage.queue, DEFAULT_QUEUE_CONFIG); + + // 5) RunnerRegistry + DebugController + const runners = createRunnerRegistry(); + const debugController = createDebugController({ storage, events, runners }); + + // 6) Keepalive (reuse global singleton to avoid multiple controllers fighting) + const keepalive = { + acquire: (tag: string) => acquireKeepalive(`rr_v3:${tag}`), + }; + + // 7) PluginRegistry - register V2 action handlers as V3 nodes + const plugins = new PluginRegistry(); + const registeredNodes = registerV2ReplayNodesAsV3Nodes(plugins, { + // Exclude control directives that V3 runner doesn't support + exclude: [...DEFAULT_V2_EXCLUDE_LIST], + }); + logger.debug(`[RR-V3] Registered ${registeredNodes.length} V2 action handlers as V3 nodes`); + + // 8) RunExecutor via RunRunnerFactory + const runnerFactory = createRunRunnerFactory({ + storage, + events, + plugins, + artifactService: createChromeArtifactService(), + now, + }); + + const execute = createDefaultRunExecutor({ + storage, + events, + runnerFactory, + runners, + now, + logger, + }); + + // 7) Scheduler + const scheduler = createRunScheduler({ + queue: storage.queue, + leaseManager, + keepalive, + config: DEFAULT_QUEUE_CONFIG, + ownerId, + execute, + now, + logger, + }); + + // 8) TriggerManager + const triggers = createTriggerManager({ + storage, + events, + scheduler, + handlerFactories: { + url: createUrlTriggerHandlerFactory({ logger }), + command: createCommandTriggerHandlerFactory({ logger }), + contextMenu: createContextMenuTriggerHandlerFactory({ logger }), + dom: createDomTriggerHandlerFactory({ logger }), + cron: createCronTriggerHandlerFactory({ logger, now }), + interval: createIntervalTriggerHandlerFactory({ logger }), + once: createOnceTriggerHandlerFactory({ logger }), + manual: createManualTriggerHandlerFactory({ logger }), + }, + now, + logger, + }); + + // 10) RpcServer (created but started after recovery) + const rpcServer = new RpcServer({ + storage, + events, + scheduler, + debugController, + runners, + triggerManager: triggers, + now, + }); + + // Cleanup helper for error recovery + const cleanup = async (): Promise => { + try { + rpcServer.stop(); + } catch { + /* ignore */ + } + try { + await triggers.stop(); + } catch { + /* ignore */ + } + try { + scheduler.stop(); + } catch { + /* ignore */ + } + try { + leaseManager.dispose(); + } catch { + /* ignore */ + } + try { + debugController.stop(); + } catch { + /* ignore */ + } + }; + + try { + // 10) Recovery - MUST run before scheduler.start() + logger.info('[RR-V3] Running crash recovery...'); + await recoverFromCrash({ storage, events, ownerId, now, logger }); + + // 11) Start components + scheduler.start(); + await triggers.start(); + rpcServer.start(); + + logger.info('[RR-V3] Bootstrap complete'); + } catch (e) { + await cleanup(); + throw e; + } + + // Build runtime handle + runtime = { + ownerId, + storage, + events, + leaseManager, + scheduler, + runners, + debugController, + triggers, + rpcServer, + stop: async () => { + logger.info('[RR-V3] Stopping...'); + // Stop order: RPC first (block new requests) -> triggers -> scheduler -> lease -> debug + rpcServer.stop(); + await triggers.stop().catch(() => {}); + scheduler.stop(); + leaseManager.dispose(); + debugController.stop(); + runtime = null; + logger.info('[RR-V3] Stopped'); + }, + }; + + return runtime; + })().finally(() => { + bootstrapPromise = null; + }); + + return bootstrapPromise; +} + +/** + * 获取当前运行时(如果已启动) + */ +export function getV3Runtime(): V3Runtime | null { + return runtime; +} + +/** + * 检查 V3 是否已启动 + */ +export function isV3Running(): boolean { + return runtime !== null; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts new file mode 100644 index 0000000..ed54064 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts @@ -0,0 +1,88 @@ +/** + * @fileoverview 调试器类型定义 + * @description 定义 Record-Replay V3 中的调试器状态和协议 + */ + +import type { JsonValue } from './json'; +import type { NodeId, RunId } from './ids'; +import type { PauseReason } from './events'; + +/** + * 断点定义 + */ +export interface Breakpoint { + /** 断点所在节点 ID */ + nodeId: NodeId; + /** 是否启用 */ + enabled: boolean; +} + +/** + * 调试器状态 + * @description 描述调试器当前的连接和执行状态 + */ +export interface DebuggerState { + /** 关联的 Run ID */ + runId: RunId; + /** 调试器连接状态 */ + status: 'attached' | 'detached'; + /** 执行状态 */ + execution: 'running' | 'paused'; + /** 暂停原因(仅当 execution='paused' 时有效) */ + pauseReason?: PauseReason; + /** 当前节点 ID */ + currentNodeId?: NodeId; + /** 断点列表 */ + breakpoints: Breakpoint[]; + /** 单步模式 */ + stepMode?: 'none' | 'stepOver'; +} + +/** + * 调试器命令 + * @description 客户端发送给调试器的命令 + */ +export type DebuggerCommand = + // ===== 连接控制 ===== + | { type: 'debug.attach'; runId: RunId } + | { type: 'debug.detach'; runId: RunId } + + // ===== 执行控制 ===== + | { type: 'debug.pause'; runId: RunId } + | { type: 'debug.resume'; runId: RunId } + | { type: 'debug.stepOver'; runId: RunId } + + // ===== 断点管理 ===== + | { type: 'debug.setBreakpoints'; runId: RunId; nodeIds: NodeId[] } + | { type: 'debug.addBreakpoint'; runId: RunId; nodeId: NodeId } + | { type: 'debug.removeBreakpoint'; runId: RunId; nodeId: NodeId } + + // ===== 状态查询 ===== + | { type: 'debug.getState'; runId: RunId } + + // ===== 变量操作 ===== + | { type: 'debug.getVar'; runId: RunId; name: string } + | { type: 'debug.setVar'; runId: RunId; name: string; value: JsonValue }; + +/** 调试器命令类型(从联合类型提取) */ +export type DebuggerCommandType = DebuggerCommand['type']; + +/** + * 调试器命令响应 + */ +export type DebuggerResponse = + | { ok: true; state?: DebuggerState; value?: JsonValue } + | { ok: false; error: string }; + +/** + * 创建初始调试器状态 + */ +export function createInitialDebuggerState(runId: RunId): DebuggerState { + return { + runId, + status: 'detached', + execution: 'running', + breakpoints: [], + stepMode: 'none', + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts new file mode 100644 index 0000000..d0f6cc5 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview 错误类型定义 + * @description 定义 Record-Replay V3 中使用的错误码和错误类型 + */ + +import type { JsonValue } from './json'; + +/** 错误码常量 */ +export const RR_ERROR_CODES = { + // ===== 验证错误 ===== + /** 通用验证错误 */ + VALIDATION_ERROR: 'VALIDATION_ERROR', + /** 不支持的节点类型 */ + UNSUPPORTED_NODE: 'UNSUPPORTED_NODE', + /** DAG 结构无效 */ + DAG_INVALID: 'DAG_INVALID', + /** DAG 存在循环 */ + DAG_CYCLE: 'DAG_CYCLE', + + // ===== 运行时错误 ===== + /** 操作超时 */ + TIMEOUT: 'TIMEOUT', + /** Tab 未找到 */ + TAB_NOT_FOUND: 'TAB_NOT_FOUND', + /** Frame 未找到 */ + FRAME_NOT_FOUND: 'FRAME_NOT_FOUND', + /** 目标元素未找到 */ + TARGET_NOT_FOUND: 'TARGET_NOT_FOUND', + /** 元素不可见 */ + ELEMENT_NOT_VISIBLE: 'ELEMENT_NOT_VISIBLE', + /** 导航失败 */ + NAVIGATION_FAILED: 'NAVIGATION_FAILED', + /** 网络请求失败 */ + NETWORK_REQUEST_FAILED: 'NETWORK_REQUEST_FAILED', + + // ===== 脚本/工具错误 ===== + /** 脚本执行失败 */ + SCRIPT_FAILED: 'SCRIPT_FAILED', + /** 权限被拒绝 */ + PERMISSION_DENIED: 'PERMISSION_DENIED', + /** 工具执行错误 */ + TOOL_ERROR: 'TOOL_ERROR', + + // ===== 控制错误 ===== + /** Run 被取消 */ + RUN_CANCELED: 'RUN_CANCELED', + /** Run 被暂停 */ + RUN_PAUSED: 'RUN_PAUSED', + + // ===== 内部错误 ===== + /** 内部错误 */ + INTERNAL: 'INTERNAL', + /** 不变量违规 */ + INVARIANT_VIOLATION: 'INVARIANT_VIOLATION', +} as const; + +/** 错误码类型 */ +export type RRErrorCode = (typeof RR_ERROR_CODES)[keyof typeof RR_ERROR_CODES]; + +/** + * Record-Replay 错误接口 + * @description 统一的错误表示,支持错误链和可重试标记 + */ +export interface RRError { + /** 错误码 */ + code: RRErrorCode; + /** 错误消息 */ + message: string; + /** 附加数据 */ + data?: JsonValue; + /** 是否可重试 */ + retryable?: boolean; + /** 原因错误(错误链) */ + cause?: RRError; +} + +/** + * 创建 RRError 的工厂函数 + */ +export function createRRError( + code: RRErrorCode, + message: string, + options?: { data?: JsonValue; retryable?: boolean; cause?: RRError }, +): RRError { + return { + code, + message, + ...(options?.data !== undefined && { data: options.data }), + ...(options?.retryable !== undefined && { retryable: options.retryable }), + ...(options?.cause !== undefined && { cause: options.cause }), + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts new file mode 100644 index 0000000..8f87137 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts @@ -0,0 +1,185 @@ +/** + * @fileoverview 事件类型定义 + * @description 定义 Record-Replay V3 中的运行事件和状态 + */ + +import type { JsonObject, JsonValue, UnixMillis } from './json'; +import type { EdgeLabel, FlowId, NodeId, RunId } from './ids'; +import type { RRError } from './errors'; +import type { TriggerFireContext } from './triggers'; + +/** 取消订阅函数类型 */ +export type Unsubscribe = () => void; + +/** Run 状态 */ +export type RunStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'canceled'; + +/** + * 事件基础接口 + * @description 所有事件的公共字段 + */ +export interface EventBase { + /** 所属 Run ID */ + runId: RunId; + /** 事件时间戳 */ + ts: UnixMillis; + /** 单调递增序列号 */ + seq: number; +} + +/** + * 暂停原因 + * @description 描述 Run 暂停的原因 + */ +export type PauseReason = + | { kind: 'breakpoint'; nodeId: NodeId } + | { kind: 'step'; nodeId: NodeId } + | { kind: 'command' } + | { kind: 'policy'; nodeId: NodeId; reason: string }; + +/** 恢复原因 */ +export type RecoveryReason = 'sw_restart' | 'lease_expired'; + +/** + * Run 事件联合类型 + * @description 所有可能的运行时事件 + */ +export type RunEvent = + // ===== Run 生命周期事件 ===== + | (EventBase & { type: 'run.queued'; flowId: FlowId }) + | (EventBase & { type: 'run.started'; flowId: FlowId; tabId: number }) + | (EventBase & { type: 'run.paused'; reason: PauseReason; nodeId?: NodeId }) + | (EventBase & { type: 'run.resumed' }) + | (EventBase & { + type: 'run.recovered'; + /** 恢复原因 */ + reason: RecoveryReason; + /** 恢复前状态 */ + fromStatus: 'running' | 'paused'; + /** 恢复后状态 */ + toStatus: 'queued'; + /** 原 ownerId(用于审计) */ + prevOwnerId?: string; + }) + | (EventBase & { type: 'run.canceled'; reason?: string }) + | (EventBase & { type: 'run.succeeded'; tookMs: number; outputs?: JsonObject }) + | (EventBase & { type: 'run.failed'; error: RRError; nodeId?: NodeId }) + + // ===== Node 执行事件 ===== + | (EventBase & { type: 'node.queued'; nodeId: NodeId }) + | (EventBase & { type: 'node.started'; nodeId: NodeId; attempt: number }) + | (EventBase & { + type: 'node.succeeded'; + nodeId: NodeId; + tookMs: number; + next?: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'end' }; + }) + | (EventBase & { + type: 'node.failed'; + nodeId: NodeId; + attempt: number; + error: RRError; + decision: 'retry' | 'continue' | 'stop' | 'goto'; + }) + | (EventBase & { type: 'node.skipped'; nodeId: NodeId; reason: 'disabled' | 'unreachable' }) + + // ===== 变量和日志事件 ===== + | (EventBase & { + type: 'vars.patch'; + patch: Array<{ op: 'set' | 'delete'; name: string; value?: JsonValue }>; + }) + | (EventBase & { type: 'artifact.screenshot'; nodeId: NodeId; data: string; savedAs?: string }) + | (EventBase & { + type: 'log'; + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + data?: JsonValue; + }); + +/** Run 事件类型(从联合类型提取) */ +export type RunEventType = RunEvent['type']; + +/** + * 分布式 Omit(保留联合类型) + */ +type DistributiveOmit = T extends unknown ? Omit : never; + +/** + * Run 事件输入类型 + * @description seq 必须由 storage 层原子分配(通过 RunRecordV3.nextSeq) + * ts 可选,默认为 Date.now() + */ +export type RunEventInput = DistributiveOmit & { + ts?: UnixMillis; +}; + +/** Run Schema 版本 */ +export const RUN_SCHEMA_VERSION = 3 as const; + +/** + * Run 记录 V3 + * @description 存储在 IndexedDB 中的 Run 摘要记录 + */ +export interface RunRecordV3 { + /** Schema 版本 */ + schemaVersion: typeof RUN_SCHEMA_VERSION; + /** Run 唯一标识符 */ + id: RunId; + /** 关联的 Flow ID */ + flowId: FlowId; + + /** 当前状态 */ + status: RunStatus; + /** 创建时间 */ + createdAt: UnixMillis; + /** 最后更新时间 */ + updatedAt: UnixMillis; + + /** 开始执行时间 */ + startedAt?: UnixMillis; + /** 结束时间 */ + finishedAt?: UnixMillis; + /** 总耗时(毫秒) */ + tookMs?: number; + + /** 绑定的 Tab ID(每 Run 独占) */ + tabId?: number; + /** 起始节点 ID(如果不是默认入口) */ + startNodeId?: NodeId; + /** 当前执行节点 ID */ + currentNodeId?: NodeId; + + /** 当前尝试次数 */ + attempt: number; + /** 最大尝试次数 */ + maxAttempts: number; + + /** 运行参数 */ + args?: JsonObject; + /** 触发器上下文 */ + trigger?: TriggerFireContext; + /** 调试配置 */ + debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean }; + + /** 错误信息(如果失败) */ + error?: RRError; + /** 输出结果 */ + outputs?: JsonObject; + + /** 下一个事件序列号(缓存字段) */ + nextSeq: number; +} + +/** + * 判断 Run 是否已终止 + */ +export function isTerminalStatus(status: RunStatus): boolean { + return status === 'succeeded' || status === 'failed' || status === 'canceled'; +} + +/** + * 判断 Run 是否正在执行 + */ +export function isActiveStatus(status: RunStatus): boolean { + return status === 'running' || status === 'paused'; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts new file mode 100644 index 0000000..9a236c8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts @@ -0,0 +1,119 @@ +/** + * @fileoverview Flow 类型定义 + * @description 定义 Record-Replay V3 中的 Flow IR(中间表示) + */ + +import type { ISODateTimeString, JsonObject } from './json'; +import type { EdgeId, EdgeLabel, FlowId, NodeId } from './ids'; +import type { FlowPolicy, NodePolicy } from './policy'; +import type { VariableDefinition } from './variables'; + +/** Flow Schema 版本 */ +export const FLOW_SCHEMA_VERSION = 3 as const; + +/** + * Edge V3 + * @description DAG 中的边,连接两个节点 + */ +export interface EdgeV3 { + /** Edge 唯一标识符 */ + id: EdgeId; + /** 源节点 ID */ + from: NodeId; + /** 目标节点 ID */ + to: NodeId; + /** 边标签(用于条件分支和错误处理) */ + label?: EdgeLabel; +} + +/** 节点类型(可扩展) */ +export type NodeKind = string; + +/** + * Node V3 + * @description DAG 中的节点,代表一个可执行的操作 + */ +export interface NodeV3 { + /** Node 唯一标识符 */ + id: NodeId; + /** 节点类型 */ + kind: NodeKind; + /** 节点名称(用于显示) */ + name?: string; + /** 是否禁用 */ + disabled?: boolean; + /** 节点级策略 */ + policy?: NodePolicy; + /** 节点配置(类型由 kind 决定) */ + config: JsonObject; + /** UI 布局信息 */ + ui?: { x: number; y: number }; +} + +/** + * Flow 元数据绑定 + * @description 定义 Flow 与特定域名/路径/URL 的关联 + */ +export interface FlowBinding { + kind: 'domain' | 'path' | 'url'; + value: string; +} + +/** + * Flow V3 + * @description 完整的 Flow 定义,包含节点、边和配置 + */ +export interface FlowV3 { + /** Schema 版本 */ + schemaVersion: typeof FLOW_SCHEMA_VERSION; + /** Flow 唯一标识符 */ + id: FlowId; + /** Flow 名称 */ + name: string; + /** Flow 描述 */ + description?: string; + /** 创建时间 */ + createdAt: ISODateTimeString; + /** 更新时间 */ + updatedAt: ISODateTimeString; + + /** 入口节点 ID(显式指定,不依赖入度推断) */ + entryNodeId: NodeId; + /** 节点列表 */ + nodes: NodeV3[]; + /** 边列表 */ + edges: EdgeV3[]; + + /** 变量定义 */ + variables?: VariableDefinition[]; + /** Flow 级策略 */ + policy?: FlowPolicy; + /** 元数据 */ + meta?: { + /** 标签 */ + tags?: string[]; + /** 绑定规则 */ + bindings?: FlowBinding[]; + }; +} + +/** + * 根据 ID 查找节点 + */ +export function findNodeById(flow: FlowV3, nodeId: NodeId): NodeV3 | undefined { + return flow.nodes.find((n) => n.id === nodeId); +} + +/** + * 查找从指定节点出发的所有边 + */ +export function findEdgesFrom(flow: FlowV3, nodeId: NodeId): EdgeV3[] { + return flow.edges.filter((e) => e.from === nodeId); +} + +/** + * 查找指向指定节点的所有边 + */ +export function findEdgesTo(flow: FlowV3, nodeId: NodeId): EdgeV3[] { + return flow.edges.filter((e) => e.to === nodeId); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts new file mode 100644 index 0000000..b3f98dc --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts @@ -0,0 +1,37 @@ +/** + * @fileoverview ID 类型定义 + * @description 定义 Record-Replay V3 中使用的各种 ID 类型 + */ + +/** Flow 唯一标识符 */ +export type FlowId = string; + +/** Node 唯一标识符 */ +export type NodeId = string; + +/** Edge 唯一标识符 */ +export type EdgeId = string; + +/** Run 唯一标识符 */ +export type RunId = string; + +/** Trigger 唯一标识符 */ +export type TriggerId = string; + +/** Edge 标签类型 */ +export type EdgeLabel = string; + +/** 预定义的 Edge 标签常量 */ +export const EDGE_LABELS = { + /** 默认边 */ + DEFAULT: 'default', + /** 错误处理边 */ + ON_ERROR: 'onError', + /** 条件为真时的边 */ + TRUE: 'true', + /** 条件为假时的边 */ + FALSE: 'false', +} as const; + +/** Edge 标签类型(从常量推导) */ +export type EdgeLabelValue = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS]; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts new file mode 100644 index 0000000..3010d10 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts @@ -0,0 +1,31 @@ +/** + * @fileoverview Domain 层导出入口 + * @description 导出所有 Domain 类型定义 + */ + +// JSON 基础类型 +export * from './json'; + +// ID 类型 +export * from './ids'; + +// 错误类型 +export * from './errors'; + +// 策略类型 +export * from './policy'; + +// 变量类型 +export * from './variables'; + +// Flow 类型 +export * from './flow'; + +// 事件类型 +export * from './events'; + +// 调试器类型 +export * from './debug'; + +// 触发器类型 +export * from './triggers'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts new file mode 100644 index 0000000..d723583 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts @@ -0,0 +1,24 @@ +/** + * @fileoverview JSON 基础类型定义 + * @description 定义 Record-Replay V3 中使用的 JSON 相关类型 + */ + +/** JSON 原始类型 */ +export type JsonPrimitive = string | number | boolean | null; + +/** JSON 对象类型 */ +export interface JsonObject { + [key: string]: JsonValue; +} + +/** JSON 数组类型 */ +export type JsonArray = JsonValue[]; + +/** 任意 JSON 值类型 */ +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +/** ISO 8601 日期时间字符串 */ +export type ISODateTimeString = string; + +/** Unix 毫秒时间戳 */ +export type UnixMillis = number; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts new file mode 100644 index 0000000..e4f90c0 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts @@ -0,0 +1,115 @@ +/** + * @fileoverview 策略类型定义 + * @description 定义 Record-Replay V3 中使用的超时、重试、错误处理和工件策略 + */ + +import type { EdgeLabel, NodeId } from './ids'; +import type { RRErrorCode } from './errors'; +import type { UnixMillis } from './json'; + +/** + * 超时策略 + * @description 定义操作的超时时间和作用范围 + */ +export interface TimeoutPolicy { + /** 超时时间(毫秒) */ + ms: UnixMillis; + /** 超时范围:attempt=每次尝试, node=整个节点执行 */ + scope?: 'attempt' | 'node'; +} + +/** + * 重试策略 + * @description 定义失败后的重试行为 + */ +export interface RetryPolicy { + /** 最大重试次数 */ + retries: number; + /** 重试间隔(毫秒) */ + intervalMs: UnixMillis; + /** 退避策略:none=固定间隔, exp=指数退避, linear=线性增长 */ + backoff?: 'none' | 'exp' | 'linear'; + /** 最大重试间隔(毫秒) */ + maxIntervalMs?: UnixMillis; + /** 抖动策略:none=无抖动, full=完全随机 */ + jitter?: 'none' | 'full'; + /** 仅在这些错误码时重试 */ + retryOn?: ReadonlyArray; +} + +/** + * 错误处理策略 + * @description 定义节点执行失败后的处理方式 + */ +export type OnErrorPolicy = + | { kind: 'stop' } + | { kind: 'continue'; as?: 'warning' | 'error' } + | { + kind: 'goto'; + target: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'node'; nodeId: NodeId }; + } + | { kind: 'retry'; override?: Partial }; + +/** + * 工件策略 + * @description 定义截图和日志收集的行为 + */ +export interface ArtifactPolicy { + /** 截图策略:never=从不, onFailure=失败时, always=总是 */ + screenshot?: 'never' | 'onFailure' | 'always'; + /** 截图保存路径模板 */ + saveScreenshotAs?: string; + /** 是否包含控制台日志 */ + includeConsole?: boolean; + /** 是否包含网络请求 */ + includeNetwork?: boolean; +} + +/** + * 节点级策略 + * @description 单个节点的执行策略配置 + */ +export interface NodePolicy { + /** 超时策略 */ + timeout?: TimeoutPolicy; + /** 重试策略 */ + retry?: RetryPolicy; + /** 错误处理策略 */ + onError?: OnErrorPolicy; + /** 工件策略 */ + artifacts?: ArtifactPolicy; +} + +/** + * Flow 级策略 + * @description 整个 Flow 的执行策略配置 + */ +export interface FlowPolicy { + /** 默认节点策略 */ + defaultNodePolicy?: NodePolicy; + /** 不支持节点的处理策略 */ + unsupportedNodePolicy?: OnErrorPolicy; + /** Run 总超时时间(毫秒) */ + runTimeoutMs?: UnixMillis; +} + +/** + * 合并节点策略 + * @description 将 Flow 级默认策略与节点级策略合并 + */ +export function mergeNodePolicy( + flowDefault: NodePolicy | undefined, + nodePolicy: NodePolicy | undefined, +): NodePolicy { + if (!flowDefault) return nodePolicy ?? {}; + if (!nodePolicy) return flowDefault; + + return { + timeout: nodePolicy.timeout ?? flowDefault.timeout, + retry: nodePolicy.retry ?? flowDefault.retry, + onError: nodePolicy.onError ?? flowDefault.onError, + artifacts: nodePolicy.artifacts + ? { ...flowDefault.artifacts, ...nodePolicy.artifacts } + : flowDefault.artifacts, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts new file mode 100644 index 0000000..37f2c1b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts @@ -0,0 +1,143 @@ +/** + * @fileoverview 触发器类型定义 + * @description 定义 Record-Replay V3 中的触发器规范 + */ + +import type { JsonObject, UnixMillis } from './json'; +import type { FlowId, TriggerId } from './ids'; + +/** 触发器类型 */ +export type TriggerKind = + | 'manual' + | 'url' + | 'cron' + | 'interval' + | 'once' + | 'command' + | 'contextMenu' + | 'dom'; + +/** + * 触发器基础接口 + */ +export interface TriggerSpecBase { + /** 触发器 ID */ + id: TriggerId; + /** 触发器类型 */ + kind: TriggerKind; + /** 是否启用 */ + enabled: boolean; + /** 关联的 Flow ID */ + flowId: FlowId; + /** 传递给 Flow 的参数 */ + args?: JsonObject; +} + +/** + * URL 匹配规则 + */ +export interface UrlMatchRule { + kind: 'url' | 'domain' | 'path'; + value: string; +} + +/** + * 触发器规范联合类型 + */ +export type TriggerSpec = + // 手动触发 + | (TriggerSpecBase & { kind: 'manual' }) + + // URL 触发 + | (TriggerSpecBase & { + kind: 'url'; + match: UrlMatchRule[]; + }) + + // Cron 定时触发 + | (TriggerSpecBase & { + kind: 'cron'; + cron: string; + timezone?: string; + }) + + // Interval 定时触发(固定间隔重复) + | (TriggerSpecBase & { + kind: 'interval'; + /** 间隔分钟数,最小为 1 */ + periodMinutes: number; + }) + + // Once 定时触发(指定时间触发一次后自动禁用) + | (TriggerSpecBase & { + kind: 'once'; + /** 触发时间戳 (Unix milliseconds) */ + whenMs: UnixMillis; + }) + + // 快捷键触发 + | (TriggerSpecBase & { + kind: 'command'; + commandKey: string; + }) + + // 右键菜单触发 + | (TriggerSpecBase & { + kind: 'contextMenu'; + title: string; + contexts?: ReadonlyArray; + }) + + // DOM 元素出现触发 + | (TriggerSpecBase & { + kind: 'dom'; + selector: string; + appear?: boolean; + once?: boolean; + debounceMs?: UnixMillis; + }); + +/** + * 触发器触发上下文 + * @description 描述触发器被触发时的上下文信息 + */ +export interface TriggerFireContext { + /** 触发器 ID */ + triggerId: TriggerId; + /** 触发器类型 */ + kind: TriggerKind; + /** 触发时间 */ + firedAt: UnixMillis; + /** 来源 Tab ID */ + sourceTabId?: number; + /** 来源 URL */ + sourceUrl?: string; +} + +/** + * 根据触发器类型获取类型化的触发器规范 + */ +export type TriggerSpecByKind = Extract; + +/** + * 判断触发器是否启用 + */ +export function isTriggerEnabled(trigger: TriggerSpec): boolean { + return trigger.enabled; +} + +/** + * 创建触发器触发上下文 + */ +export function createTriggerFireContext( + trigger: TriggerSpec, + options?: { sourceTabId?: number; sourceUrl?: string }, +): TriggerFireContext { + return { + triggerId: trigger.id, + kind: trigger.kind, + firedAt: Date.now(), + sourceTabId: options?.sourceTabId, + sourceUrl: options?.sourceUrl, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts new file mode 100644 index 0000000..7651703 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts @@ -0,0 +1,98 @@ +/** + * @fileoverview 变量类型定义 + * @description 定义 Record-Replay V3 中使用的变量指针和持久化变量 + */ + +import type { JsonValue, UnixMillis } from './json'; + +/** 变量名称 */ +export type VariableName = string; + +/** 持久化变量名称(以 $ 开头) */ +export type PersistentVariableName = `$${string}`; + +/** 变量作用域 */ +export type VariableScope = 'run' | 'flow' | 'persistent'; + +/** + * 变量指针 + * @description 指向变量的引用,支持 JSON path 访问 + */ +export interface VariablePointer { + /** 变量作用域 */ + scope: VariableScope; + /** 变量名称 */ + name: VariableName; + /** JSON path(用于访问嵌套属性) */ + path?: ReadonlyArray; +} + +/** + * 变量定义 + * @description Flow 中声明的变量 + */ +export interface VariableDefinition { + /** 变量名称 */ + name: VariableName; + /** 显示标签 */ + label?: string; + /** 描述 */ + description?: string; + /** 是否敏感(不显示/导出) */ + sensitive?: boolean; + /** 是否必需 */ + required?: boolean; + /** 默认值 */ + default?: JsonValue; + /** 作用域(不含 persistent,persistent 通过 $ 前缀判断) */ + scope?: Exclude; +} + +/** + * 持久化变量记录 + * @description 存储在 IndexedDB 中的持久化变量 + */ +export interface PersistentVarRecord { + /** 变量键(以 $ 开头) */ + key: PersistentVariableName; + /** 变量值 */ + value: JsonValue; + /** 最后更新时间 */ + updatedAt: UnixMillis; + /** 版本号(单调递增,用于 LWW 和调试) */ + version: number; +} + +/** + * 判断变量名是否为持久化变量 + */ +export function isPersistentVariable(name: string): name is PersistentVariableName { + return name.startsWith('$'); +} + +/** + * 解析变量指针字符串 + * @example "$user.name" -> { scope: 'persistent', name: '$user', path: ['name'] } + */ +export function parseVariablePointer(ref: string): VariablePointer | null { + if (!ref) return null; + + const parts = ref.split('.'); + const name = parts[0]; + const path = parts.slice(1); + + if (isPersistentVariable(name)) { + return { + scope: 'persistent', + name, + path: path.length > 0 ? path : undefined, + }; + } + + // 默认为 run 作用域 + return { + scope: 'run', + name, + path: path.length > 0 ? path : undefined, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts new file mode 100644 index 0000000..0d3a679 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts @@ -0,0 +1,27 @@ +/** + * @fileoverview Engine 层导出入口 + */ + +// Kernel +export * from './kernel'; + +// Queue +export * from './queue'; + +// Plugins +export * from './plugins'; + +// Transport +export * from './transport'; + +// Keepalive +export * from './keepalive'; + +// Recovery +export * from './recovery'; + +// Triggers +export * from './triggers'; + +// Storage Port +export * from './storage'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts new file mode 100644 index 0000000..4f28d18 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts @@ -0,0 +1,5 @@ +/** + * @fileoverview Keepalive 模块导出入口 + */ + +export * from './offscreen-keepalive'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts new file mode 100644 index 0000000..3f50220 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts @@ -0,0 +1,451 @@ +/** + * @fileoverview Offscreen Keepalive Controller + * @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat. + * + * Architecture: + * - Background (this module) listens for an Offscreen Port connection. + * - Offscreen connects and sends heartbeat pings. + * - Background replies with pong and controls the heartbeat via `start`/`stop`. + * + * Contract: + * - When at least one keepalive reference is held, keepalive must be running. + * - When the reference count drops to zero, keepalive must fully stop (no ping loop, no Port, no reconnect). + */ + +import { offscreenManager } from '@/utils/offscreen-manager'; +import { + RR_V3_KEEPALIVE_PORT_NAME, + type KeepaliveMessage, +} from '@/common/rr-v3-keepalive-protocol'; + +// ==================== Runtime Control Protocol ==================== + +const KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const; + +type KeepaliveControlCommand = 'start' | 'stop'; + +interface KeepaliveControlMessage { + type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE; + command: KeepaliveControlCommand; +} + +// ==================== Types ==================== + +/** + * Keepalive controller interface. + * @description Manages Service Worker keepalive state. + */ +export interface KeepaliveController { + /** + * Acquire (increment reference count). + * @param tag Tag used for debugging. + * @returns Release function. + */ + acquire(tag: string): () => void; + + /** Whether any keepalive reference is currently held. */ + isActive(): boolean; + + /** Current reference count. */ + getRefCount(): number; + + /** Release all references (primarily for testing). */ + releaseAll(): void; +} + +/** + * Offscreen keepalive options. + */ +export interface OffscreenKeepaliveOptions { + /** Logger. */ + logger?: Pick; +} + +// ==================== Factory ==================== + +/** + * Create an Offscreen keepalive controller. + * @description Reuses the global OffscreenManager to avoid creating multiple Offscreen Documents concurrently. + */ +export function createOffscreenKeepaliveController( + options: OffscreenKeepaliveOptions = {}, +): KeepaliveController { + return new OffscreenKeepaliveControllerImpl(options); +} + +/** + * Create a NotImplemented KeepaliveController. + * @description Placeholder implementation. + */ +export function createNotImplementedKeepaliveController(): KeepaliveController { + return { + acquire: () => { + console.warn('[KeepaliveController] Not implemented, returning no-op release'); + return () => {}; + }, + isActive: () => false, + getRefCount: () => 0, + releaseAll: () => {}, + }; +} + +// ==================== Implementation ==================== + +/** + * Offscreen keepalive controller implementation. + */ +class OffscreenKeepaliveControllerImpl implements KeepaliveController { + private readonly refs = new Map(); + private totalRefs = 0; + + private offscreenPort: chrome.runtime.Port | null = null; + private connectionListenerRegistered = false; + + // Used to serialize async operations to avoid races. + private syncPromise: Promise = Promise.resolve(); + + private readonly logger: Pick; + + constructor(options: OffscreenKeepaliveOptions) { + this.logger = options.logger ?? console; + // Register listener eagerly to avoid missing Offscreen connect events. + // This prevents race conditions where Offscreen connects before we start listening. + this.ensureConnectionListener(); + } + + acquire(tag: string): () => void { + this.totalRefs += 1; + + const count = this.refs.get(tag) ?? 0; + this.refs.set(tag, count + 1); + + this.logger.debug(`[OffscreenKeepalive] acquire(${tag}), totalRefs=${this.totalRefs}`); + + // Start keepalive when the first reference is acquired. + if (this.totalRefs === 1) { + this.scheduleSync(); + } + + let released = false; + return () => { + if (released) return; + released = true; + + if (this.totalRefs > 0) { + this.totalRefs -= 1; + } + + const currentCount = this.refs.get(tag) ?? 0; + if (currentCount <= 1) { + this.refs.delete(tag); + } else { + this.refs.set(tag, currentCount - 1); + } + + this.logger.debug(`[OffscreenKeepalive] release(${tag}), totalRefs=${this.totalRefs}`); + + // Stop keepalive when the reference count drops to zero. + if (this.totalRefs === 0) { + this.scheduleSync(); + } + }; + } + + isActive(): boolean { + return this.totalRefs > 0; + } + + getRefCount(): number { + return this.totalRefs; + } + + releaseAll(): void { + if (this.totalRefs === 0) return; + + this.logger.debug('[OffscreenKeepalive] releaseAll()'); + this.refs.clear(); + this.totalRefs = 0; + this.scheduleSync(); + } + + /** + * Get the current reference counts grouped by tag. + * @description Useful for debugging. + */ + getRefsByTag(): Record { + return Object.fromEntries(this.refs); + } + + // ==================== Private Methods ==================== + + /** + * Schedule a sync operation. + * @description Serializes async operations to avoid races. + */ + private scheduleSync(): void { + this.syncPromise = this.syncPromise + .catch(() => { + // Ignore previous operation errors. + }) + .then(() => this.syncOnce()) + .catch((e) => { + this.logger.warn('[OffscreenKeepalive] sync failed:', e); + }); + } + + /** + * Perform a single sync step based on the current ref count. + */ + private async syncOnce(): Promise { + if (this.totalRefs > 0) { + // Ensure listener exists before Offscreen connects (race prevention). + this.ensureConnectionListener(); + + // Ensure the Offscreen document exists. + await offscreenManager.ensureOffscreenDocument(); + + // Re-check after await: state may have changed while we were creating the document. + if (this.totalRefs === 0) { + await this.teardown(); + return; + } + + // Send start command via runtime message (works even if Port is not connected). + await this.sendRuntimeControl('start'); + // Also send via Port if connected. + this.sendStartCommand(); + } else { + // Send stop via Port first (if connected). + this.sendStopCommand(); + // Then send via runtime message to ensure Offscreen stops. + await this.sendRuntimeControl('stop'); + await this.teardown(); + } + } + + /** + * Clean up resources. + */ + private async teardown(): Promise { + this.disconnectPort(); + // Note: We do not close the Offscreen Document here because it may be used by other modules. + // If Offscreen Document lifecycle needs ref-counting, it should be implemented in OffscreenManager. + } + + /** + * Ensure the Port connection listener is registered. + */ + private ensureConnectionListener(): void { + if (this.connectionListenerRegistered) return; + + if (typeof chrome === 'undefined' || !chrome.runtime?.onConnect) { + this.logger.warn('[OffscreenKeepalive] chrome.runtime.onConnect not available'); + return; + } + + chrome.runtime.onConnect.addListener(this.handleConnect); + this.connectionListenerRegistered = true; + + this.logger.debug('[OffscreenKeepalive] Connection listener registered'); + } + + /** + * Handle Port connections from Offscreen. + */ + private handleConnect = (port: chrome.runtime.Port): void => { + if (port.name !== RR_V3_KEEPALIVE_PORT_NAME) return; + + this.logger.debug('[OffscreenKeepalive] Offscreen connected'); + + // Store Port reference. + this.offscreenPort = port; + + // Listen to messages. + port.onMessage.addListener(this.handlePortMessage); + + // Listen to disconnect. + port.onDisconnect.addListener(() => { + this.logger.debug('[OffscreenKeepalive] Offscreen disconnected'); + if (this.offscreenPort === port) { + this.offscreenPort = null; + } + }); + + // If active, send the start command. + if (this.totalRefs > 0) { + this.sendStartCommand(); + } + }; + + /** + * Handle messages from Offscreen. + */ + private handlePortMessage = (msg: unknown): void => { + const m = msg as Partial | null; + if (!m || typeof m !== 'object') return; + + if (m.type === 'keepalive.ping') { + this.logger.debug('[OffscreenKeepalive] Received ping, sending pong'); + this.sendPong(); + } + }; + + /** + * Disconnect the Port. + */ + private disconnectPort(): void { + if (!this.offscreenPort) return; + + const port = this.offscreenPort; + this.offscreenPort = null; + + try { + port.disconnect(); + } catch { + // Port may already be disconnected. + } + + this.logger.debug('[OffscreenKeepalive] Port disconnected'); + } + + /** + * Send the start command to Offscreen (Port channel). + */ + private sendStartCommand(): void { + if (!this.offscreenPort) return; + + const msg: KeepaliveMessage = { + type: 'keepalive.start', + timestamp: Date.now(), + }; + + try { + this.offscreenPort.postMessage(msg); + this.logger.debug('[OffscreenKeepalive] Sent start command via Port'); + } catch (e) { + this.logger.warn('[OffscreenKeepalive] Failed to send start command:', e); + } + } + + /** + * Send the stop command to Offscreen (Port channel). + */ + private sendStopCommand(): void { + if (!this.offscreenPort) return; + + const msg: KeepaliveMessage = { + type: 'keepalive.stop', + timestamp: Date.now(), + }; + + try { + this.offscreenPort.postMessage(msg); + this.logger.debug('[OffscreenKeepalive] Sent stop command via Port'); + } catch (e) { + this.logger.warn('[OffscreenKeepalive] Failed to send stop command:', e); + } + } + + /** + * Send a pong response. + */ + private sendPong(): void { + if (!this.offscreenPort) return; + + const msg: KeepaliveMessage = { + type: 'keepalive.pong', + timestamp: Date.now(), + }; + + try { + this.offscreenPort.postMessage(msg); + } catch (e) { + this.logger.warn('[OffscreenKeepalive] Failed to send pong:', e); + } + } + + /** + * Send a runtime control command to Offscreen. + * This is the control plane used to start/stop keepalive even when the Port is not connected. + */ + private async sendRuntimeControl(command: KeepaliveControlCommand): Promise { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + this.logger.warn('[OffscreenKeepalive] chrome.runtime.sendMessage not available'); + return; + } + + const msg: KeepaliveControlMessage = { + type: KEEPALIVE_CONTROL_MESSAGE_TYPE, + command, + }; + + // Retry with delays for start command (Offscreen document may not be ready yet). + const delaysMs = command === 'start' ? [0, 50, 200] : [0]; + for (const delayMs of delaysMs) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + try { + await chrome.runtime.sendMessage(msg); + this.logger.debug(`[OffscreenKeepalive] Sent runtime ${command} command`); + return; + } catch { + // Best-effort: Offscreen document may not be ready yet. + } + } + + this.logger.warn(`[OffscreenKeepalive] Failed to send runtime ${command} command`); + } +} + +// ==================== Test Utilities ==================== + +/** + * In-memory keepalive controller. + * @description For tests only: tracks reference counts without using Offscreen. + */ +export class InMemoryKeepaliveController implements KeepaliveController { + private refs = new Map(); + + acquire(tag: string): () => void { + const count = this.refs.get(tag) ?? 0; + this.refs.set(tag, count + 1); + + let released = false; + return () => { + if (released) return; + released = true; + + const currentCount = this.refs.get(tag) ?? 0; + if (currentCount <= 1) { + this.refs.delete(tag); + } else { + this.refs.set(tag, currentCount - 1); + } + }; + } + + isActive(): boolean { + return this.refs.size > 0; + } + + getRefCount(): number { + let total = 0; + for (const count of this.refs.values()) { + total += count; + } + return total; + } + + releaseAll(): void { + this.refs.clear(); + } + + /** + * Get the current reference counts grouped by tag. + * @description Useful for debugging. + */ + getRefsByTag(): Record { + return Object.fromEntries(this.refs); + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts new file mode 100644 index 0000000..6cadb73 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts @@ -0,0 +1,193 @@ +/** + * @fileoverview 工件(Artifacts)接口 + * @description 定义截图等工件的获取和存储接口 + */ + +import type { NodeId, RunId } from '../../domain/ids'; +import type { RRError } from '../../domain/errors'; +import { RR_ERROR_CODES, createRRError } from '../../domain/errors'; + +/** + * 截图结果 + */ +export type ScreenshotResult = { ok: true; base64: string } | { ok: false; error: RRError }; + +/** + * 工件服务接口 + * @description 提供工件获取和存储功能 + */ +export interface ArtifactService { + /** + * 截取页面截图 + * @param tabId Tab ID + * @param options 截图选项 + */ + screenshot( + tabId: number, + options?: { + format?: 'png' | 'jpeg'; + quality?: number; + }, + ): Promise; + + /** + * 保存截图 + * @param runId Run ID + * @param nodeId Node ID + * @param base64 截图数据 + * @param filename 文件名(可选) + */ + saveScreenshot( + runId: RunId, + nodeId: NodeId, + base64: string, + filename?: string, + ): Promise<{ savedAs: string } | { error: RRError }>; +} + +/** + * 创建 NotImplemented 的 ArtifactService + * @description Phase 0-1 占位实现 + */ +export function createNotImplementedArtifactService(): ArtifactService { + return { + screenshot: async () => ({ + ok: false, + error: createRRError(RR_ERROR_CODES.INTERNAL, 'ArtifactService.screenshot not implemented'), + }), + saveScreenshot: async () => ({ + error: createRRError( + RR_ERROR_CODES.INTERNAL, + 'ArtifactService.saveScreenshot not implemented', + ), + }), + }; +} + +/** + * 创建基于 chrome.tabs.captureVisibleTab 的 ArtifactService + * @description 使用 Chrome API 截取可见标签页 + */ +export function createChromeArtifactService(): ArtifactService { + // In-memory storage for screenshots (could be replaced with IndexedDB) + const screenshotStore = new Map(); + + return { + screenshot: async (tabId, options) => { + try { + // Get the window ID for the tab + const tab = await chrome.tabs.get(tabId); + if (!tab.windowId) { + return { + ok: false, + error: createRRError(RR_ERROR_CODES.INTERNAL, `Tab ${tabId} has no window`), + }; + } + + // Capture the visible tab + const format = options?.format ?? 'png'; + const quality = options?.quality ?? 100; + + const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { + format, + quality: format === 'jpeg' ? quality : undefined, + }); + + // Extract base64 from data URL + const base64Match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/); + if (!base64Match) { + return { + ok: false, + error: createRRError(RR_ERROR_CODES.INTERNAL, 'Invalid screenshot data URL'), + }; + } + + return { ok: true, base64: base64Match[1] }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + ok: false, + error: createRRError(RR_ERROR_CODES.INTERNAL, `Screenshot failed: ${message}`), + }; + } + }, + + saveScreenshot: async (runId, nodeId, base64, filename) => { + try { + // Generate filename if not provided + const savedAs = filename ?? `${runId}_${nodeId}_${Date.now()}.png`; + const key = `${runId}/${savedAs}`; + + // Store in memory (in production, this would go to IndexedDB or cloud storage) + screenshotStore.set(key, base64); + + return { savedAs }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + error: createRRError(RR_ERROR_CODES.INTERNAL, `Save screenshot failed: ${message}`), + }; + } + }, + }; +} + +/** + * 工件策略执行器 + * @description 根据策略配置决定是否获取工件 + */ +export interface ArtifactPolicyExecutor { + /** + * 执行截图策略 + * @param policy 截图策略 + * @param context 上下文 + */ + executeScreenshotPolicy( + policy: 'never' | 'onFailure' | 'always', + context: { + tabId: number; + runId: RunId; + nodeId: NodeId; + failed: boolean; + saveAs?: string; + }, + ): Promise<{ captured: boolean; savedAs?: string; error?: RRError }>; +} + +/** + * 创建默认的工件策略执行器 + */ +export function createArtifactPolicyExecutor(service: ArtifactService): ArtifactPolicyExecutor { + return { + executeScreenshotPolicy: async (policy, context) => { + // 根据策略决定是否截图 + const shouldCapture = policy === 'always' || (policy === 'onFailure' && context.failed); + + if (!shouldCapture) { + return { captured: false }; + } + + // 截图 + const result = await service.screenshot(context.tabId); + if (!result.ok) { + return { captured: false, error: result.error }; + } + + // 保存(如果指定了文件名) + if (context.saveAs) { + const saveResult = await service.saveScreenshot( + context.runId, + context.nodeId, + result.base64, + context.saveAs, + ); + if ('error' in saveResult) { + return { captured: true, error: saveResult.error }; + } + return { captured: true, savedAs: saveResult.savedAs }; + } + + return { captured: true }; + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts new file mode 100644 index 0000000..53105a8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts @@ -0,0 +1,187 @@ +/** + * @fileoverview 断点管理器 + * @description 管理调试断点的添加、删除和命中检测 + */ + +import type { NodeId, RunId } from '../../domain/ids'; +import type { Breakpoint, DebuggerState } from '../../domain/debug'; + +/** + * 断点管理器 + * @description 管理单个 Run 的断点 + */ +export class BreakpointManager { + private breakpoints = new Map(); + private stepMode: 'none' | 'stepOver' = 'none'; + + constructor(initialBreakpoints?: NodeId[]) { + if (initialBreakpoints) { + for (const nodeId of initialBreakpoints) { + this.add(nodeId); + } + } + } + + /** + * 添加断点 + */ + add(nodeId: NodeId): void { + this.breakpoints.set(nodeId, { nodeId, enabled: true }); + } + + /** + * 删除断点 + */ + remove(nodeId: NodeId): void { + this.breakpoints.delete(nodeId); + } + + /** + * 设置断点列表(替换所有现有断点) + */ + setAll(nodeIds: NodeId[]): void { + this.breakpoints.clear(); + for (const nodeId of nodeIds) { + this.add(nodeId); + } + } + + /** + * 启用断点 + */ + enable(nodeId: NodeId): void { + const bp = this.breakpoints.get(nodeId); + if (bp) { + bp.enabled = true; + } + } + + /** + * 禁用断点 + */ + disable(nodeId: NodeId): void { + const bp = this.breakpoints.get(nodeId); + if (bp) { + bp.enabled = false; + } + } + + /** + * 检查节点是否有启用的断点 + */ + hasBreakpoint(nodeId: NodeId): boolean { + const bp = this.breakpoints.get(nodeId); + return bp?.enabled ?? false; + } + + /** + * 检查是否应该在节点处暂停 + * @description 考虑断点和单步模式 + */ + shouldPauseAt(nodeId: NodeId): boolean { + // 如果在单步模式,总是暂停 + if (this.stepMode === 'stepOver') { + return true; + } + // 否则检查断点 + return this.hasBreakpoint(nodeId); + } + + /** + * 获取所有断点 + */ + getAll(): Breakpoint[] { + return Array.from(this.breakpoints.values()); + } + + /** + * 获取启用的断点 + */ + getEnabled(): Breakpoint[] { + return this.getAll().filter((bp) => bp.enabled); + } + + /** + * 设置单步模式 + */ + setStepMode(mode: 'none' | 'stepOver'): void { + this.stepMode = mode; + } + + /** + * 获取单步模式 + */ + getStepMode(): 'none' | 'stepOver' { + return this.stepMode; + } + + /** + * 清除所有断点 + */ + clear(): void { + this.breakpoints.clear(); + this.stepMode = 'none'; + } +} + +/** + * 断点管理器注册表 + * @description 管理多个 Run 的断点管理器 + */ +export class BreakpointRegistry { + private managers = new Map(); + + /** + * 获取或创建断点管理器 + */ + getOrCreate(runId: RunId, initialBreakpoints?: NodeId[]): BreakpointManager { + let manager = this.managers.get(runId); + if (!manager) { + manager = new BreakpointManager(initialBreakpoints); + this.managers.set(runId, manager); + } + return manager; + } + + /** + * 获取断点管理器 + */ + get(runId: RunId): BreakpointManager | undefined { + return this.managers.get(runId); + } + + /** + * 删除断点管理器 + */ + remove(runId: RunId): void { + this.managers.delete(runId); + } + + /** + * 清空所有 + */ + clear(): void { + this.managers.clear(); + } +} + +/** 全局断点注册表 */ +let globalBreakpointRegistry: BreakpointRegistry | null = null; + +/** + * 获取全局断点注册表 + */ +export function getBreakpointRegistry(): BreakpointRegistry { + if (!globalBreakpointRegistry) { + globalBreakpointRegistry = new BreakpointRegistry(); + } + return globalBreakpointRegistry; +} + +/** + * 重置全局断点注册表 + * @description 主要用于测试 + */ +export function resetBreakpointRegistry(): void { + globalBreakpointRegistry = null; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts new file mode 100644 index 0000000..1200e30 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts @@ -0,0 +1,485 @@ +/** + * @fileoverview Debug Controller + * @description Central control plane for debugging - command routing, state aggregation, and UI push + */ + +import type { NodeId, RunId } from '../../domain/ids'; +import type { JsonValue } from '../../domain/json'; +import type { PauseReason, RunEvent, Unsubscribe } from '../../domain/events'; +import type { + DebuggerCommand, + DebuggerResponse, + DebuggerState, + Breakpoint, +} from '../../domain/debug'; +import { createInitialDebuggerState } from '../../domain/debug'; + +import type { StoragePort } from '../storage/storage-port'; +import type { EventsBus } from '../transport/events-bus'; +import type { RunRunner } from './runner'; +import { BreakpointManager, getBreakpointRegistry } from './breakpoints'; + +/** + * Runner registry for managing active runners + */ +export interface RunnerRegistry { + get(runId: RunId): RunRunner | undefined; + register(runId: RunId, runner: RunRunner): void; + unregister(runId: RunId): void; + list(): RunId[]; +} + +/** + * Create a simple runner registry + */ +export function createRunnerRegistry(): RunnerRegistry { + const runners = new Map(); + return { + get: (runId) => runners.get(runId), + register: (runId, runner) => runners.set(runId, runner), + unregister: (runId) => runners.delete(runId), + list: () => Array.from(runners.keys()), + }; +} + +/** + * Debug session state (per-run) + */ +interface DebugSession { + runId: RunId; + attached: boolean; + lastPauseReason?: PauseReason; + lastKnownNodeId?: NodeId; + lastKnownExecution: 'running' | 'paused'; +} + +/** + * Debug state listener + */ +type DebugStateListener = (state: DebuggerState) => void; + +/** + * Debug Controller Configuration + */ +export interface DebugControllerConfig { + storage: StoragePort; + events: EventsBus; + runners: RunnerRegistry; +} + +/** + * Debug Controller + * @description Single entry point for all debug operations + */ +export class DebugController { + private readonly storage: StoragePort; + private readonly events: EventsBus; + private readonly runners: RunnerRegistry; + + private readonly sessions = new Map(); + private readonly listeners = new Map>(); + private eventUnsubscribe: Unsubscribe | null = null; + + constructor(config: DebugControllerConfig) { + this.storage = config.storage; + this.events = config.events; + this.runners = config.runners; + } + + /** + * Start the debug controller + */ + start(): void { + // Subscribe to all events to track pause/resume state + this.eventUnsubscribe = this.events.subscribe((event) => { + this.handleEvent(event); + }); + } + + /** + * Stop the debug controller + */ + stop(): void { + if (this.eventUnsubscribe) { + this.eventUnsubscribe(); + this.eventUnsubscribe = null; + } + this.sessions.clear(); + this.listeners.clear(); + } + + /** + * Handle a debug command + */ + async handle(cmd: DebuggerCommand): Promise { + try { + switch (cmd.type) { + case 'debug.attach': + return this.handleAttach(cmd.runId); + + case 'debug.detach': + return this.handleDetach(cmd.runId); + + case 'debug.pause': + return this.handlePause(cmd.runId); + + case 'debug.resume': + return this.handleResume(cmd.runId); + + case 'debug.stepOver': + return this.handleStepOver(cmd.runId); + + case 'debug.setBreakpoints': + return this.handleSetBreakpoints(cmd.runId, cmd.nodeIds); + + case 'debug.addBreakpoint': + return this.handleAddBreakpoint(cmd.runId, cmd.nodeId); + + case 'debug.removeBreakpoint': + return this.handleRemoveBreakpoint(cmd.runId, cmd.nodeId); + + case 'debug.getState': + return this.handleGetState(cmd.runId); + + case 'debug.getVar': + return this.handleGetVar(cmd.runId, cmd.name); + + case 'debug.setVar': + return this.handleSetVar(cmd.runId, cmd.name, cmd.value); + + default: + return { ok: false, error: `Unknown debug command: ${(cmd as { type: string }).type}` }; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { ok: false, error: message }; + } + } + + /** + * Subscribe to debug state changes + */ + subscribe(listener: DebugStateListener, filter?: { runId?: RunId }): Unsubscribe { + const key = filter?.runId ?? null; + let set = this.listeners.get(key); + if (!set) { + set = new Set(); + this.listeners.set(key, set); + } + set.add(listener); + + return () => { + set?.delete(listener); + if (set?.size === 0) { + this.listeners.delete(key); + } + }; + } + + /** + * Get current debug state for a run + */ + async getState(runId: RunId): Promise { + const session = this.sessions.get(runId); + const run = await this.storage.runs.get(runId); + const bpManager = getBreakpointRegistry().get(runId); + + const state: DebuggerState = { + runId, + status: session?.attached ? 'attached' : 'detached', + execution: session?.lastKnownExecution ?? (run?.status === 'paused' ? 'paused' : 'running'), + pauseReason: session?.lastPauseReason, + currentNodeId: session?.lastKnownNodeId ?? run?.currentNodeId, + breakpoints: bpManager?.getAll() ?? [], + stepMode: bpManager?.getStepMode() ?? 'none', + }; + + return state; + } + + // ==================== Command Handlers ==================== + + private async handleAttach(runId: RunId): Promise { + const run = await this.storage.runs.get(runId); + if (!run) { + return { ok: false, error: `Run "${runId}" not found` }; + } + + // Create or update session + let session = this.sessions.get(runId); + if (!session) { + session = { + runId, + attached: true, + lastKnownExecution: run.status === 'paused' ? 'paused' : 'running', + lastKnownNodeId: run.currentNodeId, + }; + this.sessions.set(runId, session); + } else { + session.attached = true; + } + + // Get or create breakpoint manager + getBreakpointRegistry().getOrCreate(runId, run.debug?.breakpoints); + + const state = await this.getState(runId); + this.notifyStateChange(runId, state); + return { ok: true, state }; + } + + private async handleDetach(runId: RunId): Promise { + const session = this.sessions.get(runId); + if (session) { + session.attached = false; + } + + const state = await this.getState(runId); + this.notifyStateChange(runId, state); + return { ok: true, state }; + } + + private async handlePause(runId: RunId): Promise { + const runner = this.runners.get(runId); + if (!runner) { + return { ok: false, error: `Runner for "${runId}" not found` }; + } + + runner.pause(); + const state = await this.getState(runId); + return { ok: true, state }; + } + + private async handleResume(runId: RunId): Promise { + const runner = this.runners.get(runId); + if (!runner) { + return { ok: false, error: `Runner for "${runId}" not found` }; + } + + runner.resume(); + const state = await this.getState(runId); + return { ok: true, state }; + } + + private async handleStepOver(runId: RunId): Promise { + const runner = this.runners.get(runId); + if (!runner) { + return { ok: false, error: `Runner for "${runId}" not found` }; + } + + // Set step mode to stepOver (will pause at next node) + const bpManager = getBreakpointRegistry().getOrCreate(runId); + bpManager.setStepMode('stepOver'); + + // Resume execution - runner will pause at next node due to stepOver mode + runner.resume(); + + const state = await this.getState(runId); + return { ok: true, state }; + } + + private async handleSetBreakpoints(runId: RunId, nodeIds: NodeId[]): Promise { + const bpManager = getBreakpointRegistry().getOrCreate(runId); + bpManager.setAll(nodeIds); + + // Persist breakpoints to run record + await this.persistBreakpoints(runId, bpManager); + + const state = await this.getState(runId); + this.notifyStateChange(runId, state); + return { ok: true, state }; + } + + private async handleAddBreakpoint(runId: RunId, nodeId: NodeId): Promise { + const bpManager = getBreakpointRegistry().getOrCreate(runId); + bpManager.add(nodeId); + + await this.persistBreakpoints(runId, bpManager); + + const state = await this.getState(runId); + this.notifyStateChange(runId, state); + return { ok: true, state }; + } + + private async handleRemoveBreakpoint(runId: RunId, nodeId: NodeId): Promise { + const bpManager = getBreakpointRegistry().getOrCreate(runId); + bpManager.remove(nodeId); + + await this.persistBreakpoints(runId, bpManager); + + const state = await this.getState(runId); + this.notifyStateChange(runId, state); + return { ok: true, state }; + } + + private async handleGetState(runId: RunId): Promise { + const state = await this.getState(runId); + return { ok: true, state }; + } + + private async handleGetVar(runId: RunId, name: string): Promise { + // Try to get from active runner first + const runner = this.runners.get(runId); + if (runner) { + const value = runner.getVar(name); + return { ok: true, value: value ?? null }; + } + + // Fallback: reconstruct from events + const value = await this.reconstructVar(runId, name); + return { ok: true, value: value ?? null }; + } + + private async handleSetVar( + runId: RunId, + name: string, + value: JsonValue, + ): Promise { + const runner = this.runners.get(runId); + if (!runner) { + return { + ok: false, + error: `Runner for "${runId}" not found - cannot set variable on inactive run`, + }; + } + + runner.setVar(name, value); + return { ok: true }; + } + + // ==================== Event Handling ==================== + + private handleEvent(event: RunEvent): void { + const { runId } = event; + let session = this.sessions.get(runId); + + // Track pause/resume state + if (event.type === 'run.paused') { + if (!session) { + session = { + runId, + attached: false, + lastKnownExecution: 'paused', + }; + this.sessions.set(runId, session); + } + session.lastKnownExecution = 'paused'; + session.lastPauseReason = event.reason; + session.lastKnownNodeId = event.nodeId; + } else if (event.type === 'run.resumed') { + if (session) { + session.lastKnownExecution = 'running'; + session.lastPauseReason = undefined; + } + } else if (event.type === 'run.started') { + if (!session) { + session = { + runId, + attached: false, + lastKnownExecution: 'running', + }; + this.sessions.set(runId, session); + } + } else if ( + event.type === 'run.succeeded' || + event.type === 'run.failed' || + event.type === 'run.canceled' + ) { + // Run ended - keep session for querying but mark as not running + if (session) { + session.lastKnownExecution = 'running'; // Technically ended, but not paused + } + } else if (event.type === 'node.started') { + if (session) { + session.lastKnownNodeId = event.nodeId; + } + } + + // Notify listeners if session is attached + if (session?.attached) { + void this.getState(runId).then((state) => { + this.notifyStateChange(runId, state); + }); + } + } + + // ==================== Helpers ==================== + + private async persistBreakpoints(runId: RunId, bpManager: BreakpointManager): Promise { + const breakpoints = bpManager.getEnabled().map((bp) => bp.nodeId); + try { + await this.storage.runs.patch(runId, { + debug: { breakpoints }, + }); + } catch { + // Run may not exist yet - ignore persistence error + } + } + + private async reconstructVar(runId: RunId, name: string): Promise { + // Get flow and run to reconstruct initial vars + const run = await this.storage.runs.get(runId); + if (!run) return undefined; + + const flow = await this.storage.flows.get(run.flowId); + if (!flow) return undefined; + + // Build initial vars + const vars: Record = { ...(run.args ?? {}) }; + for (const def of flow.variables ?? []) { + if (vars[def.name] === undefined && def.default !== undefined) { + vars[def.name] = def.default; + } + } + + // Apply all vars.patch events + const events = await this.storage.events.list(runId); + for (const event of events) { + if (event.type === 'vars.patch') { + for (const op of event.patch) { + if (op.op === 'set') { + vars[op.name] = op.value ?? null; + } else { + delete vars[op.name]; + } + } + } + } + + return vars[name]; + } + + private notifyStateChange(runId: RunId, state: DebuggerState): void { + // Notify specific run listeners + const runListeners = this.listeners.get(runId); + if (runListeners) { + for (const listener of runListeners) { + try { + listener(state); + } catch (e) { + console.error('[DebugController] Listener error:', e); + } + } + } + + // Notify global listeners + const globalListeners = this.listeners.get(null); + if (globalListeners) { + for (const listener of globalListeners) { + try { + listener(state); + } catch (e) { + console.error('[DebugController] Listener error:', e); + } + } + } + } +} + +/** + * Create and start a debug controller + */ +export function createDebugController(config: DebugControllerConfig): DebugController { + const controller = new DebugController(config); + controller.start(); + return controller; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts new file mode 100644 index 0000000..94fba51 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts @@ -0,0 +1,11 @@ +/** + * @fileoverview Kernel 模块导出入口 + */ + +export * from './kernel'; +export * from './runner'; +export * from './traversal'; +export * from './breakpoints'; +export * from './artifacts'; +export * from './debug-controller'; +export * from './recovery-kernel'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts new file mode 100644 index 0000000..497be25 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview ExecutionKernel 接口定义 + * @description 定义 Record-Replay V3 的核心执行引擎接口 + */ + +import type { JsonObject } from '../../domain/json'; +import type { FlowId, NodeId, RunId } from '../../domain/ids'; +import type { RRError } from '../../domain/errors'; +import type { FlowV3 } from '../../domain/flow'; +import type { DebuggerCommand, DebuggerState } from '../../domain/debug'; +import type { RunEvent, RunStatus, Unsubscribe } from '../../domain/events'; + +/** + * Run 启动请求 + */ +export interface RunStartRequest { + /** Run ID(由调用方生成) */ + runId: RunId; + /** Flow ID */ + flowId: FlowId; + /** Flow 快照(执行时使用的完整 Flow 定义) */ + flowSnapshot: FlowV3; + /** 运行参数 */ + args?: JsonObject; + /** 起始节点 ID(默认为 Flow 的 entryNodeId) */ + startNodeId?: NodeId; + /** Tab ID(必须由调用方分配,每 Run 独占) */ + tabId: number; + /** 调试配置 */ + debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean }; +} + +/** + * Run 执行结果 + */ +export interface RunResult { + /** Run ID */ + runId: RunId; + /** 最终状态 */ + status: Extract; + /** 总耗时(毫秒) */ + tookMs: number; + /** 错误信息(如果失败) */ + error?: RRError; + /** 输出结果 */ + outputs?: JsonObject; +} + +/** + * Run 状态查询结果 + */ +export interface RunStatusInfo { + /** 当前状态 */ + status: RunStatus; + /** 当前节点 ID */ + currentNodeId?: NodeId; + /** 开始时间 */ + startedAt?: number; + /** 最后更新时间 */ + updatedAt: number; + /** Tab ID */ + tabId?: number; +} + +/** + * ExecutionKernel 接口 + * @description Record-Replay V3 的核心执行引擎 + */ +export interface ExecutionKernel { + /** + * 订阅事件流 + * @param listener 事件监听器 + * @returns 取消订阅函数 + */ + onEvent(listener: (event: RunEvent) => void): Unsubscribe; + + /** + * 启动 Run + * @description 将 Run 加入队列并开始执行 + */ + startRun(req: RunStartRequest): Promise; + + /** + * 暂停 Run + * @param runId Run ID + * @param reason 暂停原因 + */ + pauseRun(runId: RunId, reason?: { kind: 'command' }): Promise; + + /** + * 恢复 Run + * @param runId Run ID + */ + resumeRun(runId: RunId): Promise; + + /** + * 取消 Run + * @param runId Run ID + * @param reason 取消原因 + */ + cancelRun(runId: RunId, reason?: string): Promise; + + /** + * 执行调试命令 + * @param runId Run ID + * @param cmd 调试命令 + */ + debug( + runId: RunId, + cmd: DebuggerCommand, + ): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }>; + + /** + * 获取 Run 状态 + * @param runId Run ID + * @returns Run 状态信息或 null(如果不存在) + */ + getRunStatus(runId: RunId): Promise; + + /** + * 恢复执行 + * @description 在 Service Worker 重启后调用,恢复中断的 Run + */ + recover(): Promise; +} + +/** + * 创建 NotImplemented 的 ExecutionKernel + * @description Phase 0 占位实现 + */ +export function createNotImplementedKernel(): ExecutionKernel { + const notImplemented = () => { + throw new Error('ExecutionKernel not implemented'); + }; + + return { + onEvent: () => { + notImplemented(); + return () => {}; + }, + startRun: async () => notImplemented(), + pauseRun: async () => notImplemented(), + resumeRun: async () => notImplemented(), + cancelRun: async () => notImplemented(), + debug: async () => notImplemented(), + getRunStatus: async () => notImplemented(), + recover: async () => notImplemented(), + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts new file mode 100644 index 0000000..ccbcb9b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts @@ -0,0 +1,97 @@ +/** + * @fileoverview 支持崩溃恢复的 ExecutionKernel 实现 (P3-06) + * @description + * 提供 ExecutionKernel 的恢复增强实现,支持 `recover()` 方法。 + * 通过委托给 RecoveryCoordinator 实现崩溃恢复。 + * + * 其他执行方法(startRun, pauseRun 等)暂未实现,将在后续阶段完成。 + */ + +import type { UnixMillis } from '../../domain/json'; +import type { RunId } from '../../domain/ids'; +import type { DebuggerCommand, DebuggerState } from '../../domain/debug'; + +import type { StoragePort } from '../storage/storage-port'; +import type { EventsBus } from '../transport/events-bus'; +import { recoverFromCrash } from '../recovery/recovery-coordinator'; + +import type { ExecutionKernel, RunStartRequest, RunStatusInfo } from './kernel'; + +// ==================== Types ==================== + +/** + * 支持恢复的 Kernel 依赖 + */ +export interface RecoveryEnabledKernelDeps { + /** 存储层 */ + storage: StoragePort; + /** 事件总线 */ + events: EventsBus; + /** 当前 Service Worker 的 ownerId */ + ownerId: string; + /** 时间源 */ + now?: () => UnixMillis; + /** 日志器 */ + logger?: Pick; +} + +// ==================== Factory ==================== + +/** + * 创建支持恢复的 ExecutionKernel + * @description + * 此实现仅支持 `recover()` 和 `getRunStatus()` 方法。 + * 其他执行方法暂未实现,将在后续阶段完成。 + */ +export function createRecoveryEnabledKernel(deps: RecoveryEnabledKernelDeps): ExecutionKernel { + const logger = deps.logger ?? console; + const now = deps.now ?? (() => Date.now()); + + if (!deps.ownerId) { + throw new Error('ownerId is required'); + } + + const notImplemented = (name: string): never => { + throw new Error(`ExecutionKernel.${name} not implemented`); + }; + + return { + onEvent: (listener) => deps.events.subscribe(listener), + + startRun: async (_req: RunStartRequest) => notImplemented('startRun'), + pauseRun: async (_runId: RunId) => notImplemented('pauseRun'), + resumeRun: async (_runId: RunId) => notImplemented('resumeRun'), + cancelRun: async (_runId: RunId) => notImplemented('cancelRun'), + + debug: async ( + _runId: RunId, + _cmd: DebuggerCommand, + ): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }> => { + return { ok: false, error: 'ExecutionKernel.debug not configured' }; + }, + + getRunStatus: async (runId: RunId): Promise => { + const run = await deps.storage.runs.get(runId); + if (!run) return null; + return { + status: run.status, + currentNodeId: run.currentNodeId, + startedAt: run.startedAt, + updatedAt: run.updatedAt, + tabId: run.tabId, + }; + }, + + recover: async (): Promise => { + logger.info('[RecoveryKernel] Starting crash recovery...'); + const result = await recoverFromCrash({ + storage: deps.storage, + events: deps.events, + ownerId: deps.ownerId, + now, + logger, + }); + logger.info('[RecoveryKernel] Recovery complete:', result); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts new file mode 100644 index 0000000..9f0042e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts @@ -0,0 +1,893 @@ +/** + * @fileoverview RunRunner 接口和实现 + * @description 定义和实现单个 Run 的顺序执行器 + */ + +import type { NodeId, RunId } from '../../domain/ids'; +import { EDGE_LABELS } from '../../domain/ids'; +import type { FlowV3, NodeV3 } from '../../domain/flow'; +import { findNodeById } from '../../domain/flow'; +import type { + PauseReason, + RunEvent, + RunEventInput, + RunRecordV3, + Unsubscribe, +} from '../../domain/events'; +import { RUN_SCHEMA_VERSION } from '../../domain/events'; +import type { JsonObject, JsonValue } from '../../domain/json'; +import { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors'; +import type { NodePolicy, RetryPolicy } from '../../domain/policy'; +import { mergeNodePolicy } from '../../domain/policy'; + +import type { EventsBus } from '../transport/events-bus'; +import type { StoragePort } from '../storage/storage-port'; +import type { PluginRegistry } from '../plugins/registry'; +import { getPluginRegistry } from '../plugins/registry'; +import type { NodeExecutionContext, NodeExecutionResult, VarsPatchOp } from '../plugins/types'; + +import type { ArtifactService } from './artifacts'; +import { createNotImplementedArtifactService } from './artifacts'; +import { getBreakpointRegistry, type BreakpointManager } from './breakpoints'; +import { findEdgeByLabel, findNextNode, validateFlowDAG } from './traversal'; +import type { RunResult } from './kernel'; + +// ==================== Types ==================== + +/** + * RunRunner 运行时状态 + */ +export interface RunnerRuntimeState { + /** Run ID */ + runId: RunId; + /** 当前节点 ID */ + currentNodeId: NodeId | null; + /** 当前尝试次数 */ + attempt: number; + /** 变量表 */ + vars: Record; + /** 是否暂停 */ + paused: boolean; + /** 是否取消 */ + canceled: boolean; +} + +/** + * RunRunner 配置 + */ +export interface RunnerConfig { + /** Flow 快照 */ + flow: FlowV3; + /** Tab ID */ + tabId: number; + /** 初始参数 */ + args?: JsonObject; + /** 起始节点 ID */ + startNodeId?: NodeId; + /** 调试配置 */ + debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean }; +} + +/** + * RunRunner 接口 + */ +export interface RunRunner { + /** Run ID */ + readonly runId: RunId; + /** 当前状态 */ + readonly state: RunnerRuntimeState; + /** 订阅事件 */ + onEvent(listener: (event: RunEvent) => void): Unsubscribe; + /** 开始执行 */ + start(): Promise; + /** 暂停执行 */ + pause(): void; + /** 恢复执行 */ + resume(): void; + /** 取消执行 */ + cancel(reason?: string): void; + /** 获取变量值 */ + getVar(name: string): JsonValue | undefined; + /** 设置变量值 */ + setVar(name: string, value: JsonValue): void; +} + +/** + * RunRunner 工厂接口 + */ +export interface RunRunnerFactory { + create(runId: RunId, config: RunnerConfig): RunRunner; +} + +/** + * RunRunner 工厂依赖 + */ +export interface RunRunnerFactoryDeps { + storage: StoragePort; + events: EventsBus; + plugins?: PluginRegistry; + artifactService?: ArtifactService; + now?: () => number; +} + +// ==================== Helpers ==================== + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === 'object' && 'message' in err) + return String((err as { message: unknown }).message); + return String(err); +} + +async function withTimeout( + p: Promise, + ms: number | undefined, + onTimeout: () => RRError, +): Promise { + if (ms === undefined || !Number.isFinite(ms) || ms <= 0) { + return p; + } + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(onTimeout()), ms); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +function computeRetryDelayMs(policy: RetryPolicy, attempt: number): number { + const base = Math.max(0, policy.intervalMs); + let delay = base; + const backoff = policy.backoff ?? 'none'; + + if (backoff === 'linear') { + delay = base * attempt; + } else if (backoff === 'exp') { + delay = base * Math.pow(2, Math.max(0, attempt - 1)); + } + + if (policy.maxIntervalMs !== undefined) { + delay = Math.min(delay, Math.max(0, policy.maxIntervalMs)); + } + + if (policy.jitter === 'full') { + delay = Math.floor(Math.random() * (delay + 1)); + } + + return Math.max(0, Math.floor(delay)); +} + +function applyVarsPatch(vars: Record, patch: VarsPatchOp[]): void { + for (const op of patch) { + if (op.op === 'set') { + vars[op.name] = op.value ?? null; + } else { + delete vars[op.name]; + } + } +} + +function toRRError(err: unknown, fallback: { code: string; message: string }): RRError { + if (err && typeof err === 'object' && 'code' in err && 'message' in err) { + return err as RRError; + } + return createRRError( + fallback.code as RRError['code'], + `${fallback.message}: ${errorMessage(err)}`, + ); +} + +/** + * Serial queue for write operations + * Ensures event ordering and reduces write races + */ +class SerialQueue { + private tail: Promise = Promise.resolve(); + + run(fn: () => Promise): Promise { + const next = this.tail.then(fn, fn); + this.tail = next.then( + () => undefined, + () => undefined, + ); + return next; + } +} + +// ==================== Factory ==================== + +/** + * 创建 NotImplemented 的 RunRunnerFactory + */ +export function createNotImplementedRunnerFactory(): RunRunnerFactory { + return { + create: () => { + throw new Error('RunRunnerFactory not implemented'); + }, + }; +} + +/** + * 创建 RunRunner 工厂 + */ +export function createRunRunnerFactory(deps: RunRunnerFactoryDeps): RunRunnerFactory { + const plugins = deps.plugins ?? getPluginRegistry(); + const artifactService = deps.artifactService ?? createNotImplementedArtifactService(); + const now = deps.now ?? Date.now; + + return { + create: (runId, config) => + new StorageBackedRunRunner(runId, config, { + storage: deps.storage, + events: deps.events, + plugins, + artifactService, + now, + }), + }; +} + +// ==================== Implementation ==================== + +interface RunnerEnv { + storage: StoragePort; + events: EventsBus; + plugins: PluginRegistry; + artifactService: ArtifactService; + now: () => number; +} + +type OnErrorDecision = + | { kind: 'stop' } + | { kind: 'continue' } + | { + kind: 'goto'; + target: { kind: 'edgeLabel'; label: string } | { kind: 'node'; nodeId: NodeId }; + } + | { kind: 'retry'; retryPolicy: RetryPolicy | null }; + +type NodeRunResult = + | { nextNodeId: NodeId | null } + | { terminal: 'failed'; error: RRError } + | { terminal: 'canceled' }; + +/** + * Storage-backed RunRunner implementation + */ +class StorageBackedRunRunner implements RunRunner { + readonly runId: RunId; + readonly state: RunnerRuntimeState; + + private readonly config: RunnerConfig; + private readonly env: RunnerEnv; + private readonly queue = new SerialQueue(); + private readonly breakpoints: BreakpointManager; + + private startPromise: Promise | null = null; + private outputs: JsonObject = {}; + private cancelReason: string | undefined; + private pauseWaiter: Deferred | null = null; + + constructor(runId: RunId, config: RunnerConfig, env: RunnerEnv) { + this.runId = runId; + this.config = config; + this.env = env; + + this.state = { + runId, + currentNodeId: null, + attempt: 0, + vars: this.buildInitialVars(), + paused: false, + canceled: false, + }; + + this.breakpoints = getBreakpointRegistry().getOrCreate(runId, config.debug?.breakpoints); + } + + onEvent(listener: (event: RunEvent) => void): Unsubscribe { + return this.env.events.subscribe(listener, { runId: this.runId }); + } + + start(): Promise { + if (!this.startPromise) { + this.startPromise = this.run(); + } + return this.startPromise; + } + + pause(): void { + this.requestPause({ kind: 'command' }); + } + + resume(): void { + if (!this.state.paused) return; + this.state.paused = false; + this.pauseWaiter?.resolve(undefined); + this.pauseWaiter = null; + + void this.queue + .run(async () => { + await this.env.storage.runs.patch(this.runId, { status: 'running' }); + await this.env.events.append({ runId: this.runId, type: 'run.resumed' } as RunEventInput); + }) + .catch((e) => { + console.error('[RunRunner] resume persistence failed:', e); + }); + } + + cancel(reason?: string): void { + if (this.state.canceled) return; + this.state.canceled = true; + this.cancelReason = reason; + + if (this.state.paused) { + this.state.paused = false; + this.pauseWaiter?.resolve(undefined); + this.pauseWaiter = null; + } + } + + getVar(name: string): JsonValue | undefined { + return this.state.vars[name]; + } + + setVar(name: string, value: JsonValue): void { + this.state.vars[name] = value; + + // Best-effort: emit vars.patch event + void this.queue + .run(() => + this.env.events.append({ + runId: this.runId, + type: 'vars.patch', + patch: [{ op: 'set', name, value }], + } as RunEventInput), + ) + .catch(() => {}); + } + + // ==================== Private Methods ==================== + + private buildInitialVars(): Record { + const vars: Record = { ...(this.config.args ?? {}) }; + for (const def of this.config.flow.variables ?? []) { + if (vars[def.name] === undefined && def.default !== undefined) { + vars[def.name] = def.default; + } + } + return vars; + } + + private requestPause(reason: PauseReason): void { + if (this.state.canceled) return; + if (this.state.paused) return; + + this.state.paused = true; + if (!this.pauseWaiter) { + this.pauseWaiter = createDeferred(); + } + + const nodeId = this.state.currentNodeId ?? undefined; + void this.queue + .run(async () => { + await this.env.storage.runs.patch(this.runId, { + status: 'paused', + ...(nodeId ? { currentNodeId: nodeId } : {}), + }); + await this.env.events.append({ + runId: this.runId, + type: 'run.paused', + reason, + ...(nodeId ? { nodeId } : {}), + } as RunEventInput); + }) + .catch((e) => { + console.error('[RunRunner] pause persistence failed:', e); + }); + } + + private async waitIfPaused(): Promise { + while (this.state.paused && !this.state.canceled) { + if (!this.pauseWaiter) { + this.pauseWaiter = createDeferred(); + } + await this.pauseWaiter.promise; + } + } + + private async ensureRunRecord(startNodeId: NodeId, startedAt: number): Promise { + await this.queue.run(async () => { + const existing = await this.env.storage.runs.get(this.runId); + if (!existing) { + const record: RunRecordV3 = { + schemaVersion: RUN_SCHEMA_VERSION, + id: this.runId, + flowId: this.config.flow.id, + status: 'running', + createdAt: startedAt, + updatedAt: startedAt, + startedAt, + tabId: this.config.tabId, + startNodeId: this.config.startNodeId, + currentNodeId: startNodeId, + attempt: 0, + maxAttempts: 1, + args: this.config.args, + debug: this.config.debug, + nextSeq: 1, + }; + await this.env.storage.runs.save(record); + return; + } + + if (!Number.isSafeInteger(existing.nextSeq) || existing.nextSeq < 0) { + throw createRRError( + RR_ERROR_CODES.INVARIANT_VIOLATION, + `Invalid nextSeq for run "${this.runId}": ${String(existing.nextSeq)}`, + ); + } + + const patch: Partial = { + status: 'running', + tabId: this.config.tabId, + currentNodeId: startNodeId, + }; + if (existing.startedAt === undefined) patch.startedAt = startedAt; + if (this.config.startNodeId !== undefined) patch.startNodeId = this.config.startNodeId; + if (this.config.args !== undefined) patch.args = this.config.args; + if (this.config.debug !== undefined) patch.debug = this.config.debug; + await this.env.storage.runs.patch(this.runId, patch); + }); + } + + private async run(): Promise { + const startedAt = this.env.now(); + const { flow } = this.config; + + const startNodeId = (this.config.startNodeId ?? flow.entryNodeId) as NodeId; + + // Ensure Run record exists FIRST (before DAG validation) + // so that finishFailed can safely patch the record + await this.ensureRunRecord(startNodeId, startedAt); + + // Validate DAG + const validation = validateFlowDAG(flow); + if (!validation.ok) { + const error = + validation.errors[0] ?? createRRError(RR_ERROR_CODES.DAG_INVALID, 'Invalid DAG'); + return this.finishFailed(startedAt, error, undefined); + } + + if (this.state.canceled) { + return this.finishCanceled(startedAt); + } + + // Emit run.started + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'run.started', + flowId: flow.id, + tabId: this.config.tabId, + } as RunEventInput), + ); + + // Handle pauseOnStart + if (this.config.debug?.pauseOnStart) { + this.requestPause({ kind: 'policy', nodeId: startNodeId, reason: 'pauseOnStart' }); + } + + // Main execution loop + let currentNodeId: NodeId | null = startNodeId; + while (currentNodeId) { + this.state.currentNodeId = currentNodeId; + + // Only update currentNodeId, not status (to preserve paused state) + const nodeIdToUpdate = currentNodeId; // Capture for closure + await this.queue.run(() => + this.env.storage.runs.patch(this.runId, { currentNodeId: nodeIdToUpdate }), + ); + + if (this.state.canceled) break; + await this.waitIfPaused(); + if (this.state.canceled) break; + + const node = findNodeById(flow, currentNodeId); + if (!node) { + const error = createRRError( + RR_ERROR_CODES.DAG_INVALID, + `Node "${currentNodeId}" not found in flow`, + ); + return this.finishFailed(startedAt, error, currentNodeId); + } + + // Skip disabled nodes + if (node.disabled) { + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'node.skipped', + nodeId: node.id, + reason: 'disabled', + } as RunEventInput), + ); + currentNodeId = findNextNode(flow, node.id); + continue; + } + + // Check breakpoints + if (this.breakpoints.shouldPauseAt(node.id)) { + const reason: PauseReason = + this.breakpoints.getStepMode() === 'stepOver' + ? { kind: 'step', nodeId: node.id } + : { kind: 'breakpoint', nodeId: node.id }; + + // Clear step mode after hitting (to avoid infinite pause loop) + if (this.breakpoints.getStepMode() === 'stepOver') { + this.breakpoints.setStepMode('none'); + } + + this.requestPause(reason); + await this.waitIfPaused(); + // After resume, proceed to execute the node (don't continue loop) + } + + // Emit node.queued + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'node.queued', + nodeId: node.id, + } as RunEventInput), + ); + + // Execute node + const nodeStartAt = this.env.now(); + const next = await this.runNode(flow, node, nodeStartAt); + if ('terminal' in next) { + if (next.terminal === 'canceled') break; + if (next.terminal === 'failed') { + return this.finishFailed(startedAt, next.error, node.id); + } + break; + } + + currentNodeId = next.nextNodeId; + } + + if (this.state.canceled) { + return this.finishCanceled(startedAt); + } + + return this.finishSucceeded(startedAt); + } + + private async runNode(flow: FlowV3, node: NodeV3, nodeStartAt: number): Promise { + let attempt = 1; + + for (;;) { + if (this.state.canceled) return { terminal: 'canceled' }; + await this.waitIfPaused(); + if (this.state.canceled) return { terminal: 'canceled' }; + + this.state.attempt = attempt; + + // Emit node.started + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'node.started', + nodeId: node.id, + attempt, + } as RunEventInput), + ); + + const exec = await this.executeNodeAttempt(flow, node); + if (exec.status === 'succeeded') { + const tookMs = this.env.now() - nodeStartAt; + + // Apply vars patch + if (exec.varsPatch && exec.varsPatch.length > 0) { + applyVarsPatch(this.state.vars, exec.varsPatch); + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'vars.patch', + patch: exec.varsPatch, + } as RunEventInput), + ); + } + + // Merge outputs + if (exec.outputs) { + this.outputs = { ...this.outputs, ...exec.outputs }; + } + + // Emit node.succeeded + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'node.succeeded', + nodeId: node.id, + tookMs, + ...(exec.next ? { next: exec.next } : {}), + } as RunEventInput), + ); + + if (exec.next?.kind === 'end') { + return { nextNodeId: null }; + } + + const label = exec.next?.kind === 'edgeLabel' ? exec.next.label : undefined; + return { nextNodeId: findNextNode(flow, node.id, label) }; + } + + // Handle failure + const error = exec.error; + const policy = this.resolveNodePolicy(flow, node); + const decision = this.decideOnError(flow, node, policy, error); + + // Emit node.failed + await this.queue.run(() => + this.env.events.append({ + runId: this.runId, + type: 'node.failed', + nodeId: node.id, + attempt, + error, + decision: decision.kind, + } as RunEventInput), + ); + + if (decision.kind === 'retry' && decision.retryPolicy) { + const maxAttempts = 1 + Math.max(0, decision.retryPolicy.retries); + const canRetry = + attempt < maxAttempts && + (decision.retryPolicy.retryOn + ? decision.retryPolicy.retryOn.includes( + error.code as (typeof decision.retryPolicy.retryOn)[number], + ) + : true); + + if (!canRetry) { + return { terminal: 'failed', error }; + } + + const delay = computeRetryDelayMs(decision.retryPolicy, attempt); + if (delay > 0) { + await sleep(delay); + } + attempt++; + continue; + } + + if (decision.kind === 'continue') { + return { nextNodeId: findNextNode(flow, node.id) }; + } + + if (decision.kind === 'goto') { + if (decision.target.kind === 'node') { + return { nextNodeId: decision.target.nodeId }; + } + return { nextNodeId: findNextNode(flow, node.id, decision.target.label) }; + } + + return { terminal: 'failed', error }; + } + } + + private resolveNodePolicy(flow: FlowV3, node: NodeV3): NodePolicy { + const def = this.env.plugins.getNode(node.kind); + const flowDefault = flow.policy?.defaultNodePolicy; + const pluginDefault = def?.defaultPolicy; + const merged1 = mergeNodePolicy(flowDefault, pluginDefault); + return mergeNodePolicy(merged1, node.policy); + } + + private decideOnError( + flow: FlowV3, + node: NodeV3, + policy: NodePolicy, + _error: RRError, + ): OnErrorDecision { + const configured = policy.onError; + + // Default: if there's an ON_ERROR edge, use it + if (!configured) { + const onErrorEdge = findEdgeByLabel(flow, node.id, EDGE_LABELS.ON_ERROR); + if (onErrorEdge) { + return { kind: 'goto', target: { kind: 'edgeLabel', label: EDGE_LABELS.ON_ERROR } }; + } + return { kind: 'stop' }; + } + + if (configured.kind === 'stop') return { kind: 'stop' }; + if (configured.kind === 'continue') return { kind: 'continue' }; + if (configured.kind === 'goto') { + return { + kind: 'goto', + target: configured.target as + | { kind: 'edgeLabel'; label: string } + | { kind: 'node'; nodeId: NodeId }, + }; + } + + // retry + const base: RetryPolicy = policy.retry ?? { retries: 1, intervalMs: 0 }; + const retryPolicy: RetryPolicy = configured.override + ? { ...base, ...configured.override } + : base; + return { kind: 'retry', retryPolicy }; + } + + private async executeNodeAttempt(flow: FlowV3, node: NodeV3): Promise { + const def = this.env.plugins.getNode(node.kind); + if (!def) { + return { + status: 'failed', + error: createRRError( + RR_ERROR_CODES.UNSUPPORTED_NODE, + `Node kind "${node.kind}" is not registered`, + ), + }; + } + + let parsedConfig: unknown = node.config; + try { + parsedConfig = def.schema.parse(node.config); + } catch (e) { + return { + status: 'failed', + error: createRRError( + RR_ERROR_CODES.VALIDATION_ERROR, + `Invalid node config: ${errorMessage(e)}`, + ), + }; + } + + const ctx: NodeExecutionContext = { + runId: this.runId, + flow, + nodeId: node.id, + tabId: this.config.tabId, + vars: this.state.vars, + log: (level, message, data) => { + void this.queue + .run(() => + this.env.events.append({ + runId: this.runId, + type: 'log', + level, + message, + ...(data !== undefined ? { data } : {}), + } as RunEventInput), + ) + .catch(() => {}); + }, + chooseNext: (label) => ({ kind: 'edgeLabel', label }), + artifacts: { + screenshot: () => this.env.artifactService.screenshot(this.config.tabId), + }, + persistent: { + get: async (name) => (await this.env.storage.persistentVars.get(name))?.value, + set: async (name, value) => { + await this.env.storage.persistentVars.set(name, value); + }, + delete: async (name) => { + await this.env.storage.persistentVars.delete(name); + }, + }, + }; + + const policy = this.resolveNodePolicy(flow, node); + const timeoutMs = policy.timeout?.ms; + const scope = policy.timeout?.scope ?? 'attempt'; + const attemptTimeoutMs = scope === 'attempt' && timeoutMs !== undefined ? timeoutMs : undefined; + + try { + const nodeWithConfig = { ...node, config: parsedConfig } as Parameters[1]; + const execPromise = def.execute(ctx, nodeWithConfig); + const result = await withTimeout(execPromise, attemptTimeoutMs, () => + createRRError(RR_ERROR_CODES.TIMEOUT, `Node "${node.id}" timed out`), + ); + return result; + } catch (e) { + return { + status: 'failed', + error: toRRError(e, { code: RR_ERROR_CODES.INTERNAL, message: 'Node execution threw' }), + }; + } + } + + private async finishSucceeded(startedAt: number): Promise { + const tookMs = this.env.now() - startedAt; + await this.queue.run(async () => { + await this.env.storage.runs.patch(this.runId, { + status: 'succeeded', + finishedAt: this.env.now(), + tookMs, + outputs: this.outputs, + }); + await this.env.events.append({ + runId: this.runId, + type: 'run.succeeded', + tookMs, + outputs: this.outputs, + } as RunEventInput); + }); + + return { runId: this.runId, status: 'succeeded', tookMs, outputs: this.outputs }; + } + + private async finishFailed( + startedAt: number, + error: RRError, + nodeId?: NodeId, + ): Promise { + const tookMs = this.env.now() - startedAt; + await this.queue.run(async () => { + await this.env.storage.runs.patch(this.runId, { + status: 'failed', + finishedAt: this.env.now(), + tookMs, + error, + ...(nodeId ? { currentNodeId: nodeId } : {}), + }); + await this.env.events.append({ + runId: this.runId, + type: 'run.failed', + error, + ...(nodeId ? { nodeId } : {}), + } as RunEventInput); + }); + + return { runId: this.runId, status: 'failed', tookMs, error }; + } + + private async finishCanceled(startedAt: number): Promise { + const tookMs = this.env.now() - startedAt; + await this.queue.run(async () => { + await this.env.storage.runs.patch(this.runId, { + status: 'canceled', + finishedAt: this.env.now(), + tookMs, + }); + await this.env.events.append({ + runId: this.runId, + type: 'run.canceled', + ...(this.cancelReason ? { reason: this.cancelReason } : {}), + } as RunEventInput); + }); + + return { runId: this.runId, status: 'canceled', tookMs }; + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts new file mode 100644 index 0000000..b93c1bd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts @@ -0,0 +1,226 @@ +/** + * @fileoverview DAG 遍历和校验 + * @description 提供 Flow DAG 的校验、遍历和下一节点查找功能 + */ + +import type { NodeId, EdgeLabel } from '../../domain/ids'; +import type { FlowV3, EdgeV3 } from '../../domain/flow'; +import { EDGE_LABELS } from '../../domain/ids'; +import { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors'; + +/** + * DAG 校验结果 + */ +export type ValidateFlowDAGResult = { ok: true } | { ok: false; errors: RRError[] }; + +/** + * 校验 Flow DAG 结构 + * @param flow Flow 定义 + * @returns 校验结果 + */ +export function validateFlowDAG(flow: FlowV3): ValidateFlowDAGResult { + const errors: RRError[] = []; + const nodeIds = new Set(flow.nodes.map((n) => n.id)); + + // 检查 entryNodeId 是否存在 + if (!nodeIds.has(flow.entryNodeId)) { + errors.push( + createRRError( + RR_ERROR_CODES.DAG_INVALID, + `Entry node "${flow.entryNodeId}" does not exist in flow`, + ), + ); + } + + // 检查边引用的节点是否存在 + for (const edge of flow.edges) { + if (!nodeIds.has(edge.from)) { + errors.push( + createRRError( + RR_ERROR_CODES.DAG_INVALID, + `Edge "${edge.id}" references non-existent source node "${edge.from}"`, + ), + ); + } + if (!nodeIds.has(edge.to)) { + errors.push( + createRRError( + RR_ERROR_CODES.DAG_INVALID, + `Edge "${edge.id}" references non-existent target node "${edge.to}"`, + ), + ); + } + } + + // 检查循环 + const cycle = detectCycle(flow); + if (cycle) { + errors.push( + createRRError(RR_ERROR_CODES.DAG_CYCLE, `Cycle detected in flow: ${cycle.join(' -> ')}`), + ); + } + + return errors.length > 0 ? { ok: false, errors } : { ok: true }; +} + +/** + * 检测 DAG 中的循环 + * @param flow Flow 定义 + * @returns 循环路径(如果存在)或 null + */ +export function detectCycle(flow: FlowV3): NodeId[] | null { + const adjacency = buildAdjacencyMap(flow); + const visited = new Set(); + const recursionStack = new Set(); + const path: NodeId[] = []; + + function dfs(nodeId: NodeId): boolean { + visited.add(nodeId); + recursionStack.add(nodeId); + path.push(nodeId); + + const neighbors = adjacency.get(nodeId) || []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + if (dfs(neighbor)) { + return true; + } + } else if (recursionStack.has(neighbor)) { + // 找到循环 + const cycleStart = path.indexOf(neighbor); + path.push(neighbor); // 闭合循环 + path.splice(0, cycleStart); // 移除循环前的节点 + return true; + } + } + + path.pop(); + recursionStack.delete(nodeId); + return false; + } + + for (const node of flow.nodes) { + if (!visited.has(node.id)) { + if (dfs(node.id)) { + return path; + } + } + } + + return null; +} + +/** + * 查找下一个节点 + * @param flow Flow 定义 + * @param currentNodeId 当前节点 ID + * @param label 边标签(可选,默认使用 default) + * @returns 下一个节点 ID 或 null(如果没有后续节点) + */ +export function findNextNode( + flow: FlowV3, + currentNodeId: NodeId, + label?: EdgeLabel, +): NodeId | null { + const outEdges = flow.edges.filter((e) => e.from === currentNodeId); + + if (outEdges.length === 0) { + return null; + } + + // 如果指定了 label,优先匹配 + if (label) { + const matchedEdge = outEdges.find((e) => e.label === label); + if (matchedEdge) { + return matchedEdge.to; + } + } + + // 否则使用 default 边 + const defaultEdge = outEdges.find( + (e) => e.label === EDGE_LABELS.DEFAULT || e.label === undefined, + ); + if (defaultEdge) { + return defaultEdge.to; + } + + // 如果只有一条边,使用它 + if (outEdges.length === 1) { + return outEdges[0].to; + } + + return null; +} + +/** + * 查找指定标签的边 + */ +export function findEdgeByLabel( + flow: FlowV3, + fromNodeId: NodeId, + label: EdgeLabel, +): EdgeV3 | undefined { + return flow.edges.find((e) => e.from === fromNodeId && e.label === label); +} + +/** + * 获取节点的所有出边 + */ +export function getOutEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] { + return flow.edges.filter((e) => e.from === nodeId); +} + +/** + * 获取节点的所有入边 + */ +export function getInEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] { + return flow.edges.filter((e) => e.to === nodeId); +} + +/** + * 构建邻接表 + */ +function buildAdjacencyMap(flow: FlowV3): Map { + const map = new Map(); + + for (const node of flow.nodes) { + map.set(node.id, []); + } + + for (const edge of flow.edges) { + const neighbors = map.get(edge.from); + if (neighbors) { + neighbors.push(edge.to); + } + } + + return map; +} + +/** + * 获取从入口节点可达的所有节点 + */ +export function getReachableNodes(flow: FlowV3): Set { + const reachable = new Set(); + const adjacency = buildAdjacencyMap(flow); + + function dfs(nodeId: NodeId): void { + if (reachable.has(nodeId)) return; + reachable.add(nodeId); + + const neighbors = adjacency.get(nodeId) || []; + for (const neighbor of neighbors) { + dfs(neighbor); + } + } + + dfs(flow.entryNodeId); + return reachable; +} + +/** + * 检查节点是否可达 + */ +export function isNodeReachable(flow: FlowV3, nodeId: NodeId): boolean { + return getReachableNodes(flow).has(nodeId); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts new file mode 100644 index 0000000..0a493f4 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts @@ -0,0 +1,8 @@ +/** + * @fileoverview 插件系统导出入口 + */ + +export * from './types'; +export * from './registry'; +export * from './v2-action-adapter'; +export * from './register-v2-replay-nodes'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts new file mode 100644 index 0000000..011eb46 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview Register RR-V2 replay action handlers as RR-V3 nodes + * @description + * Batch registration of V2 action handlers into the V3 PluginRegistry. + * This enables V3 to execute flows that use V2 action types. + */ + +import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions/handlers'; +import type { + ActionHandler, + ExecutableActionType, +} from '@/entrypoints/background/record-replay/actions/types'; + +import type { PluginRegistry } from './registry'; +import { + adaptV2ActionHandlerToV3NodeDefinition, + type V2ActionNodeAdapterOptions, +} from './v2-action-adapter'; + +export interface RegisterV2ReplayNodesOptions extends V2ActionNodeAdapterOptions { + /** + * Only include these action types. If not specified, all V2 handlers are included. + */ + include?: ReadonlyArray; + + /** + * Exclude these action types. Applied after include filter. + */ + exclude?: ReadonlyArray; +} + +/** + * Register V2 replay action handlers as V3 node definitions. + * + * @param registry The V3 PluginRegistry to register nodes into + * @param options Configuration options + * @returns Array of registered node kinds + * + * @example + * ```ts + * const plugins = new PluginRegistry(); + * const registered = registerV2ReplayNodesAsV3Nodes(plugins, { + * // Exclude control flow handlers that V3 runner doesn't support + * exclude: ['foreach', 'while'], + * }); + * console.log('Registered:', registered); + * ``` + */ +export function registerV2ReplayNodesAsV3Nodes( + registry: PluginRegistry, + options: RegisterV2ReplayNodesOptions = {}, +): string[] { + const actionRegistry = createReplayActionRegistry(); + const handlers = actionRegistry.list(); + + const include = options.include ? new Set(options.include) : null; + const exclude = options.exclude ? new Set(options.exclude) : null; + + const registered: string[] = []; + + for (const handler of handlers) { + if (include && !include.has(handler.type)) continue; + if (exclude && exclude.has(handler.type)) continue; + + // Cast needed because V2 handler types don't perfectly align with V3 NodeKind + const nodeDef = adaptV2ActionHandlerToV3NodeDefinition( + handler as ActionHandler, + options, + ); + registry.registerNode(nodeDef as unknown as Parameters[0]); + registered.push(handler.type); + } + + return registered; +} + +/** + * Get list of V2 action types that can be registered. + * Useful for debugging and documentation. + */ +export function listV2ActionTypes(): string[] { + const actionRegistry = createReplayActionRegistry(); + return actionRegistry.list().map((h) => h.type); +} + +/** + * Default exclude list for V3 registration. + * These handlers rely on V2 control directives that V3 runner doesn't support. + */ +export const DEFAULT_V2_EXCLUDE_LIST = ['foreach', 'while'] as const; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts new file mode 100644 index 0000000..dbf307a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview 插件注册表 + * @description 管理节点和触发器插件的注册和查询 + */ + +import type { NodeKind } from '../../domain/flow'; +import type { TriggerKind } from '../../domain/triggers'; +import { RR_ERROR_CODES, createRRError } from '../../domain/errors'; +import type { + NodeDefinition, + TriggerDefinition, + PluginRegistrationContext, + RRPlugin, +} from './types'; + +/** + * 插件注册表 + * @description 单例模式,管理所有已注册的节点和触发器 + */ +export class PluginRegistry implements PluginRegistrationContext { + private nodes = new Map(); + private triggers = new Map(); + + /** + * 注册节点定义 + * @description 如果已存在同名节点,会覆盖 + */ + registerNode(def: NodeDefinition): void { + this.nodes.set(def.kind, def); + } + + /** + * 注册触发器定义 + * @description 如果已存在同名触发器,会覆盖 + */ + registerTrigger(def: TriggerDefinition): void { + this.triggers.set(def.kind, def); + } + + /** + * 获取节点定义 + * @returns 节点定义或 undefined + */ + getNode(kind: NodeKind): NodeDefinition | undefined { + return this.nodes.get(kind); + } + + /** + * 获取节点定义(必须存在) + * @throws RRError 如果节点未注册 + */ + getNodeOrThrow(kind: NodeKind): NodeDefinition { + const def = this.nodes.get(kind); + if (!def) { + throw createRRError(RR_ERROR_CODES.UNSUPPORTED_NODE, `Node kind "${kind}" is not registered`); + } + return def; + } + + /** + * 获取触发器定义 + * @returns 触发器定义或 undefined + */ + getTrigger(kind: TriggerKind): TriggerDefinition | undefined { + return this.triggers.get(kind); + } + + /** + * 获取触发器定义(必须存在) + * @throws RRError 如果触发器未注册 + */ + getTriggerOrThrow(kind: TriggerKind): TriggerDefinition { + const def = this.triggers.get(kind); + if (!def) { + throw createRRError( + RR_ERROR_CODES.UNSUPPORTED_NODE, + `Trigger kind "${kind}" is not registered`, + ); + } + return def; + } + + /** + * 检查节点是否已注册 + */ + hasNode(kind: NodeKind): boolean { + return this.nodes.has(kind); + } + + /** + * 检查触发器是否已注册 + */ + hasTrigger(kind: TriggerKind): boolean { + return this.triggers.has(kind); + } + + /** + * 获取所有已注册的节点类型 + */ + listNodeKinds(): NodeKind[] { + return Array.from(this.nodes.keys()); + } + + /** + * 获取所有已注册的触发器类型 + */ + listTriggerKinds(): TriggerKind[] { + return Array.from(this.triggers.keys()); + } + + /** + * 注册插件 + * @description 调用插件的 register 方法 + */ + registerPlugin(plugin: RRPlugin): void { + plugin.register(this); + } + + /** + * 批量注册插件 + */ + registerPlugins(plugins: RRPlugin[]): void { + for (const plugin of plugins) { + this.registerPlugin(plugin); + } + } + + /** + * 清空所有注册 + * @description 主要用于测试 + */ + clear(): void { + this.nodes.clear(); + this.triggers.clear(); + } +} + +/** 全局插件注册表实例 */ +let globalRegistry: PluginRegistry | null = null; + +/** + * 获取全局插件注册表 + */ +export function getPluginRegistry(): PluginRegistry { + if (!globalRegistry) { + globalRegistry = new PluginRegistry(); + } + return globalRegistry; +} + +/** + * 重置全局插件注册表 + * @description 主要用于测试 + */ +export function resetPluginRegistry(): void { + globalRegistry = null; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts new file mode 100644 index 0000000..8851f7b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts @@ -0,0 +1,181 @@ +/** + * @fileoverview 插件类型定义 + * @description 定义 Record-Replay V3 中的节点和触发器插件接口 + */ + +import { z } from 'zod'; + +import type { JsonObject, JsonValue } from '../../domain/json'; +import type { FlowId, NodeId, RunId, TriggerId } from '../../domain/ids'; +import type { NodeKind } from '../../domain/flow'; +import type { RRError } from '../../domain/errors'; +import type { NodePolicy } from '../../domain/policy'; +import type { FlowV3, NodeV3 } from '../../domain/flow'; +import type { TriggerKind } from '../../domain/triggers'; + +/** + * Schema 类型 + * @description 使用 Zod 进行配置校验 + */ +export type Schema = z.ZodType; + +/** + * 节点执行上下文 + * @description 提供给节点执行器的运行时上下文 + */ +export interface NodeExecutionContext { + /** Run ID */ + runId: RunId; + /** Flow 定义(快照) */ + flow: FlowV3; + /** 当前节点 ID */ + nodeId: NodeId; + + /** 绑定的 Tab ID(每 Run 独占) */ + tabId: number; + /** Frame ID(默认 0 为主框架) */ + frameId?: number; + + /** 当前变量表 */ + vars: Record; + + /** + * 日志记录 + */ + log: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: JsonValue) => void; + + /** + * 选择下一个边 + * @description 用于条件分支节点 + */ + chooseNext: (label: string) => { kind: 'edgeLabel'; label: string }; + + /** + * 工件操作 + */ + artifacts: { + /** 截取当前页面截图 */ + screenshot: () => Promise<{ ok: true; base64: string } | { ok: false; error: RRError }>; + }; + + /** + * 持久化变量操作 + */ + persistent: { + /** 获取持久化变量 */ + get: (name: `$${string}`) => Promise; + /** 设置持久化变量 */ + set: (name: `$${string}`, value: JsonValue) => Promise; + /** 删除持久化变量 */ + delete: (name: `$${string}`) => Promise; + }; +} + +/** + * 变量补丁操作 + */ +export interface VarsPatchOp { + op: 'set' | 'delete'; + name: string; + value?: JsonValue; +} + +/** + * 节点执行结果 + */ +export type NodeExecutionResult = + | { + status: 'succeeded'; + /** 下一步执行方向 */ + next?: { kind: 'edgeLabel'; label: string } | { kind: 'end' }; + /** 输出结果 */ + outputs?: JsonObject; + /** 变量修改 */ + varsPatch?: VarsPatchOp[]; + } + | { status: 'failed'; error: RRError }; + +/** + * 节点定义 + * @description 定义一种节点类型的执行逻辑 + */ +export interface NodeDefinition< + TKind extends NodeKind = NodeKind, + TConfig extends JsonObject = JsonObject, +> { + /** 节点类型标识 */ + kind: TKind; + /** 配置校验 Schema */ + schema: Schema; + /** 默认策略 */ + defaultPolicy?: NodePolicy; + /** + * 执行节点 + * @param ctx 执行上下文 + * @param node 节点定义(含配置) + */ + execute( + ctx: NodeExecutionContext, + node: NodeV3 & { kind: TKind; config: TConfig }, + ): Promise; +} + +/** + * 触发器安装上下文 + */ +export interface TriggerInstallContext< + TKind extends TriggerKind = TriggerKind, + TConfig extends JsonObject = JsonObject, +> { + /** 触发器 ID */ + triggerId: TriggerId; + /** 触发器类型 */ + kind: TKind; + /** 是否启用 */ + enabled: boolean; + /** 关联的 Flow ID */ + flowId: FlowId; + /** 触发器配置 */ + config: TConfig; + /** 传递给 Flow 的参数 */ + args?: JsonObject; +} + +/** + * 触发器定义 + * @description 定义一种触发器类型的安装和卸载逻辑 + */ +export interface TriggerDefinition< + TKind extends TriggerKind = TriggerKind, + TConfig extends JsonObject = JsonObject, +> { + /** 触发器类型标识 */ + kind: TKind; + /** 配置校验 Schema */ + schema: Schema; + /** 安装触发器 */ + install(ctx: TriggerInstallContext): Promise | void; + /** 卸载触发器 */ + uninstall(ctx: TriggerInstallContext): Promise | void; +} + +/** + * 插件注册上下文 + */ +export interface PluginRegistrationContext { + /** 注册节点定义 */ + registerNode(def: NodeDefinition): void; + /** 注册触发器定义 */ + registerTrigger(def: TriggerDefinition): void; +} + +/** + * 插件接口 + * @description Record-Replay 插件的标准接口 + */ +export interface RRPlugin { + /** 插件名称 */ + name: string; + /** 注册插件内容 */ + register(ctx: PluginRegistrationContext): void; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts new file mode 100644 index 0000000..cbc1068 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts @@ -0,0 +1,414 @@ +/** + * @fileoverview V2 ActionHandler -> V3 NodeDefinition adapter + * @description Bridges legacy RR-V2 action handlers into the RR-V3 PluginRegistry. + * + * Design notes: + * - V3 requires variable mutations to be represented as varsPatch so they are auditable in the event log. + * - V2 handlers mutate ctx.vars directly, so we run them against a cloned VariableStore and diff it. + * - Cross-node state (tabId/frameId changes from switchFrame/openTab/switchTab) is persisted in internal vars. + * + * WARNING: This adapter accesses V2 handler internals and may need updates if V2 types change. + */ + +import { z } from 'zod'; + +import type { + ActionExecutionContext, + ActionExecutionResult, + ActionHandler, + ActionError, + ActionErrorCode, + ActionPolicy, + ExecutableActionType, + ValidationResult, + Action, +} from '@/entrypoints/background/record-replay/actions/types'; + +import type { JsonValue, JsonObject } from '../../domain/json'; +import { RR_ERROR_CODES, createRRError, type RRError, type RRErrorCode } from '../../domain/errors'; +import type { NodePolicy } from '../../domain/policy'; +import { mergeNodePolicy } from '../../domain/policy'; + +import type { + NodeDefinition, + NodeExecutionContext, + NodeExecutionResult, + VarsPatchOp, +} from './types'; + +// Internal run-scoped state keys used to emulate V2 "mutable context" across nodes. +const DEFAULT_TAB_ID_VAR = '__rr_v2__tabId'; +const DEFAULT_FRAME_ID_VAR = '__rr_v2__frameId'; + +export interface V2ActionNodeAdapterOptions { + /** + * Whether to emit v2 ActionExecutionResult.output into V3 NodeExecutionResult.outputs. + * Defaults to true. + */ + includeOutput?: boolean; + + /** + * Where to store cross-node "mutable context" state (tabId/frameId). + * Defaults are "__rr_v2__tabId" and "__rr_v2__frameId". + */ + stateVars?: { + tabIdVar?: string; + frameIdVar?: string; + }; + + /** + * Execution flags forwarded into V2 ActionExecutionContext.execution. + * Keep default undefined to preserve V2 handler behavior. + */ + executionFlags?: ActionExecutionContext['execution']; +} + +// ==================== Utilities ==================== + +function toErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + if (e && typeof e === 'object' && 'message' in e) + return String((e as { message: unknown }).message); + return String(e); +} + +function deepClone(value: T): T { + const sc = (globalThis as unknown as { structuredClone?: (v: U) => U }).structuredClone; + if (typeof sc === 'function') return sc(value); + return JSON.parse(JSON.stringify(value)) as T; +} + +function safeJsonValue(value: unknown): JsonValue { + if (value === undefined) return null; + try { + const s = JSON.stringify(value); + if (s === undefined) return String(value); + return JSON.parse(s) as JsonValue; + } catch { + return String(value); + } +} + +function mapLogLevel(level: 'info' | 'warn' | 'error' | undefined): 'info' | 'warn' | 'error' { + return level ?? 'info'; +} + +function mapV2ErrorCode(code: ActionErrorCode): RRErrorCode { + switch (code) { + case 'VALIDATION_ERROR': + return RR_ERROR_CODES.VALIDATION_ERROR; + case 'TIMEOUT': + return RR_ERROR_CODES.TIMEOUT; + case 'TAB_NOT_FOUND': + return RR_ERROR_CODES.TAB_NOT_FOUND; + case 'FRAME_NOT_FOUND': + return RR_ERROR_CODES.FRAME_NOT_FOUND; + case 'TARGET_NOT_FOUND': + return RR_ERROR_CODES.TARGET_NOT_FOUND; + case 'ELEMENT_NOT_VISIBLE': + return RR_ERROR_CODES.ELEMENT_NOT_VISIBLE; + case 'NAVIGATION_FAILED': + return RR_ERROR_CODES.NAVIGATION_FAILED; + case 'NETWORK_REQUEST_FAILED': + return RR_ERROR_CODES.NETWORK_REQUEST_FAILED; + case 'SCRIPT_FAILED': + return RR_ERROR_CODES.SCRIPT_FAILED; + + // V3 doesn't currently have dedicated codes for these. + case 'DOWNLOAD_FAILED': + case 'ASSERTION_FAILED': + return RR_ERROR_CODES.TOOL_ERROR; + + case 'UNKNOWN': + default: + return RR_ERROR_CODES.INTERNAL; + } +} + +function toRRErrorFromV2(error: ActionError): RRError { + const data = error.data !== undefined ? safeJsonValue(error.data) : undefined; + return createRRError( + mapV2ErrorCode(error.code), + error.message, + data !== undefined ? { data } : undefined, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function jsonEquals(a: JsonValue, b: JsonValue): boolean { + if (a === b) return true; + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + if (aIsArray || bIsArray) { + if (!aIsArray || !bIsArray) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!jsonEquals(a[i] as JsonValue, b[i] as JsonValue)) return false; + } + return true; + } + + const aIsObj = isRecord(a); + const bIsObj = isRecord(b); + if (aIsObj || bIsObj) { + if (!aIsObj || !bIsObj) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const k of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!jsonEquals(a[k] as JsonValue, (b as Record)[k] as JsonValue)) + return false; + } + return true; + } + + return false; +} + +function diffVars( + before: Record, + after: Record, +): VarsPatchOp[] { + const patch: VarsPatchOp[] = []; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of keys) { + const beforeHas = Object.prototype.hasOwnProperty.call(before, key); + const afterHas = Object.prototype.hasOwnProperty.call(after, key); + + if (!afterHas) { + if (beforeHas) patch.push({ op: 'delete', name: key }); + continue; + } + + const afterVal = after[key]; + if (!beforeHas) { + patch.push({ op: 'set', name: key, value: afterVal }); + continue; + } + + const beforeVal = before[key]; + if (!jsonEquals(beforeVal, afterVal)) { + patch.push({ op: 'set', name: key, value: afterVal }); + } + } + + return patch; +} + +function readNumberVar(vars: Record, key: string): number | undefined { + const v = vars[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : undefined; +} + +function toV2ActionPolicy(policy: NodePolicy | undefined): ActionPolicy | undefined { + if (!policy) return undefined; + + const timeout = policy.timeout + ? { + ms: policy.timeout.ms, + scope: policy.timeout.scope === 'node' ? ('action' as const) : ('attempt' as const), + } + : undefined; + + // NodePolicy/ActionPolicy are structurally similar; we only normalize timeout.scope. + return { + ...(timeout ? { timeout } : {}), + ...(policy.retry ? { retry: policy.retry as unknown as ActionPolicy['retry'] } : {}), + ...(policy.artifacts + ? { artifacts: policy.artifacts as unknown as ActionPolicy['artifacts'] } + : {}), + ...(policy.onError + ? (() => { + // V2 only supports goto by edge label. Node-target goto can't be represented. + if (policy.onError.kind === 'goto' && policy.onError.target.kind === 'node') { + return { onError: { kind: 'stop' } as ActionPolicy['onError'] }; + } + if (policy.onError.kind === 'continue') { + return { + onError: { + kind: 'continue', + level: policy.onError.as, + } as ActionPolicy['onError'], + }; + } + if (policy.onError.kind === 'goto') { + const target = policy.onError.target; + if (target.kind === 'edgeLabel') { + return { + onError: { + kind: 'goto', + label: target.label, + } as ActionPolicy['onError'], + }; + } + // Node target can't be represented in V2, fall through to stop + return { onError: { kind: 'stop' } as ActionPolicy['onError'] }; + } + if (policy.onError.kind === 'retry') { + // V2 has retry policy on action.policy.retry; keep onError as stop to avoid double semantics. + return { onError: { kind: 'stop' } as ActionPolicy['onError'] }; + } + return { onError: policy.onError as unknown as ActionPolicy['onError'] }; + })() + : {}), + }; +} + +function toJsonRecord(value: unknown): Record { + const out: Record = {}; + if (!isRecord(value)) return out; + + for (const [k, v] of Object.entries(value)) { + // Treat undefined as deletion (omit). + if (v === undefined) continue; + out[k] = safeJsonValue(v); + } + + return out; +} + +// ==================== Main Adapter ==================== + +/** + * Adapt a single V2 ActionHandler into a V3 NodeDefinition. + */ +export function adaptV2ActionHandlerToV3NodeDefinition( + handler: ActionHandler, + options: V2ActionNodeAdapterOptions = {}, +): NodeDefinition { + const tabIdVar = options.stateVars?.tabIdVar ?? DEFAULT_TAB_ID_VAR; + const frameIdVar = options.stateVars?.frameIdVar ?? DEFAULT_FRAME_ID_VAR; + + return { + kind: handler.type, + schema: z.record(z.any()) as unknown as NodeDefinition['schema'], + execute: async (ctx: NodeExecutionContext, node): Promise => { + const beforeVars = ctx.vars; + + const effectiveTabId = readNumberVar(beforeVars, tabIdVar) ?? ctx.tabId; + const effectiveFrameId = readNumberVar(beforeVars, frameIdVar); + + // Run against a cloned variable store to prevent bypassing vars.patch event stream. + const v2Vars = deepClone(beforeVars) as unknown as Record; + + const v2Ctx: ActionExecutionContext = { + vars: v2Vars as unknown as ActionExecutionContext['vars'], + tabId: effectiveTabId, + frameId: effectiveFrameId, + runId: ctx.runId, + log: (message, level) => ctx.log(mapLogLevel(level), message), + pushLog: (entry) => { + try { + ctx.log('debug', 'v2.pushLog', safeJsonValue(entry)); + } catch { + // ignore + } + }, + captureScreenshot: async () => { + const r = await ctx.artifacts.screenshot(); + if (r.ok) return r.base64; + throw new Error(r.error.message); + }, + ...(options.executionFlags ? { execution: options.executionFlags } : {}), + }; + + const effectivePolicy = mergeNodePolicy(ctx.flow.policy?.defaultNodePolicy, node.policy); + const v2Policy = toV2ActionPolicy(effectivePolicy); + + const action: Action = { + id: node.id as Action['id'], + type: handler.type, + ...(node.name ? { name: node.name } : {}), + ...(node.disabled ? { disabled: true } : {}), + ...(v2Policy ? { policy: v2Policy } : {}), + params: node.config as unknown as Action['params'], + ...(node.ui ? { ui: node.ui as Action['ui'] } : {}), + }; + + // V2 handler-level validation + if (handler.validate) { + const v: ValidationResult = handler.validate(action); + if (!v.ok) { + return { + status: 'failed', + error: createRRError(RR_ERROR_CODES.VALIDATION_ERROR, v.errors.join(', ')), + }; + } + } + + let result: ActionExecutionResult; + try { + result = await handler.run(v2Ctx, action); + } catch (e) { + return { + status: 'failed', + error: createRRError( + RR_ERROR_CODES.INTERNAL, + `V2 handler "${handler.type}" threw: ${toErrorMessage(e)}`, + ), + }; + } + + if (result.status === 'failed') { + const err = result.error + ? toRRErrorFromV2(result.error) + : createRRError(RR_ERROR_CODES.INTERNAL, `V2 handler "${handler.type}" failed`); + return { status: 'failed', error: err }; + } + + if (result.status === 'paused') { + return { + status: 'failed', + error: createRRError( + RR_ERROR_CODES.RUN_PAUSED, + `V2 handler "${handler.type}" returned paused (not supported in V3 NodeExecutionResult)`, + ), + }; + } + + // V3 does not support V2 scheduler control directives (foreach/while). + if (result.control) { + return { + status: 'failed', + error: createRRError( + RR_ERROR_CODES.UNSUPPORTED_NODE, + `V2 control directive "${result.control.kind}" is not supported by the V3 runner`, + { data: safeJsonValue(result.control) }, + ), + }; + } + + // Persist cross-node context changes via internal vars. + if (typeof v2Ctx.frameId === 'number' && Number.isFinite(v2Ctx.frameId)) { + v2Vars[frameIdVar] = v2Ctx.frameId; + } else { + delete v2Vars[frameIdVar]; + } + + if (typeof result.newTabId === 'number' && Number.isFinite(result.newTabId)) { + v2Vars[tabIdVar] = result.newTabId; + } + + const afterVars = toJsonRecord(v2Vars); + const varsPatch = diffVars(beforeVars, afterVars); + + const outputs: Record | undefined = + options.includeOutput === false || result.output === undefined + ? undefined + : { [node.id]: safeJsonValue(result.output) }; + + return { + status: 'succeeded', + ...(result.nextLabel ? { next: ctx.chooseNext(result.nextLabel) } : {}), + ...(outputs ? { outputs } : {}), + ...(varsPatch.length > 0 ? { varsPatch } : {}), + }; + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts new file mode 100644 index 0000000..52e2591 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts @@ -0,0 +1,219 @@ +/** + * @fileoverview 共享入队服务 + * @description + * 提供统一的 Run 入队逻辑,供 RPC Server 和 TriggerManager 共用。 + * + * 设计理由: + * - 将原本位于 RpcServer 的入队逻辑抽离为独立服务 + * - 避免 RPC 和 TriggerManager 之间的行为漂移 + * - 统一参数校验、Run 创建、队列入队、事件发布流程 + */ + +import type { JsonObject, UnixMillis } from '../../domain/json'; +import type { FlowId, NodeId, RunId } from '../../domain/ids'; +import type { TriggerFireContext } from '../../domain/triggers'; +import { RUN_SCHEMA_VERSION, type RunRecordV3 } from '../../domain/events'; +import type { StoragePort } from '../storage/storage-port'; +import type { EventsBus } from '../transport/events-bus'; +import type { RunScheduler } from './scheduler'; + +// ==================== Types ==================== + +/** + * 入队服务依赖 + */ +export interface EnqueueRunDeps { + /** 存储层 (仅需 flows/runs/queue) */ + storage: Pick; + /** 事件总线 */ + events: Pick; + /** 调度器 (可选) */ + scheduler?: Pick; + /** RunId 生成器 (用于测试注入) */ + generateRunId?: () => RunId; + /** 时间源 (用于测试注入) */ + now?: () => UnixMillis; +} + +/** + * 入队请求参数 + */ +export interface EnqueueRunInput { + /** Flow ID (必选) */ + flowId: FlowId; + /** 起始节点 ID (可选,默认使用 Flow 的 entryNodeId) */ + startNodeId?: NodeId; + /** 优先级 (默认 0) */ + priority?: number; + /** 最大尝试次数 (默认 1) */ + maxAttempts?: number; + /** 传递给 Flow 的参数 */ + args?: JsonObject; + /** 触发上下文 (由 TriggerManager 设置) */ + trigger?: TriggerFireContext; + /** 调试选项 */ + debug?: { + breakpoints?: NodeId[]; + pauseOnStart?: boolean; + }; +} + +/** + * 入队结果 + */ +export interface EnqueueRunResult { + /** 新创建的 Run ID */ + runId: RunId; + /** 在队列中的位置 (1-based) */ + position: number; +} + +// ==================== Utilities ==================== + +/** + * 默认 RunId 生成器 + */ +function defaultGenerateRunId(): RunId { + return `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * 校验整数参数 + */ +function validateInt( + value: unknown, + defaultValue: number, + fieldName: string, + opts?: { min?: number; max?: number }, +): number { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${fieldName} must be a finite number`); + } + const intValue = Math.floor(value); + if (opts?.min !== undefined && intValue < opts.min) { + throw new Error(`${fieldName} must be >= ${opts.min}`); + } + if (opts?.max !== undefined && intValue > opts.max) { + throw new Error(`${fieldName} must be <= ${opts.max}`); + } + return intValue; +} + +/** + * 计算 Run 在队列中的位置 + * @description 按调度顺序: priority DESC + createdAt ASC + * @returns 1-based position, or -1 if run not found in queued items + * + * Note: Due to race conditions (scheduler may claim the run before this is called), + * position may be -1. Callers should handle this gracefully. + */ +async function computeQueuePosition( + storage: Pick, + runId: RunId, +): Promise { + const queueItems = await storage.queue.list('queued'); + queueItems.sort((a, b) => { + if (a.priority !== b.priority) return b.priority - a.priority; + return a.createdAt - b.createdAt; + }); + const index = queueItems.findIndex((item) => item.id === runId); + // Return -1 if not found (run may have been claimed already) + return index === -1 ? -1 : index + 1; +} + +// ==================== Main Function ==================== + +/** + * 入队执行一个 Run + * @description + * 执行步骤: + * 1. 参数校验 + * 2. 验证 Flow 存在 + * 3. 创建 RunRecordV3 (status=queued) + * 4. 入队到 RunQueue + * 5. 发布 run.queued 事件 + * 6. 触发调度 (best-effort) + * 7. 计算队列位置 + */ +export async function enqueueRun( + deps: EnqueueRunDeps, + input: EnqueueRunInput, +): Promise { + const { flowId } = input; + if (!flowId) { + throw new Error('flowId is required'); + } + + const now = deps.now ?? (() => Date.now()); + const generateRunId = deps.generateRunId ?? defaultGenerateRunId; + + // 参数校验 + const priority = validateInt(input.priority, 0, 'priority'); + const maxAttempts = validateInt(input.maxAttempts, 1, 'maxAttempts', { min: 1 }); + + // 验证 Flow 存在 + const flow = await deps.storage.flows.get(flowId); + if (!flow) { + throw new Error(`Flow "${flowId}" not found`); + } + + // 验证 startNodeId 存在于 Flow 中 + if (input.startNodeId) { + const nodeExists = flow.nodes.some((n) => n.id === input.startNodeId); + if (!nodeExists) { + throw new Error(`startNodeId "${input.startNodeId}" not found in flow "${flowId}"`); + } + } + + const ts = now(); + const runId = generateRunId(); + + // 1. 创建 RunRecordV3 + const runRecord: RunRecordV3 = { + schemaVersion: RUN_SCHEMA_VERSION, + id: runId, + flowId, + status: 'queued', + createdAt: ts, + updatedAt: ts, + attempt: 0, + maxAttempts, + args: input.args, + trigger: input.trigger, + debug: input.debug, + startNodeId: input.startNodeId, + nextSeq: 0, + }; + await deps.storage.runs.save(runRecord); + + // 2. 入队 + await deps.storage.queue.enqueue({ + id: runId, + flowId, + priority, + maxAttempts, + args: input.args, + trigger: input.trigger, + debug: input.debug, + }); + + // 3. 发布 run.queued 事件 + await deps.events.append({ + runId, + type: 'run.queued', + flowId, + }); + + // 4. 计算队列位置 (在 kick 之前计算,减少竞态条件导致 position=-1 的概率) + const position = await computeQueuePosition(deps.storage, runId); + + // 5. 触发调度 (best-effort, 不阻塞返回) + if (deps.scheduler) { + void deps.scheduler.kick(); + } + + return { runId, position }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts new file mode 100644 index 0000000..2d3b052 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts @@ -0,0 +1,8 @@ +/** + * @fileoverview Queue 模块导出入口 + */ + +export * from './queue'; +export * from './leasing'; +export * from './scheduler'; +export * from './enqueue-run'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts new file mode 100644 index 0000000..9925ee1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts @@ -0,0 +1,113 @@ +/** + * @fileoverview 租约管理 + * @description 管理 Run 的租约续约和过期回收 + */ + +import type { UnixMillis } from '../../domain/json'; +import type { RunId } from '../../domain/ids'; +import type { RunQueue, RunQueueConfig, Lease } from './queue'; + +/** + * 租约管理器 + * @description 管理租约续约和过期检测 + */ +export interface LeaseManager { + /** + * 开始心跳 + * @param ownerId 持有者 ID + */ + startHeartbeat(ownerId: string): void; + + /** + * 停止心跳 + * @param ownerId 持有者 ID + */ + stopHeartbeat(ownerId: string): void; + + /** + * 检查并回收过期租约 + * @param now 当前时间 + * @returns 被回收的 Run ID 列表 + */ + reclaimExpiredLeases(now: UnixMillis): Promise; + + /** + * 判断租约是否过期 + */ + isLeaseExpired(lease: Lease, now: UnixMillis): boolean; + + /** + * 创建新租约 + */ + createLease(ownerId: string, now: UnixMillis): Lease; + + /** + * 停止所有心跳 + */ + dispose(): void; +} + +/** + * 创建租约管理器 + */ +export function createLeaseManager(queue: RunQueue, config: RunQueueConfig): LeaseManager { + const heartbeatTimers = new Map>(); + + return { + startHeartbeat(ownerId: string): void { + // 如果已有定时器,先停止 + this.stopHeartbeat(ownerId); + + // 创建新的心跳定时器 + const timer = setInterval(async () => { + try { + await queue.heartbeat(ownerId, Date.now()); + } catch (error) { + console.error(`[LeaseManager] Heartbeat failed for ${ownerId}:`, error); + } + }, config.heartbeatIntervalMs); + + heartbeatTimers.set(ownerId, timer); + }, + + stopHeartbeat(ownerId: string): void { + const timer = heartbeatTimers.get(ownerId); + if (timer) { + clearInterval(timer); + heartbeatTimers.delete(ownerId); + } + }, + + async reclaimExpiredLeases(now: UnixMillis): Promise { + // Delegate to the queue implementation which uses the lease_expiresAt index + // for efficient scanning and updates storage atomically. + return queue.reclaimExpiredLeases(now); + }, + + isLeaseExpired(lease: Lease, now: UnixMillis): boolean { + return lease.expiresAt < now; + }, + + createLease(ownerId: string, now: UnixMillis): Lease { + return { + ownerId, + expiresAt: now + config.leaseTtlMs, + }; + }, + + dispose(): void { + for (const timer of heartbeatTimers.values()) { + clearInterval(timer); + } + heartbeatTimers.clear(); + }, + }; +} + +/** + * 生成唯一的 owner ID + * @description 用于标识当前 Service Worker 实例 + */ +export function generateOwnerId(): string { + return `sw_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts new file mode 100644 index 0000000..ec8fd2c --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts @@ -0,0 +1,199 @@ +/** + * @fileoverview RunQueue 接口定义 + * @description 定义 Run 队列的管理接口 + */ + +import type { JsonObject, UnixMillis } from '../../domain/json'; +import type { FlowId, NodeId, RunId } from '../../domain/ids'; +import type { TriggerFireContext } from '../../domain/triggers'; + +/** + * RunQueue 配置 + */ +export interface RunQueueConfig { + /** 最大并行 Run 数量 */ + maxParallelRuns: number; + /** 租约 TTL(毫秒) */ + leaseTtlMs: number; + /** 心跳间隔(毫秒) */ + heartbeatIntervalMs: number; +} + +/** + * 默认队列配置 + */ +export const DEFAULT_QUEUE_CONFIG: RunQueueConfig = { + maxParallelRuns: 3, + leaseTtlMs: 15_000, + heartbeatIntervalMs: 5_000, +}; + +/** + * 队列项状态 + */ +export type QueueItemStatus = 'queued' | 'running' | 'paused'; + +/** + * 租约信息 + */ +export interface Lease { + /** 持有者 ID */ + ownerId: string; + /** 过期时间 */ + expiresAt: UnixMillis; +} + +/** + * RunQueue 队列项 + */ +export interface RunQueueItem { + /** Run ID */ + id: RunId; + /** Flow ID */ + flowId: FlowId; + /** 状态 */ + status: QueueItemStatus; + /** 创建时间 */ + createdAt: UnixMillis; + /** 更新时间 */ + updatedAt: UnixMillis; + /** 优先级(数字越大优先级越高) */ + priority: number; + /** 当前尝试次数 */ + attempt: number; + /** 最大尝试次数 */ + maxAttempts: number; + /** Tab ID */ + tabId?: number; + /** 运行参数 */ + args?: JsonObject; + /** 触发器上下文 */ + trigger?: TriggerFireContext; + /** 租约信息 */ + lease?: Lease; + /** 调试配置 */ + debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean }; +} + +/** + * 入队请求(不含自动生成的字段) + * - priority 默认为 0 + * - maxAttempts 默认为 1 + */ +export type EnqueueInput = Omit< + RunQueueItem, + 'status' | 'createdAt' | 'updatedAt' | 'attempt' | 'lease' | 'priority' | 'maxAttempts' +> & { + id: RunId; + /** 优先级(数字越大优先级越高,默认 0) */ + priority?: number; + /** 最大尝试次数(默认 1) */ + maxAttempts?: number; +}; + +/** + * RunQueue 接口 + * @description 管理 Run 的队列和调度 + */ +export interface RunQueue { + /** + * 入队 + * @param input 入队请求 + * @returns 队列项 + */ + enqueue(input: EnqueueInput): Promise; + + /** + * 领取下一个可执行的 Run + * @param ownerId 领取者 ID + * @param now 当前时间 + * @returns 队列项或 null + */ + claimNext(ownerId: string, now: UnixMillis): Promise; + + /** + * 续约心跳 + * @param ownerId 领取者 ID + * @param now 当前时间 + */ + heartbeat(ownerId: string, now: UnixMillis): Promise; + + /** + * 回收过期租约 + * @description 将 lease.expiresAt < now 的 running/paused 项回收为 queued + * @param now 当前时间 + * @returns 被回收的 Run ID 列表 + */ + reclaimExpiredLeases(now: UnixMillis): Promise; + + /** + * 恢复孤儿租约(SW 重启后调用) + * @description + * - 将孤儿 running 项回收为 queued(status -> queued,租约清除) + * - 将孤儿 paused 项接管(保持 status=paused,租约 ownerId 更新为新 ownerId) + * @param ownerId 新的 ownerId(当前 Service Worker 实例) + * @param now 当前时间 + * @returns 受影响的 runId 列表(含原 ownerId 用于审计) + */ + recoverOrphanLeases( + ownerId: string, + now: UnixMillis, + ): Promise<{ + requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>; + adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>; + }>; + + /** + * 标记为 running + */ + markRunning(runId: RunId, ownerId: string, now: UnixMillis): Promise; + + /** + * 标记为 paused + */ + markPaused(runId: RunId, ownerId: string, now: UnixMillis): Promise; + + /** + * 标记为完成(从队列移除) + */ + markDone(runId: RunId, now: UnixMillis): Promise; + + /** + * 取消 Run + */ + cancel(runId: RunId, now: UnixMillis, reason?: string): Promise; + + /** + * 获取队列项 + */ + get(runId: RunId): Promise; + + /** + * 列出队列项 + */ + list(status?: QueueItemStatus): Promise; +} + +/** + * 创建 NotImplemented 的 RunQueue + * @description Phase 0 占位实现 + */ +export function createNotImplementedQueue(): RunQueue { + const notImplemented = () => { + throw new Error('RunQueue not implemented'); + }; + + return { + enqueue: async () => notImplemented(), + claimNext: async () => notImplemented(), + heartbeat: async () => notImplemented(), + reclaimExpiredLeases: async () => notImplemented(), + recoverOrphanLeases: async () => notImplemented(), + markRunning: async () => notImplemented(), + markPaused: async () => notImplemented(), + markDone: async () => notImplemented(), + cancel: async () => notImplemented(), + get: async () => notImplemented(), + list: async () => notImplemented(), + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts new file mode 100644 index 0000000..75da4b6 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts @@ -0,0 +1,336 @@ +/** + * @fileoverview RunQueue scheduler (maxParallelRuns) + * @description + * Orchestrates atomic claims from RunQueue and launches execution with an injected executor. + * + * Responsibilities: + * - Enforce maxParallelRuns (per scheduler instance) + * - Backfill available slots when runs complete + * - Periodically reclaim expired leases (best-effort) + * - Start/stop lease heartbeats via LeaseManager + * - Acquire/release keepalive to prevent MV3 SW termination (P3-05) + * + * Non-responsibilities: + * - Run execution details (Flow loading, tab allocation, etc.) are injected via RunExecutor + */ + +import type { UnixMillis } from '../../domain/json'; +import type { RunId } from '../../domain/ids'; +import type { LeaseManager } from './leasing'; +import type { RunQueue, RunQueueConfig, RunQueueItem } from './queue'; +import type { KeepaliveController } from '../keepalive/offscreen-keepalive'; + +// ==================== Types ==================== + +/** + * Run executor contract: + * - Resolve when the run reaches a terminal state (succeeded/failed/canceled). + * - Throw/reject only for unexpected infrastructure errors. + */ +export type RunExecutor = (item: RunQueueItem) => Promise; + +/** + * Scheduler tuning parameters + */ +export interface RunSchedulerTuning { + /** + * Poll interval for queue consumption fallback. + * Set to 0 to disable polling (kick-only). + */ + pollIntervalMs?: number; + + /** + * Minimum interval between lease reclaim scans. + * Set to 0 to disable periodic reclaim (not recommended in production). + */ + reclaimIntervalMs?: number; +} + +/** + * Scheduler dependencies (dependency injection) + */ +export interface RunSchedulerDeps { + queue: Pick; + leaseManager: Pick; + keepalive: Pick; + config: RunQueueConfig; + ownerId: string; + execute: RunExecutor; + now?: () => UnixMillis; + tuning?: RunSchedulerTuning; + logger?: Pick; +} + +/** + * Scheduler state for inspection + */ +export interface RunSchedulerState { + started: boolean; + ownerId: string; + maxParallelRuns: number; + activeRunIds: RunId[]; +} + +/** + * Scheduler interface + */ +export interface RunScheduler { + /** Start the scheduler */ + start(): void; + /** Stop the scheduler */ + stop(): void; + /** + * Trigger a scheduling pass. + * Safe to call frequently; re-entrancy is coalesced. + */ + kick(): Promise; + /** Get current state */ + getState(): RunSchedulerState; + /** Dispose the scheduler */ + dispose(): void; +} + +// ==================== Constants ==================== + +const DEFAULT_POLL_INTERVAL_MS = 500; + +// ==================== Helpers ==================== + +function clampNonNegativeInt(value: unknown, fallback: number): number { + const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback; + return Math.max(0, n); +} + +function defaultReclaimIntervalMs(leaseTtlMs: number): number { + const ttl = clampNonNegativeInt(leaseTtlMs, 0); + // Reclaim at most every ~TTL/2, but never less than 1s to avoid tight loops. + return Math.max(1_000, Math.floor(ttl / 2)); +} + +// ==================== Factory ==================== + +/** + * Create a RunScheduler + * + * Scheduling model: + * - Concurrency is enforced by an in-memory set of active runIds. + * - Ordering is delegated to RunQueue.claimNext() (priority DESC, createdAt ASC). + * + * MV3 Service Worker may be suspended/restarted, so we use a "kick + polling" strategy: + * - kick: Immediate scheduling trigger on enqueue/completion (low latency) + * - polling: Fallback to ensure queue is consumed even if caller forgets to kick + */ +export function createRunScheduler(deps: RunSchedulerDeps): RunScheduler { + const logger = deps.logger ?? console; + + if (!deps.ownerId) { + throw new Error('ownerId is required'); + } + + const now = deps.now ?? (() => Date.now()); + const maxParallelRuns = clampNonNegativeInt(deps.config.maxParallelRuns, 0); + const pollIntervalMs = clampNonNegativeInt( + deps.tuning?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + DEFAULT_POLL_INTERVAL_MS, + ); + const reclaimIntervalMs = clampNonNegativeInt( + deps.tuning?.reclaimIntervalMs ?? defaultReclaimIntervalMs(deps.config.leaseTtlMs), + defaultReclaimIntervalMs(deps.config.leaseTtlMs), + ); + + let started = false; + let pollTimer: ReturnType | null = null; + let releaseKeepalive: (() => void) | null = null; + + const activeRunIds = new Set(); + + // Coalesced re-entrancy control for tick() + let pendingKick = false; + let pumpPromise: Promise | null = null; + + let lastReclaimAt: UnixMillis | null = null; + + /** + * Single scheduling tick: + * 1. Reclaim expired leases (if interval elapsed) + * 2. Fill available slots up to maxParallelRuns + */ + async function tick(): Promise { + const t = now(); + + // Best-effort lease reclaim (disabled when reclaimIntervalMs === 0) + if (reclaimIntervalMs > 0) { + const shouldReclaim = lastReclaimAt === null || t - lastReclaimAt >= reclaimIntervalMs; + if (shouldReclaim) { + lastReclaimAt = t; + try { + await deps.leaseManager.reclaimExpiredLeases(t); + } catch (e) { + logger.warn('[RunScheduler] reclaimExpiredLeases failed:', e); + } + } + } + + // Fill available slots up to maxParallelRuns + // + // Note: `stop()` can be called while an async claim is in-flight. Guard the loop + // with `started` to prevent claiming additional items after stop is requested. + while (started && activeRunIds.size < maxParallelRuns) { + let claimed: RunQueueItem | null = null; + try { + claimed = await deps.queue.claimNext(deps.ownerId, t); + } catch (e) { + logger.error('[RunScheduler] claimNext failed:', e); + return; + } + + if (!claimed) return; + + // Guard against double-launch within the same scheduler instance + if (activeRunIds.has(claimed.id)) { + logger.error( + `[RunScheduler] Invariant violation: run "${claimed.id}" was claimed twice in the same scheduler instance`, + ); + // Best-effort cleanup: avoid a stuck running entry + void deps.queue + .markDone(claimed.id, now()) + .catch((err) => + logger.warn('[RunScheduler] markDone after duplicate claim failed:', err), + ); + continue; + } + + activeRunIds.add(claimed.id); + + // Capture claimed item for the closure + const claimedItem = claimed; + + const runPromise = Promise.resolve() + .then(() => deps.execute(claimedItem)) + .catch((e) => { + // If execution failed unexpectedly, log but still cleanup + logger.error(`[RunScheduler] execute failed for run "${claimedItem.id}":`, e); + }) + .finally(async () => { + activeRunIds.delete(claimedItem.id); + try { + await deps.queue.markDone(claimedItem.id, now()); + } catch (e) { + logger.warn(`[RunScheduler] markDone failed for run "${claimedItem.id}":`, e); + } + + // Backfill immediately when a slot frees up + if (started) { + void kick(); + } + }); + + // Ensure no floating promise warnings + void runPromise; + } + } + + /** + * Pump loop: keeps running while pendingKick is set + */ + async function pump(): Promise { + try { + while (started && pendingKick) { + pendingKick = false; + try { + await tick(); + } catch (e) { + logger.error('[RunScheduler] tick failed:', e); + } + } + } finally { + pumpPromise = null; + } + } + + function start(): void { + if (started) return; + started = true; + + // Acquire keepalive to prevent MV3 SW termination + try { + releaseKeepalive = deps.keepalive.acquire('scheduler'); + } catch (e) { + logger.warn('[RunScheduler] keepalive.acquire failed:', e); + releaseKeepalive = null; + } + + try { + deps.leaseManager.startHeartbeat(deps.ownerId); + } catch (e) { + logger.warn('[RunScheduler] startHeartbeat failed:', e); + } + + if (pollIntervalMs > 0) { + pollTimer = setInterval(() => { + void kick(); + }, pollIntervalMs); + } + + void kick(); + } + + function stop(): void { + if (!started) return; + + if (activeRunIds.size > 0) { + logger.warn( + `[RunScheduler] stop() called with ${activeRunIds.size} active runs; heartbeats will stop and leases may expire/reclaim concurrently`, + ); + } + + started = false; + + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + + try { + deps.leaseManager.stopHeartbeat(deps.ownerId); + } catch (e) { + logger.warn('[RunScheduler] stopHeartbeat failed:', e); + } + + // Release keepalive + if (releaseKeepalive) { + try { + releaseKeepalive(); + } catch (e) { + logger.warn('[RunScheduler] keepalive release failed:', e); + } + releaseKeepalive = null; + } + } + + function kick(): Promise { + if (!started) return Promise.resolve(); + + pendingKick = true; + if (!pumpPromise) { + pumpPromise = pump(); + } + return pumpPromise; + } + + function getState(): RunSchedulerState { + return { + started, + ownerId: deps.ownerId, + maxParallelRuns, + activeRunIds: Array.from(activeRunIds), + }; + } + + function dispose(): void { + stop(); + activeRunIds.clear(); + } + + return { start, stop, kick, getState, dispose }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts new file mode 100644 index 0000000..2e5bba3 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Recovery module exports + * @description 崩溃恢复模块导出 + */ + +export * from './recovery-coordinator'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts new file mode 100644 index 0000000..45d9c7c --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts @@ -0,0 +1,260 @@ +/** + * @fileoverview 崩溃恢复协调器 (P3-06) + * @description + * MV3 Service Worker 可能随时被终止。此协调器在 SW 启动时协调队列状态和 Run 记录, + * 使中断的 Run 能够被恢复执行。 + * + * 恢复策略: + * - 孤儿 running 项:回收为 queued,等待重新调度(从头重跑) + * - 孤儿 paused 项:接管 lease,保持 paused 状态 + * - 已终态 Run 的队列残留:清理 + * + * 调用时机: + * - 必须在 scheduler.start() 之前调用 + * - 通常在 SW 启动时调用一次 + */ + +import type { UnixMillis } from '../../domain/json'; +import type { RunId } from '../../domain/ids'; +import { isTerminalStatus, type RunStatus } from '../../domain/events'; +import type { StoragePort } from '../storage/storage-port'; +import type { EventsBus } from '../transport/events-bus'; + +// ==================== Types ==================== + +/** + * 恢复结果 + */ +export interface RecoveryResult { + /** 被回收为 queued 的 running Run ID */ + requeuedRunning: RunId[]; + /** 被接管的 paused Run ID */ + adoptedPaused: RunId[]; + /** 被清理的已终态 Run ID */ + cleanedTerminal: RunId[]; +} + +/** + * 恢复协调器依赖 + */ +export interface RecoveryCoordinatorDeps { + /** 存储层 */ + storage: StoragePort; + /** 事件总线 */ + events: EventsBus; + /** 当前 Service Worker 的 ownerId */ + ownerId: string; + /** 时间源 */ + now: () => UnixMillis; + /** 日志器 */ + logger?: Pick; +} + +// ==================== Main Function ==================== + +/** + * 执行崩溃恢复 + * @description + * 在 SW 启动时调用,协调队列状态和 Run 记录。 + * + * 执行顺序: + * 1. 预清理:检查队列中的所有项,清理已终态或无对应 RunRecord 的残留 + * 2. 恢复孤儿租约:回收 running,接管 paused + * 3. 同步 RunRecord 状态:确保 RunRecord 与队列状态一致 + * 4. 发送恢复事件:为 requeued running 项发送 run.recovered 事件 + */ +export async function recoverFromCrash(deps: RecoveryCoordinatorDeps): Promise { + const logger = deps.logger ?? console; + + if (!deps.ownerId) { + throw new Error('ownerId is required'); + } + + const now = deps.now(); + + // 设计理由:恢复过程必须"先清理后接管/回收",否则可能把已经终态的 Run 重新排队执行 + const cleanedTerminalSet = new Set(); + + // ==================== Step 1: 预清理 ==================== + // 检查队列中的所有项,清理已终态或无对应 RunRecord 的残留 + try { + const items = await deps.storage.queue.list(); + for (const item of items) { + const runId = item.id; + const run = await deps.storage.runs.get(runId); + + // 防御性清理:无 RunRecord 的队列项无法执行 + if (!run) { + try { + await deps.storage.queue.markDone(runId, now); + cleanedTerminalSet.add(runId); + logger.debug(`[Recovery] Cleaned orphan queue item without RunRecord: ${runId}`); + } catch (e) { + logger.warn('[Recovery] markDone for missing RunRecord failed:', runId, e); + } + continue; + } + + // 清理已终态的 Run(SW 可能在 runner 完成后、scheduler markDone 前崩溃) + if (isTerminalStatus(run.status)) { + try { + await deps.storage.queue.markDone(runId, now); + cleanedTerminalSet.add(runId); + logger.debug(`[Recovery] Cleaned terminal queue item: ${runId} (status=${run.status})`); + } catch (e) { + logger.warn('[Recovery] markDone for terminal run failed:', runId, e); + } + } + } + } catch (e) { + logger.warn('[Recovery] Pre-clean failed:', e); + } + + // ==================== Step 2: 恢复孤儿租约 ==================== + // Best-effort:即使失败也不应该阻止启动 + let requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = []; + let adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = []; + try { + const result = await deps.storage.queue.recoverOrphanLeases(deps.ownerId, now); + requeuedRunning = result.requeuedRunning; + adoptedPaused = result.adoptedPaused; + } catch (e) { + logger.error('[Recovery] recoverOrphanLeases failed:', e); + // 继续执行,不阻止启动 + } + + // ==================== Step 3: 同步 RunRecord 状态 ==================== + const requeuedRunningIds: RunId[] = []; + for (const entry of requeuedRunning) { + const runId = entry.runId; + requeuedRunningIds.push(runId); + + // 跳过在 Step 1 中已清理的项 + if (cleanedTerminalSet.has(runId)) { + continue; + } + + try { + const run = await deps.storage.runs.get(runId); + if (!run) { + // RunRecord 不存在,清理队列项(防御性) + try { + await deps.storage.queue.markDone(runId, now); + cleanedTerminalSet.add(runId); + } catch (markDoneErr) { + logger.warn( + '[Recovery] markDone for missing RunRecord in Step3 failed:', + runId, + markDoneErr, + ); + } + continue; + } + + // 跳过已终态的 Run(可能在恢复过程中被其他逻辑更新) + // 同时清理队列项,防止残留 + if (isTerminalStatus(run.status)) { + try { + await deps.storage.queue.markDone(runId, now); + cleanedTerminalSet.add(runId); + logger.debug( + `[Recovery] Cleaned terminal queue item in Step3: ${runId} (status=${run.status})`, + ); + } catch (markDoneErr) { + logger.warn('[Recovery] markDone for terminal run in Step3 failed:', runId, markDoneErr); + } + continue; + } + + // 更新 RunRecord 状态为 queued + await deps.storage.runs.patch(runId, { status: 'queued', updatedAt: now }); + + // 发送恢复事件(best-effort,失败不影响恢复流程) + try { + const fromStatus: 'running' | 'paused' = run.status === 'paused' ? 'paused' : 'running'; + await deps.events.append({ + runId, + type: 'run.recovered', + reason: 'sw_restart', + fromStatus, + toStatus: 'queued', + prevOwnerId: entry.prevOwnerId, + ts: now, + }); + logger.info(`[Recovery] Requeued orphan running run: ${runId} (from=${fromStatus})`); + } catch (eventErr) { + logger.warn('[Recovery] Failed to emit run.recovered event:', runId, eventErr); + // 继续执行,不影响恢复流程 + } + } catch (e) { + logger.warn('[Recovery] Reconcile requeued running failed:', runId, e); + } + } + + // ==================== Step 4: 同步 adopted paused 的 RunRecord ==================== + const adoptedPausedIds: RunId[] = []; + for (const entry of adoptedPaused) { + const runId = entry.runId; + adoptedPausedIds.push(runId); + + // 跳过在 Step 1 中已清理的项 + if (cleanedTerminalSet.has(runId)) { + continue; + } + + try { + const run = await deps.storage.runs.get(runId); + if (!run) { + // RunRecord 不存在,清理队列项(防御性) + try { + await deps.storage.queue.markDone(runId, now); + cleanedTerminalSet.add(runId); + } catch (markDoneErr) { + logger.warn( + '[Recovery] markDone for missing RunRecord in Step4 failed:', + runId, + markDoneErr, + ); + } + continue; + } + + // 跳过已终态的 Run,同时清理队列项 + if (isTerminalStatus(run.status)) { + try { + await deps.storage.queue.markDone(runId, now); + cleanedTerminalSet.add(runId); + logger.debug( + `[Recovery] Cleaned terminal queue item in Step4: ${runId} (status=${run.status})`, + ); + } catch (markDoneErr) { + logger.warn('[Recovery] markDone for terminal run in Step4 failed:', runId, markDoneErr); + } + continue; + } + + // 如果 RunRecord 状态不是 paused,同步更新 + if (run.status !== 'paused') { + await deps.storage.runs.patch(runId, { status: 'paused' as RunStatus, updatedAt: now }); + } + + logger.info(`[Recovery] Adopted orphan paused run: ${runId}`); + } catch (e) { + logger.warn('[Recovery] Reconcile adopted paused failed:', runId, e); + } + } + + const result: RecoveryResult = { + requeuedRunning: requeuedRunningIds, + adoptedPaused: adoptedPausedIds, + cleanedTerminal: Array.from(cleanedTerminalSet), + }; + + logger.info('[Recovery] Complete:', { + requeuedRunning: result.requeuedRunning.length, + adoptedPaused: result.adoptedPaused.length, + cleanedTerminal: result.cleanedTerminal.length, + }); + + return result; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts new file mode 100644 index 0000000..df3f3a7 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts @@ -0,0 +1,5 @@ +/** + * @fileoverview Engine Storage 模块导出入口 + */ + +export * from './storage-port'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts new file mode 100644 index 0000000..0ab9df6 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview StoragePort 接口定义 + * @description 定义 Storage 层的抽象接口,用于依赖注入 + */ + +import type { FlowId, RunId, TriggerId } from '../../domain/ids'; +import type { FlowV3 } from '../../domain/flow'; +import type { RunEvent, RunEventInput, RunRecordV3 } from '../../domain/events'; +import type { PersistentVarRecord, PersistentVariableName } from '../../domain/variables'; +import type { TriggerSpec } from '../../domain/triggers'; +import type { RunQueue } from '../queue/queue'; + +/** + * FlowsStore 接口 + */ +export interface FlowsStore { + /** 列出所有 Flow */ + list(): Promise; + /** 获取单个 Flow */ + get(id: FlowId): Promise; + /** 保存 Flow */ + save(flow: FlowV3): Promise; + /** 删除 Flow */ + delete(id: FlowId): Promise; +} + +/** + * RunsStore 接口 + */ +export interface RunsStore { + /** 列出所有 Run 记录 */ + list(): Promise; + /** 获取单个 Run 记录 */ + get(id: RunId): Promise; + /** 保存 Run 记录 */ + save(record: RunRecordV3): Promise; + /** 部分更新 Run 记录 */ + patch(id: RunId, patch: Partial): Promise; +} + +/** + * EventsStore 接口 + * @description seq 分配必须由 append() 内部原子完成 + */ +export interface EventsStore { + /** + * 追加事件并原子分配 seq + * @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq + * @param event 事件输入(不含 seq) + * @returns 完整事件(含分配的 seq 和 ts) + */ + append(event: RunEventInput): Promise; + + /** + * 列出事件 + * @param runId Run ID + * @param opts 查询选项 + */ + list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise; +} + +/** + * PersistentVarsStore 接口 + */ +export interface PersistentVarsStore { + /** 获取持久化变量 */ + get(key: PersistentVariableName): Promise; + /** 设置持久化变量 */ + set( + key: PersistentVariableName, + value: PersistentVarRecord['value'], + ): Promise; + /** 删除持久化变量 */ + delete(key: PersistentVariableName): Promise; + /** 列出持久化变量 */ + list(prefix?: PersistentVariableName): Promise; +} + +/** + * TriggersStore 接口 + */ +export interface TriggersStore { + /** 列出所有触发器 */ + list(): Promise; + /** 获取单个触发器 */ + get(id: TriggerId): Promise; + /** 保存触发器 */ + save(spec: TriggerSpec): Promise; + /** 删除触发器 */ + delete(id: TriggerId): Promise; +} + +/** + * StoragePort 接口 + * @description 聚合所有存储接口,用于依赖注入 + */ +export interface StoragePort { + /** Flows 存储 */ + flows: FlowsStore; + /** Runs 存储 */ + runs: RunsStore; + /** Events 存储 */ + events: EventsStore; + /** Queue 存储 */ + queue: RunQueue; + /** 持久化变量存储 */ + persistentVars: PersistentVarsStore; + /** 触发器存储 */ + triggers: TriggersStore; +} + +/** + * 创建 NotImplemented 的 Store + * @description 避免 Proxy 生成 'then' 导致 thenable 行为 + */ +function createNotImplementedStore(name: string): T { + const target = {} as T; + return new Proxy(target, { + get(_, prop) { + // Avoid thenable behavior by returning undefined for 'then' + if (prop === 'then') { + return undefined; + } + return async () => { + throw new Error(`${name}.${String(prop)} not implemented`); + }; + }, + }); +} + +/** + * 创建 NotImplemented 的 StoragePort + * @description Phase 0 占位实现 + */ +export function createNotImplementedStoragePort(): StoragePort { + return { + flows: createNotImplementedStore('FlowsStore'), + runs: createNotImplementedStore('RunsStore'), + events: createNotImplementedStore('EventsStore'), + queue: createNotImplementedStore('RunQueue'), + persistentVars: createNotImplementedStore('PersistentVarsStore'), + triggers: createNotImplementedStore('TriggersStore'), + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts new file mode 100644 index 0000000..2ecd93d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview EventsBus Interface and Implementation + * @description Event subscription, publishing, and persistence + */ + +import type { RunId } from '../../domain/ids'; +import type { RunEvent, RunEventInput, Unsubscribe } from '../../domain/events'; +import type { EventsStore } from '../storage/storage-port'; + +/** + * Event query parameters + */ +export interface EventsQuery { + /** Run ID */ + runId: RunId; + /** Starting sequence number (inclusive) */ + fromSeq?: number; + /** Maximum number of results */ + limit?: number; +} + +/** + * Subscription filter + */ +export interface EventsFilter { + /** Only receive events for this Run */ + runId?: RunId; +} + +/** + * EventsBus Interface + * @description Responsible for event subscription, publishing, and persistence + */ +export interface EventsBus { + /** + * Subscribe to events + * @param listener Event listener + * @param filter Optional filter + * @returns Unsubscribe function + */ + subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe; + + /** + * Append event + * @description Delegates to EventsStore for atomic seq allocation, then broadcasts + * @param event Event input (without seq) + * @returns Complete event (with seq and ts) + */ + append(event: RunEventInput): Promise; + + /** + * Query historical events + * @param query Query parameters + * @returns Events sorted by seq ascending + */ + list(query: EventsQuery): Promise; +} + +/** + * Create NotImplemented EventsBus + * @description Phase 0 placeholder + */ +export function createNotImplementedEventsBus(): EventsBus { + const notImplemented = () => { + throw new Error('EventsBus not implemented'); + }; + + return { + subscribe: () => { + notImplemented(); + return () => {}; + }, + append: async () => notImplemented(), + list: async () => notImplemented(), + }; +} + +/** + * Listener entry for subscription management + */ +interface ListenerEntry { + listener: (event: RunEvent) => void; + filter?: EventsFilter; +} + +/** + * Storage-backed EventsBus Implementation + * @description + * - seq allocation is done by EventsStore.append() (atomic with RunRecordV3.nextSeq) + * - broadcast happens only after append resolves (i.e. after commit) + */ +export class StorageBackedEventsBus implements EventsBus { + private listeners = new Set(); + + constructor(private readonly store: EventsStore) {} + + subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe { + const entry: ListenerEntry = { listener, filter }; + this.listeners.add(entry); + return () => { + this.listeners.delete(entry); + }; + } + + async append(input: RunEventInput): Promise { + // Delegate to storage for atomic seq allocation + const event = await this.store.append(input); + + // Broadcast after successful commit + this.broadcast(event); + + return event; + } + + async list(query: EventsQuery): Promise { + return this.store.list(query.runId, { + fromSeq: query.fromSeq, + limit: query.limit, + }); + } + + /** + * Broadcast event to all matching listeners + */ + private broadcast(event: RunEvent): void { + const { runId } = event; + for (const { listener, filter } of this.listeners) { + if (!filter || !filter.runId || filter.runId === runId) { + try { + listener(event); + } catch (error) { + console.error('[StorageBackedEventsBus] Listener error:', error); + } + } + } + } +} + +/** + * In-memory EventsBus for testing + * @description Uses internal seq counter, NOT suitable for production + * @deprecated Use StorageBackedEventsBus with mock EventsStore for testing + */ +export class InMemoryEventsBus implements EventsBus { + private events = new Map(); + private seqCounters = new Map(); + private listeners = new Set(); + + subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe { + const entry: ListenerEntry = { listener, filter }; + this.listeners.add(entry); + return () => { + this.listeners.delete(entry); + }; + } + + async append(input: RunEventInput): Promise { + const { runId } = input; + + // Allocate seq (NOT atomic, for testing only) + const currentSeq = this.seqCounters.get(runId) ?? 0; + const seq = currentSeq + 1; + this.seqCounters.set(runId, seq); + + // Create complete event + const event: RunEvent = { + ...input, + seq, + ts: input.ts ?? Date.now(), + } as RunEvent; + + // Store + const runEvents = this.events.get(runId) ?? []; + runEvents.push(event); + this.events.set(runId, runEvents); + + // Broadcast + for (const { listener, filter } of this.listeners) { + if (!filter || !filter.runId || filter.runId === runId) { + try { + listener(event); + } catch (error) { + console.error('[InMemoryEventsBus] Listener error:', error); + } + } + } + + return event; + } + + async list(query: EventsQuery): Promise { + const runEvents = this.events.get(query.runId) ?? []; + + let result = runEvents; + + if (query.fromSeq !== undefined) { + result = result.filter((e) => e.seq >= query.fromSeq!); + } + + if (query.limit !== undefined) { + result = result.slice(0, query.limit); + } + + return result; + } + + /** + * Clear all data (for testing) + */ + clear(): void { + this.events.clear(); + this.seqCounters.clear(); + this.listeners.clear(); + } + + /** + * Get current seq for a run (for testing) + */ + getSeq(runId: RunId): number { + return this.seqCounters.get(runId) ?? 0; + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/index.ts new file mode 100644 index 0000000..f9a62c5 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/index.ts @@ -0,0 +1,7 @@ +/** + * @fileoverview Transport 模块导出入口 + */ + +export * from './rpc'; +export * from './rpc-server'; +export * from './events-bus'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts new file mode 100644 index 0000000..810fcd1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts @@ -0,0 +1,1168 @@ +/** + * @fileoverview RPC Server Implementation + * @description Handles RPC requests from UI via chrome.runtime.Port + */ + +import type { ISODateTimeString, JsonObject, JsonValue } from '../../domain/json'; +import type { EdgeId, FlowId, NodeId, RunId, TriggerId } from '../../domain/ids'; +import type { DebuggerCommand } from '../../domain/debug'; +import type { RunEvent } from '../../domain/events'; +import type { FlowV3, NodeV3, EdgeV3 } from '../../domain/flow'; +import { FLOW_SCHEMA_VERSION as CURRENT_FLOW_SCHEMA_VERSION } from '../../domain/flow'; +import type { VariableDefinition } from '../../domain/variables'; +import type { TriggerKind, TriggerSpec } from '../../domain/triggers'; +import type { StoragePort } from '../storage/storage-port'; +import type { EventsBus } from './events-bus'; +import type { DebugController, RunnerRegistry } from '../kernel/debug-controller'; +import type { RunScheduler } from '../queue/scheduler'; +import type { QueueItemStatus } from '../queue/queue'; +import { enqueueRun } from '../queue/enqueue-run'; +import type { TriggerManager } from '../triggers/trigger-manager'; +import { + RR_V3_PORT_NAME, + isRpcRequest, + createRpcResponseOk, + createRpcResponseErr, + createRpcEventMessage, + type RpcRequest, +} from './rpc'; + +/** + * RPC Server 配置 + */ +export interface RpcServerConfig { + storage: StoragePort; + events: EventsBus; + debugController?: DebugController; + runners?: RunnerRegistry; + scheduler?: RunScheduler; + triggerManager?: TriggerManager; + /** ID 生成器(用于测试注入) */ + generateRunId?: () => RunId; + /** 时间源(用于测试注入) */ + now?: () => number; +} + +/** + * 活跃的 Port 连接 + */ +interface PortConnection { + port: chrome.runtime.Port; + subscriptions: Set; // null means subscribe to all +} + +/** + * 默认 RunId 生成器 + */ +function defaultGenerateRunId(): RunId { + return `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * RPC Server + * @description 处理来自 UI 的 RPC 请求 + */ +export class RpcServer { + private readonly storage: StoragePort; + private readonly events: EventsBus; + private readonly debugController?: DebugController; + private readonly runners?: RunnerRegistry; + private readonly scheduler?: RunScheduler; + private readonly triggerManager?: TriggerManager; + private readonly generateRunId: () => RunId; + private readonly now: () => number; + private readonly connections = new Map(); + private eventUnsubscribe: (() => void) | null = null; + + constructor(config: RpcServerConfig) { + this.storage = config.storage; + this.events = config.events; + this.debugController = config.debugController; + this.runners = config.runners; + this.scheduler = config.scheduler; + this.triggerManager = config.triggerManager; + this.generateRunId = config.generateRunId ?? defaultGenerateRunId; + this.now = config.now ?? Date.now; + } + + /** + * 启动 RPC Server + */ + start(): void { + chrome.runtime.onConnect.addListener(this.handleConnect); + + // Subscribe to all events and broadcast to connected ports + this.eventUnsubscribe = this.events.subscribe((event) => { + this.broadcastEvent(event); + }); + } + + /** + * 停止 RPC Server + */ + stop(): void { + chrome.runtime.onConnect.removeListener(this.handleConnect); + + if (this.eventUnsubscribe) { + this.eventUnsubscribe(); + this.eventUnsubscribe = null; + } + + // Disconnect all ports + for (const conn of this.connections.values()) { + conn.port.disconnect(); + } + this.connections.clear(); + } + + /** + * 处理新连接 + */ + private handleConnect = (port: chrome.runtime.Port): void => { + if (port.name !== RR_V3_PORT_NAME) return; + + const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const connection: PortConnection = { + port, + subscriptions: new Set(), + }; + + this.connections.set(connId, connection); + + port.onMessage.addListener((msg) => this.handleMessage(connId, msg)); + port.onDisconnect.addListener(() => this.handleDisconnect(connId)); + }; + + /** + * 处理消息 + */ + private handleMessage = async (connId: string, msg: unknown): Promise => { + if (!isRpcRequest(msg)) return; + + const conn = this.connections.get(connId); + if (!conn) return; + + try { + const result = await this.handleRequest(msg, conn); + conn.port.postMessage(createRpcResponseOk(msg.requestId, result)); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + conn.port.postMessage(createRpcResponseErr(msg.requestId, error)); + } + }; + + /** + * 处理断开连接 + */ + private handleDisconnect = (connId: string): void => { + this.connections.delete(connId); + }; + + /** + * 广播事件 + */ + private broadcastEvent(event: RunEvent): void { + const message = createRpcEventMessage(event); + + for (const conn of this.connections.values()) { + // Check if this connection subscribed to this event + const subs = conn.subscriptions; + if (subs.size === 0) continue; // No subscriptions + if (subs.has(null) || subs.has(event.runId)) { + try { + conn.port.postMessage(message); + } catch { + // Port may be disconnected + } + } + } + } + + // ===== Queue Management Handlers ===== + + /** + * 处理 enqueueRun 请求 + * @description 委托给共享的 enqueueRun 服务 + */ + private async handleEnqueueRun(params: JsonObject | undefined): Promise { + const result = await enqueueRun( + { + storage: this.storage, + events: this.events, + scheduler: this.scheduler, + generateRunId: this.generateRunId, + now: this.now, + }, + { + flowId: params?.flowId as FlowId, + startNodeId: params?.startNodeId as NodeId | undefined, + priority: params?.priority as number | undefined, + maxAttempts: params?.maxAttempts as number | undefined, + args: params?.args as JsonObject | undefined, + debug: params?.debug as { breakpoints?: string[]; pauseOnStart?: boolean } | undefined, + }, + ); + + return result as unknown as JsonValue; + } + + /** + * 处理 listQueue 请求 + * @description 列出队列项,按 priority DESC + createdAt ASC 排序 + */ + private async handleListQueue(params: JsonObject | undefined): Promise { + const rawStatus = params?.status; + + // 校验 status 白名单 + let status: QueueItemStatus | undefined; + if (rawStatus !== undefined) { + if (rawStatus !== 'queued' && rawStatus !== 'running' && rawStatus !== 'paused') { + throw new Error('status must be one of: queued, running, paused'); + } + status = rawStatus; + } + + const items = await this.storage.queue.list(status); + + // 按 priority DESC + createdAt ASC 排序 + items.sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; // DESC + } + return a.createdAt - b.createdAt; // ASC (FIFO) + }); + + return items as unknown as JsonValue; + } + + /** + * 处理 cancelQueueItem 请求 + * @description 取消排队中的队列项,更新 Run 状态,发布 run.canceled 事件 + * @note 仅允许取消 status=queued 的项;running/paused 需使用 rr_v3.cancelRun + */ + private async handleCancelQueueItem(params: JsonObject | undefined): Promise { + const runId = params?.runId as RunId | undefined; + if (!runId) throw new Error('runId is required'); + + const reason = params?.reason as string | undefined; + const now = this.now(); + + // 1. 检查队列项存在 + const queueItem = await this.storage.queue.get(runId); + if (!queueItem) { + throw new Error(`Queue item "${runId}" not found`); + } + + // 2. 仅允许取消 queued 状态(running/paused 需使用 rr_v3.cancelRun) + if (queueItem.status !== 'queued') { + throw new Error( + `Cannot cancel queue item "${runId}" with status "${queueItem.status}"; use rr_v3.cancelRun for running/paused runs`, + ); + } + + // 3. 从队列移除 + await this.storage.queue.cancel(runId, now, reason); + + // 4. 更新 Run 记录状态 + await this.storage.runs.patch(runId, { + status: 'canceled', + updatedAt: now, + finishedAt: now, + }); + + // 5. 发布 run.canceled 事件(通过 EventsBus 以确保广播) + await this.events.append({ + runId, + type: 'run.canceled', + reason, + }); + + return { ok: true, runId }; + } + + /** + * 处理 RPC 请求 + */ + private async handleRequest(request: RpcRequest, conn: PortConnection): Promise { + const { method, params } = request; + + switch (method) { + case 'rr_v3.listRuns': { + const runs = await this.storage.runs.list(); + return runs as unknown as JsonValue; + } + + case 'rr_v3.getRun': { + const runId = params?.runId as RunId | undefined; + if (!runId) throw new Error('runId is required'); + const run = await this.storage.runs.get(runId); + return run as unknown as JsonValue; + } + + case 'rr_v3.getEvents': { + const runId = params?.runId as RunId | undefined; + if (!runId) throw new Error('runId is required'); + const fromSeq = params?.fromSeq as number | undefined; + const limit = params?.limit as number | undefined; + const events = await this.storage.events.list(runId, { fromSeq, limit }); + return events as unknown as JsonValue; + } + + case 'rr_v3.getFlow': { + const flowId = params?.flowId as FlowId | undefined; + if (!flowId) throw new Error('flowId is required'); + const flow = await this.storage.flows.get(flowId); + return flow as unknown as JsonValue; + } + + case 'rr_v3.listFlows': { + const flows = await this.storage.flows.list(); + return flows as unknown as JsonValue; + } + + case 'rr_v3.saveFlow': { + return this.handleSaveFlow(params); + } + + case 'rr_v3.deleteFlow': { + return this.handleDeleteFlow(params); + } + + // ===== Trigger APIs ===== + + case 'rr_v3.createTrigger': + return this.handleCreateTrigger(params); + + case 'rr_v3.updateTrigger': + return this.handleUpdateTrigger(params); + + case 'rr_v3.deleteTrigger': + return this.handleDeleteTrigger(params); + + case 'rr_v3.getTrigger': + return this.handleGetTrigger(params); + + case 'rr_v3.listTriggers': + return this.handleListTriggers(params); + + case 'rr_v3.enableTrigger': + return this.handleEnableTrigger(params); + + case 'rr_v3.disableTrigger': + return this.handleDisableTrigger(params); + + case 'rr_v3.fireTrigger': + return this.handleFireTrigger(params); + + // ===== Queue Management APIs ===== + + case 'rr_v3.enqueueRun': { + return this.handleEnqueueRun(params); + } + + case 'rr_v3.listQueue': { + return this.handleListQueue(params); + } + + case 'rr_v3.cancelQueueItem': { + return this.handleCancelQueueItem(params); + } + + case 'rr_v3.subscribe': { + const runId = (params?.runId as RunId | undefined) ?? null; + conn.subscriptions.add(runId); + return { subscribed: true, runId }; + } + + case 'rr_v3.unsubscribe': { + const runId = (params?.runId as RunId | undefined) ?? null; + conn.subscriptions.delete(runId); + return { unsubscribed: true, runId }; + } + + // Debug method - route to DebugController + case 'rr_v3.debug': { + if (!this.debugController) { + throw new Error('DebugController not configured'); + } + const cmd = params as unknown as DebuggerCommand; + if (!cmd || !cmd.type) { + throw new Error('Invalid debug command'); + } + const response = await this.debugController.handle(cmd); + return response as unknown as JsonValue; + } + + // Control methods + case 'rr_v3.startRun': + // startRun is essentially enqueueRun - the run starts when claimed by scheduler + return this.handleEnqueueRun(params); + + case 'rr_v3.pauseRun': + return this.handlePauseRun(params); + + case 'rr_v3.resumeRun': + return this.handleResumeRun(params); + + case 'rr_v3.cancelRun': + return this.handleCancelRun(params); + + default: + throw new Error(`Unknown method: ${method}`); + } + } + + // ===== Flow Management Handlers ===== + + /** + * 处理 saveFlow 请求 + * @description 保存或更新 Flow,执行完整的结构验证 + */ + private async handleSaveFlow(params: JsonObject | undefined): Promise { + const rawFlow = params?.flow; + if (!rawFlow || typeof rawFlow !== 'object' || Array.isArray(rawFlow)) { + throw new Error('flow is required'); + } + + // 检查是否为更新现有 flow(使用 trim 后的 ID 查询) + const rawId = (rawFlow as JsonObject).id; + let existingFlow: FlowV3 | null = null; + if (typeof rawId === 'string' && rawId.trim()) { + existingFlow = await this.storage.flows.get(rawId.trim() as FlowId); + } + + // 规范化 flow,传入 existingFlow 以继承 createdAt + const flow = this.normalizeFlowSpec(rawFlow, existingFlow); + + // 保存到存储(存储层会执行二次验证) + await this.storage.flows.save(flow); + + return flow as unknown as JsonValue; + } + + /** + * 处理 deleteFlow 请求 + * @description 删除 Flow,先检查是否有关联的 Trigger 和 queued runs + */ + private async handleDeleteFlow(params: JsonObject | undefined): Promise { + const flowId = params?.flowId as FlowId | undefined; + if (!flowId) throw new Error('flowId is required'); + + // 检查 Flow 是否存在 + const existing = await this.storage.flows.get(flowId); + if (!existing) { + throw new Error(`Flow "${flowId}" not found`); + } + + // 检查是否有关联的 Trigger + const triggers = await this.storage.triggers.list(); + const linkedTriggers = triggers.filter((t) => t.flowId === flowId); + if (linkedTriggers.length > 0) { + const triggerIds = linkedTriggers.map((t) => t.id).join(', '); + throw new Error( + `Cannot delete flow "${flowId}": it has ${linkedTriggers.length} linked trigger(s): ${triggerIds}. ` + + `Delete the trigger(s) first.`, + ); + } + + // 检查是否有 queued runs(未执行的 runs 删除后会失败) + const queuedItems = await this.storage.queue.list('queued'); + const linkedQueuedRuns = queuedItems.filter((item) => item.flowId === flowId); + if (linkedQueuedRuns.length > 0) { + const runIds = linkedQueuedRuns.map((r) => r.id).join(', '); + throw new Error( + `Cannot delete flow "${flowId}": it has ${linkedQueuedRuns.length} queued run(s): ${runIds}. ` + + `Cancel the run(s) first or wait for them to complete.`, + ); + } + + // 删除 Flow + await this.storage.flows.delete(flowId); + + return { ok: true, flowId }; + } + + /** + * 规范化 FlowV3 输入 + * @description 验证并转换输入为完整的 FlowV3 结构 + * @param value 原始输入 + * @param existingFlow 已存在的 flow(用于继承 createdAt) + */ + private normalizeFlowSpec(value: unknown, existingFlow: FlowV3 | null = null): FlowV3 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('flow is required'); + } + const raw = value as JsonObject; + + // id 校验与生成 + let id: FlowId; + if (raw.id === undefined || raw.id === null) { + id = `flow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` as FlowId; + } else { + if (typeof raw.id !== 'string' || !raw.id.trim()) { + throw new Error('flow.id must be a non-empty string'); + } + id = raw.id.trim() as FlowId; + } + + // name 校验 + if (!raw.name || typeof raw.name !== 'string' || !raw.name.trim()) { + throw new Error('flow.name is required'); + } + const name = raw.name.trim(); + + // description 校验 + let description: string | undefined; + if (raw.description !== undefined && raw.description !== null) { + if (typeof raw.description !== 'string') { + throw new Error('flow.description must be a string'); + } + description = raw.description; + } + + // entryNodeId 校验 + if (!raw.entryNodeId || typeof raw.entryNodeId !== 'string' || !raw.entryNodeId.trim()) { + throw new Error('flow.entryNodeId is required'); + } + const entryNodeId = raw.entryNodeId.trim() as NodeId; + + // nodes 校验 + if (!Array.isArray(raw.nodes)) { + throw new Error('flow.nodes must be an array'); + } + const nodes = raw.nodes.map((n, i) => this.normalizeNode(n, i)); + + // 验证 node ID 唯一性 + const nodeIdSet = new Set(); + for (const node of nodes) { + if (nodeIdSet.has(node.id)) { + throw new Error(`Duplicate node ID: "${node.id}"`); + } + nodeIdSet.add(node.id); + } + + // edges 校验 + let edges: EdgeV3[] = []; + if (raw.edges !== undefined && raw.edges !== null) { + if (!Array.isArray(raw.edges)) { + throw new Error('flow.edges must be an array'); + } + edges = raw.edges.map((e, i) => this.normalizeEdge(e, i)); + } + + // 验证 edge ID 唯一性 + const edgeIdSet = new Set(); + for (const edge of edges) { + if (edgeIdSet.has(edge.id)) { + throw new Error(`Duplicate edge ID: "${edge.id}"`); + } + edgeIdSet.add(edge.id); + } + + // 验证 entryNodeId 存在 + if (!nodeIdSet.has(entryNodeId)) { + throw new Error(`Entry node "${entryNodeId}" does not exist in flow`); + } + + // 验证边引用 + for (const edge of edges) { + if (!nodeIdSet.has(edge.from)) { + throw new Error(`Edge "${edge.id}" references non-existent source node "${edge.from}"`); + } + if (!nodeIdSet.has(edge.to)) { + throw new Error(`Edge "${edge.id}" references non-existent target node "${edge.to}"`); + } + } + + // 时间戳:更新时继承 existingFlow.createdAt,新建时用当前时间 + const now = new Date(this.now()).toISOString() as ISODateTimeString; + const createdAt = existingFlow?.createdAt ?? now; + const updatedAt = now; + + // 构建完整的 FlowV3 + const flow: FlowV3 = { + schemaVersion: CURRENT_FLOW_SCHEMA_VERSION, + id, + name, + createdAt, + updatedAt, + entryNodeId, + nodes, + edges, + }; + + // 可选字段 + if (description !== undefined) { + flow.description = description; + } + + // variables 验证:每项必须是 object 且有 name 字段 + if (raw.variables !== undefined && raw.variables !== null) { + if (!Array.isArray(raw.variables)) { + throw new Error('flow.variables must be an array'); + } + const variables: VariableDefinition[] = []; + const varNameSet = new Set(); + for (let i = 0; i < raw.variables.length; i++) { + const v = raw.variables[i]; + if (!v || typeof v !== 'object' || Array.isArray(v)) { + throw new Error(`flow.variables[${i}] must be an object`); + } + const varObj = v as JsonObject; + if (!varObj.name || typeof varObj.name !== 'string' || !varObj.name.trim()) { + throw new Error(`flow.variables[${i}].name is required`); + } + const varName = varObj.name.trim(); + if (varNameSet.has(varName)) { + throw new Error(`Duplicate variable name: "${varName}"`); + } + varNameSet.add(varName); + // 使用 trim 后的 name + variables.push({ ...varObj, name: varName } as unknown as VariableDefinition); + } + if (variables.length > 0) { + flow.variables = variables; + } + } + + if (raw.policy !== undefined && raw.policy !== null) { + if (typeof raw.policy !== 'object' || Array.isArray(raw.policy)) { + throw new Error('flow.policy must be an object'); + } + flow.policy = raw.policy as FlowV3['policy']; + } + if (raw.meta !== undefined && raw.meta !== null) { + if (typeof raw.meta !== 'object' || Array.isArray(raw.meta)) { + throw new Error('flow.meta must be an object'); + } + flow.meta = raw.meta as FlowV3['meta']; + } + + return flow; + } + + /** + * 规范化 Node 输入 + */ + private normalizeNode(value: unknown, index: number): NodeV3 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`flow.nodes[${index}] must be an object`); + } + const raw = value as JsonObject; + + // id 校验(非空 + trim) + if (!raw.id || typeof raw.id !== 'string' || !raw.id.trim()) { + throw new Error(`flow.nodes[${index}].id is required`); + } + const nodeId = raw.id.trim() as NodeId; + + // kind 校验(非空 + trim) + if (!raw.kind || typeof raw.kind !== 'string' || !raw.kind.trim()) { + throw new Error(`flow.nodes[${index}].kind is required`); + } + const kind = raw.kind.trim(); + + // config 校验 + if (raw.config !== undefined && raw.config !== null) { + if (typeof raw.config !== 'object' || Array.isArray(raw.config)) { + throw new Error(`flow.nodes[${index}].config must be an object`); + } + } + + const node: NodeV3 = { + id: nodeId, + kind, + config: (raw.config as JsonObject) ?? {}, + }; + + // 可选字段 + if (raw.name !== undefined && raw.name !== null) { + if (typeof raw.name !== 'string') { + throw new Error(`flow.nodes[${index}].name must be a string`); + } + node.name = raw.name; + } + if (raw.disabled !== undefined && raw.disabled !== null) { + if (typeof raw.disabled !== 'boolean') { + throw new Error(`flow.nodes[${index}].disabled must be a boolean`); + } + node.disabled = raw.disabled; + } + if (raw.policy !== undefined && raw.policy !== null) { + if (typeof raw.policy !== 'object' || Array.isArray(raw.policy)) { + throw new Error(`flow.nodes[${index}].policy must be an object`); + } + node.policy = raw.policy as NodeV3['policy']; + } + if (raw.ui !== undefined && raw.ui !== null) { + if (typeof raw.ui !== 'object' || Array.isArray(raw.ui)) { + throw new Error(`flow.nodes[${index}].ui must be an object`); + } + node.ui = raw.ui as NodeV3['ui']; + } + + return node; + } + + /** + * 规范化 Edge 输入 + */ + private normalizeEdge(value: unknown, index: number): EdgeV3 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`flow.edges[${index}] must be an object`); + } + const raw = value as JsonObject; + + // id 校验或生成(非空 + trim) + let id: EdgeId; + if (raw.id === undefined || raw.id === null) { + id = `edge_${index}_${Math.random().toString(36).slice(2, 8)}` as EdgeId; + } else { + if (typeof raw.id !== 'string' || !raw.id.trim()) { + throw new Error(`flow.edges[${index}].id must be a non-empty string`); + } + id = raw.id.trim() as EdgeId; + } + + // from 校验(非空 + trim) + if (!raw.from || typeof raw.from !== 'string' || !raw.from.trim()) { + throw new Error(`flow.edges[${index}].from is required`); + } + const from = raw.from.trim() as NodeId; + + // to 校验(非空 + trim) + if (!raw.to || typeof raw.to !== 'string' || !raw.to.trim()) { + throw new Error(`flow.edges[${index}].to is required`); + } + const to = raw.to.trim() as NodeId; + + const edge: EdgeV3 = { + id, + from, + to, + }; + + // label 可选 + if (raw.label !== undefined && raw.label !== null) { + if (typeof raw.label !== 'string') { + throw new Error(`flow.edges[${index}].label must be a string`); + } + edge.label = raw.label as EdgeV3['label']; + } + + return edge; + } + + // ===== Trigger Management Handlers ===== + + private requireTriggerManager(): TriggerManager { + if (!this.triggerManager) { + throw new Error('TriggerManager not configured'); + } + return this.triggerManager; + } + + private async handleCreateTrigger(params: JsonObject | undefined): Promise { + const trigger = this.normalizeTriggerSpec(params?.trigger, { requireId: false }); + + const existing = await this.storage.triggers.get(trigger.id); + if (existing) { + throw new Error(`Trigger "${trigger.id}" already exists`); + } + + const flow = await this.storage.flows.get(trigger.flowId); + if (!flow) { + throw new Error(`Flow "${trigger.flowId}" not found`); + } + + await this.storage.triggers.save(trigger); + await this.requireTriggerManager().refresh(); + return trigger as unknown as JsonValue; + } + + private async handleUpdateTrigger(params: JsonObject | undefined): Promise { + const trigger = this.normalizeTriggerSpec(params?.trigger, { requireId: true }); + + const existing = await this.storage.triggers.get(trigger.id); + if (!existing) { + throw new Error(`Trigger "${trigger.id}" not found`); + } + + const flow = await this.storage.flows.get(trigger.flowId); + if (!flow) { + throw new Error(`Flow "${trigger.flowId}" not found`); + } + + await this.storage.triggers.save(trigger); + await this.requireTriggerManager().refresh(); + return trigger as unknown as JsonValue; + } + + private async handleDeleteTrigger(params: JsonObject | undefined): Promise { + const triggerId = params?.triggerId as TriggerId | undefined; + if (!triggerId) throw new Error('triggerId is required'); + + await this.storage.triggers.delete(triggerId); + await this.requireTriggerManager().refresh(); + return { ok: true, triggerId }; + } + + private async handleGetTrigger(params: JsonObject | undefined): Promise { + const triggerId = params?.triggerId as TriggerId | undefined; + if (!triggerId) throw new Error('triggerId is required'); + const trigger = await this.storage.triggers.get(triggerId); + return trigger as unknown as JsonValue; + } + + private async handleListTriggers(params: JsonObject | undefined): Promise { + const flowIdValue = params?.flowId; + let flowId: FlowId | undefined; + if (flowIdValue !== undefined && flowIdValue !== null) { + if (typeof flowIdValue !== 'string') { + throw new Error('flowId must be a string'); + } + flowId = flowIdValue as FlowId; + } + + const triggers = await this.storage.triggers.list(); + const filtered = flowId ? triggers.filter((t) => t.flowId === flowId) : triggers; + return filtered as unknown as JsonValue; + } + + private async handleEnableTrigger(params: JsonObject | undefined): Promise { + const triggerId = params?.triggerId as TriggerId | undefined; + if (!triggerId) throw new Error('triggerId is required'); + + const trigger = await this.storage.triggers.get(triggerId); + if (!trigger) { + throw new Error(`Trigger "${triggerId}" not found`); + } + + const updated: TriggerSpec = { ...trigger, enabled: true }; + await this.storage.triggers.save(updated); + await this.requireTriggerManager().refresh(); + return updated as unknown as JsonValue; + } + + private async handleDisableTrigger(params: JsonObject | undefined): Promise { + const triggerId = params?.triggerId as TriggerId | undefined; + if (!triggerId) throw new Error('triggerId is required'); + + const trigger = await this.storage.triggers.get(triggerId); + if (!trigger) { + throw new Error(`Trigger "${triggerId}" not found`); + } + + const updated: TriggerSpec = { ...trigger, enabled: false }; + await this.storage.triggers.save(updated); + await this.requireTriggerManager().refresh(); + return updated as unknown as JsonValue; + } + + private async handleFireTrigger(params: JsonObject | undefined): Promise { + const triggerId = params?.triggerId as TriggerId | undefined; + if (!triggerId) throw new Error('triggerId is required'); + + const trigger = await this.storage.triggers.get(triggerId); + if (!trigger) { + throw new Error(`Trigger "${triggerId}" not found`); + } + if (trigger.kind !== 'manual') { + throw new Error(`fireTrigger only supports manual triggers (got kind="${trigger.kind}")`); + } + if (!trigger.enabled) { + throw new Error(`Trigger "${triggerId}" is disabled`); + } + + let sourceTabId: number | undefined; + if (params?.sourceTabId !== undefined && params?.sourceTabId !== null) { + if (typeof params.sourceTabId !== 'number' || !Number.isFinite(params.sourceTabId)) { + throw new Error('sourceTabId must be a finite number'); + } + sourceTabId = Math.floor(params.sourceTabId); + } + + let sourceUrl: string | undefined; + if (params?.sourceUrl !== undefined && params?.sourceUrl !== null) { + if (typeof params.sourceUrl !== 'string') { + throw new Error('sourceUrl must be a string'); + } + sourceUrl = params.sourceUrl; + } + + const result = await this.requireTriggerManager().fire(triggerId, { + sourceTabId, + sourceUrl, + }); + return result as unknown as JsonValue; + } + + /** + * 规范化 TriggerSpec 输入 + */ + private normalizeTriggerSpec(value: unknown, opts: { requireId: boolean }): TriggerSpec { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('trigger is required'); + } + const raw = value as JsonObject; + + // kind 校验 + const kind = raw.kind; + if (!kind || typeof kind !== 'string') { + throw new Error('trigger.kind is required'); + } + + // flowId 校验 + const flowId = raw.flowId; + if (!flowId || typeof flowId !== 'string') { + throw new Error('trigger.flowId is required'); + } + + // id 校验 + let id: TriggerId; + if (raw.id === undefined || raw.id === null) { + if (opts.requireId) { + throw new Error('trigger.id is required'); + } + id = `trg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` as TriggerId; + } else { + if (typeof raw.id !== 'string' || !raw.id.trim()) { + throw new Error('trigger.id must be a non-empty string'); + } + id = raw.id as TriggerId; + } + + // enabled 校验 + let enabled = true; + if (raw.enabled !== undefined && raw.enabled !== null) { + if (typeof raw.enabled !== 'boolean') { + throw new Error('trigger.enabled must be a boolean'); + } + enabled = raw.enabled; + } + + // args 校验 + let args: JsonObject | undefined; + if (raw.args !== undefined && raw.args !== null) { + if (typeof raw.args !== 'object' || Array.isArray(raw.args)) { + throw new Error('trigger.args must be an object'); + } + args = raw.args as JsonObject; + } + + // 基础字段 + const base = { id, kind: kind as TriggerKind, enabled, flowId: flowId as FlowId, args }; + + // 根据 kind 添加特定字段 + switch (kind) { + case 'manual': + return base as TriggerSpec; + + case 'url': { + let match: unknown[] = []; + if (raw.match !== undefined && raw.match !== null) { + if (!Array.isArray(raw.match)) { + throw new Error('trigger.match must be an array'); + } + match = raw.match; + } + return { ...base, match } as TriggerSpec; + } + + case 'cron': { + if (!raw.cron || typeof raw.cron !== 'string') { + throw new Error('trigger.cron is required for cron triggers'); + } + let timezone: string | undefined; + if (raw.timezone !== undefined && raw.timezone !== null) { + if (typeof raw.timezone !== 'string') { + throw new Error('trigger.timezone must be a string'); + } + timezone = raw.timezone.trim() || undefined; + } + return { ...base, cron: raw.cron, timezone } as TriggerSpec; + } + + case 'interval': { + if (raw.periodMinutes === undefined || raw.periodMinutes === null) { + throw new Error('trigger.periodMinutes is required for interval triggers'); + } + if (typeof raw.periodMinutes !== 'number' || !Number.isFinite(raw.periodMinutes)) { + throw new Error('trigger.periodMinutes must be a finite number'); + } + if (raw.periodMinutes < 1) { + throw new Error('trigger.periodMinutes must be >= 1'); + } + return { ...base, periodMinutes: raw.periodMinutes } as TriggerSpec; + } + + case 'once': { + if (raw.whenMs === undefined || raw.whenMs === null) { + throw new Error('trigger.whenMs is required for once triggers'); + } + if (typeof raw.whenMs !== 'number' || !Number.isFinite(raw.whenMs)) { + throw new Error('trigger.whenMs must be a finite number'); + } + return { ...base, whenMs: Math.floor(raw.whenMs) } as TriggerSpec; + } + + case 'command': { + if (!raw.commandKey || typeof raw.commandKey !== 'string') { + throw new Error('trigger.commandKey is required for command triggers'); + } + return { ...base, commandKey: raw.commandKey } as TriggerSpec; + } + + case 'contextMenu': { + if (!raw.title || typeof raw.title !== 'string') { + throw new Error('trigger.title is required for contextMenu triggers'); + } + let contexts: string[] | undefined; + if (raw.contexts !== undefined && raw.contexts !== null) { + if (!Array.isArray(raw.contexts) || !raw.contexts.every((c) => typeof c === 'string')) { + throw new Error('trigger.contexts must be an array of strings'); + } + contexts = raw.contexts as string[]; + } + return { ...base, title: raw.title, contexts } as TriggerSpec; + } + + case 'dom': { + if (!raw.selector || typeof raw.selector !== 'string') { + throw new Error('trigger.selector is required for dom triggers'); + } + let appear: boolean | undefined; + if (raw.appear !== undefined && raw.appear !== null) { + if (typeof raw.appear !== 'boolean') { + throw new Error('trigger.appear must be a boolean'); + } + appear = raw.appear; + } + let once: boolean | undefined; + if (raw.once !== undefined && raw.once !== null) { + if (typeof raw.once !== 'boolean') { + throw new Error('trigger.once must be a boolean'); + } + once = raw.once; + } + let debounceMs: number | undefined; + if (raw.debounceMs !== undefined && raw.debounceMs !== null) { + if (typeof raw.debounceMs !== 'number' || !Number.isFinite(raw.debounceMs)) { + throw new Error('trigger.debounceMs must be a finite number'); + } + debounceMs = raw.debounceMs; + } + return { ...base, selector: raw.selector, appear, once, debounceMs } as TriggerSpec; + } + + default: + throw new Error( + `trigger.kind must be one of: manual, url, cron, interval, once, command, contextMenu, dom`, + ); + } + } + + // ===== Run Control Handlers ===== + + private async handlePauseRun(params: JsonObject | undefined): Promise { + const runId = params?.runId as RunId | undefined; + if (!runId) throw new Error('runId is required'); + + if (!this.runners) { + throw new Error('RunnerRegistry not configured'); + } + + const runner = this.runners.get(runId); + if (!runner) { + throw new Error(`Runner for "${runId}" not found (run may not be executing)`); + } + + const queueItem = await this.storage.queue.get(runId); + if (!queueItem) { + throw new Error(`Queue item "${runId}" not found`); + } + if (queueItem.status === 'queued') { + throw new Error(`Cannot pause run "${runId}" while status=queued`); + } + + const ownerId = queueItem.lease?.ownerId; + if (!ownerId) { + throw new Error(`Queue item "${runId}" has no lease ownerId`); + } + + const now = this.now(); + await this.storage.queue.markPaused(runId, ownerId, now); + runner.pause(); + + return { ok: true, runId }; + } + + private async handleResumeRun(params: JsonObject | undefined): Promise { + const runId = params?.runId as RunId | undefined; + if (!runId) throw new Error('runId is required'); + + if (!this.runners) { + throw new Error('RunnerRegistry not configured'); + } + + const runner = this.runners.get(runId); + if (!runner) { + throw new Error(`Runner for "${runId}" not found (run may not be executing)`); + } + + const queueItem = await this.storage.queue.get(runId); + if (!queueItem) { + throw new Error(`Queue item "${runId}" not found`); + } + if (queueItem.status !== 'paused') { + throw new Error(`Cannot resume run "${runId}" with status=${queueItem.status}`); + } + + const ownerId = queueItem.lease?.ownerId; + if (!ownerId) { + throw new Error(`Queue item "${runId}" has no lease ownerId`); + } + + const now = this.now(); + await this.storage.queue.markRunning(runId, ownerId, now); + runner.resume(); + + return { ok: true, runId }; + } + + private async handleCancelRun(params: JsonObject | undefined): Promise { + const runId = params?.runId as RunId | undefined; + if (!runId) throw new Error('runId is required'); + + const reason = (params?.reason as string) ?? 'Canceled by user'; + const queueItem = await this.storage.queue.get(runId); + + // If still queued (not yet claimed), cancel via queue + if (queueItem?.status === 'queued') { + return this.handleCancelQueueItem({ runId, reason } as unknown as JsonObject); + } + + // If running/paused, cancel via runner + if (!this.runners) { + throw new Error('RunnerRegistry not configured'); + } + + const runner = this.runners.get(runId); + if (!runner) { + // Run may have already finished + throw new Error(`Runner for "${runId}" not found (run may have already finished)`); + } + + runner.cancel(reason); + return { ok: true, runId }; + } +} + +/** + * 创建并启动 RPC Server + */ +export function createRpcServer(config: RpcServerConfig): RpcServer { + const server = new RpcServer(config); + server.start(); + return server; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc.ts new file mode 100644 index 0000000..9b53131 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc.ts @@ -0,0 +1,192 @@ +/** + * @fileoverview Port RPC 协议定义 + * @description 定义通过 chrome.runtime.Port 进行通信的协议类型 + */ + +import type { JsonObject, JsonValue } from '../../domain/json'; +import type { RunId } from '../../domain/ids'; +import type { RunEvent } from '../../domain/events'; + +/** Port 名称 */ +export const RR_V3_PORT_NAME = 'rr_v3' as const; + +/** + * RPC 方法名称 + */ +export type RpcMethod = + // 查询方法 + | 'rr_v3.listRuns' + | 'rr_v3.getRun' + | 'rr_v3.getEvents' + // Flow 管理方法 + | 'rr_v3.getFlow' + | 'rr_v3.listFlows' + | 'rr_v3.saveFlow' + | 'rr_v3.deleteFlow' + // 触发器管理方法 + | 'rr_v3.createTrigger' + | 'rr_v3.updateTrigger' + | 'rr_v3.deleteTrigger' + | 'rr_v3.getTrigger' + | 'rr_v3.listTriggers' + | 'rr_v3.enableTrigger' + | 'rr_v3.disableTrigger' + | 'rr_v3.fireTrigger' + // 队列管理方法 + | 'rr_v3.enqueueRun' + | 'rr_v3.listQueue' + | 'rr_v3.cancelQueueItem' + // 控制方法 + | 'rr_v3.startRun' + | 'rr_v3.cancelRun' + | 'rr_v3.pauseRun' + | 'rr_v3.resumeRun' + // 调试方法 + | 'rr_v3.debug' + // 订阅方法 + | 'rr_v3.subscribe' + | 'rr_v3.unsubscribe'; + +/** + * RPC 请求消息 + */ +export interface RpcRequest { + type: 'rr_v3.request'; + /** 请求 ID(用于匹配响应) */ + requestId: string; + /** 方法名 */ + method: RpcMethod; + /** 参数 */ + params?: JsonObject; +} + +/** + * RPC 成功响应 + */ +export interface RpcResponseOk { + type: 'rr_v3.response'; + /** 对应的请求 ID */ + requestId: string; + ok: true; + /** 返回结果 */ + result: JsonValue; +} + +/** + * RPC 错误响应 + */ +export interface RpcResponseErr { + type: 'rr_v3.response'; + /** 对应的请求 ID */ + requestId: string; + ok: false; + /** 错误信息 */ + error: string; +} + +/** + * RPC 响应 + */ +export type RpcResponse = RpcResponseOk | RpcResponseErr; + +/** + * RPC 事件推送 + */ +export interface RpcEventMessage { + type: 'rr_v3.event'; + /** 事件数据 */ + event: RunEvent; +} + +/** + * RPC 订阅确认 + */ +export interface RpcSubscribeAck { + type: 'rr_v3.subscribeAck'; + /** 订阅的 Run ID(可选,null 表示订阅所有) */ + runId: RunId | null; +} + +/** + * 所有 RPC 消息类型 + */ +export type RpcMessage = + | RpcRequest + | RpcResponseOk + | RpcResponseErr + | RpcEventMessage + | RpcSubscribeAck; + +/** + * 生成唯一的请求 ID + */ +export function generateRequestId(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * 判断消息是否为 RPC 请求 + */ +export function isRpcRequest(msg: unknown): msg is RpcRequest { + return typeof msg === 'object' && msg !== null && (msg as RpcRequest).type === 'rr_v3.request'; +} + +/** + * 判断消息是否为 RPC 响应 + */ +export function isRpcResponse(msg: unknown): msg is RpcResponse { + return typeof msg === 'object' && msg !== null && (msg as RpcResponse).type === 'rr_v3.response'; +} + +/** + * 判断消息是否为 RPC 事件 + */ +export function isRpcEvent(msg: unknown): msg is RpcEventMessage { + return typeof msg === 'object' && msg !== null && (msg as RpcEventMessage).type === 'rr_v3.event'; +} + +/** + * 创建 RPC 请求 + */ +export function createRpcRequest(method: RpcMethod, params?: JsonObject): RpcRequest { + return { + type: 'rr_v3.request', + requestId: generateRequestId(), + method, + params, + }; +} + +/** + * 创建成功响应 + */ +export function createRpcResponseOk(requestId: string, result: JsonValue): RpcResponseOk { + return { + type: 'rr_v3.response', + requestId, + ok: true, + result, + }; +} + +/** + * 创建错误响应 + */ +export function createRpcResponseErr(requestId: string, error: string): RpcResponseErr { + return { + type: 'rr_v3.response', + requestId, + ok: false, + error, + }; +} + +/** + * 创建事件消息 + */ +export function createRpcEventMessage(event: RunEvent): RpcEventMessage { + return { + type: 'rr_v3.event', + event, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts new file mode 100644 index 0000000..17c90c3 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Command Trigger Handler (P4-04) + * @description + * Listens to `chrome.commands.onCommand` and fires installed command triggers. + * + * Command triggers allow users to execute flows via keyboard shortcuts + * defined in the extension's manifest. + * + * Design notes: + * - Commands must be registered in manifest.json under the "commands" key + * - Each command is identified by its commandKey (e.g., "run-flow-1") + * - Active tab info is captured when available + */ + +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +export interface CommandTriggerHandlerDeps { + logger?: Pick; +} + +type CommandTriggerSpec = TriggerSpecByKind<'command'>; + +interface InstalledCommandTrigger { + spec: CommandTriggerSpec; +} + +// ==================== Handler Implementation ==================== + +/** + * Create command trigger handler factory + */ +export function createCommandTriggerHandlerFactory( + deps?: CommandTriggerHandlerDeps, +): TriggerHandlerFactory<'command'> { + return (fireCallback) => createCommandTriggerHandler(fireCallback, deps); +} + +/** + * Create command trigger handler + */ +export function createCommandTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: CommandTriggerHandlerDeps, +): TriggerHandler<'command'> { + const logger = deps?.logger ?? console; + + // Map commandKey -> triggerId for fast lookup + const commandKeyToTriggerId = new Map(); + const installed = new Map(); + let listening = false; + + /** + * Handle chrome.commands.onCommand event + */ + const onCommand = (command: string, tab?: chrome.tabs.Tab): void => { + const triggerId = commandKeyToTriggerId.get(command); + if (!triggerId) return; + + const trigger = installed.get(triggerId); + if (!trigger) return; + + // Fire and forget: chrome event listeners should not block + Promise.resolve( + fireCallback.onFire(triggerId, { + sourceTabId: tab?.id, + sourceUrl: tab?.url, + }), + ).catch((e) => { + logger.error(`[CommandTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + }); + }; + + /** + * Ensure listener is registered + */ + function ensureListening(): void { + if (listening) return; + if (!chrome.commands?.onCommand?.addListener) { + logger.warn('[CommandTriggerHandler] chrome.commands.onCommand is unavailable'); + return; + } + chrome.commands.onCommand.addListener(onCommand); + listening = true; + } + + /** + * Stop listening + */ + function stopListening(): void { + if (!listening) return; + try { + chrome.commands.onCommand.removeListener(onCommand); + } catch (e) { + logger.debug('[CommandTriggerHandler] removeListener failed:', e); + } finally { + listening = false; + } + } + + return { + kind: 'command', + + async install(trigger: CommandTriggerSpec): Promise { + const { id, commandKey } = trigger; + + // Warn if commandKey already used by another trigger + const existingTriggerId = commandKeyToTriggerId.get(commandKey); + if (existingTriggerId && existingTriggerId !== id) { + logger.warn( + `[CommandTriggerHandler] Command "${commandKey}" already used by trigger "${existingTriggerId}", overwriting with "${id}"`, + ); + // Remove old mapping + installed.delete(existingTriggerId); + } + + installed.set(id, { spec: trigger }); + commandKeyToTriggerId.set(commandKey, id); + ensureListening(); + }, + + async uninstall(triggerId: string): Promise { + const trigger = installed.get(triggerId as TriggerId); + if (trigger) { + commandKeyToTriggerId.delete(trigger.spec.commandKey); + installed.delete(triggerId as TriggerId); + } + + if (installed.size === 0) { + stopListening(); + } + }, + + async uninstallAll(): Promise { + installed.clear(); + commandKeyToTriggerId.clear(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts new file mode 100644 index 0000000..d873a45 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts @@ -0,0 +1,217 @@ +/** + * @fileoverview ContextMenu Trigger Handler (P4-05) + * @description + * Uses `chrome.contextMenus` API to create right-click menu items that fire triggers. + * + * Design notes: + * - Each trigger creates a separate menu item with unique ID + * - Menu item ID is prefixed with 'rr_v3_' to avoid conflicts + * - Context types: 'page', 'selection', 'link', 'image', 'video', 'audio', etc. + * - Captures click info and tab info for trigger context + */ + +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +export interface ContextMenuTriggerHandlerDeps { + logger?: Pick; +} + +type ContextMenuTriggerSpec = TriggerSpecByKind<'contextMenu'>; + +interface InstalledContextMenuTrigger { + spec: ContextMenuTriggerSpec; + menuItemId: string; +} + +// ==================== Constants ==================== + +const MENU_ITEM_PREFIX = 'rr_v3_'; + +// Default context types if not specified +const DEFAULT_CONTEXTS: chrome.contextMenus.ContextType[] = ['page']; + +// ==================== Handler Implementation ==================== + +/** + * Create context menu trigger handler factory + */ +export function createContextMenuTriggerHandlerFactory( + deps?: ContextMenuTriggerHandlerDeps, +): TriggerHandlerFactory<'contextMenu'> { + return (fireCallback) => createContextMenuTriggerHandler(fireCallback, deps); +} + +/** + * Create context menu trigger handler + */ +export function createContextMenuTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: ContextMenuTriggerHandlerDeps, +): TriggerHandler<'contextMenu'> { + const logger = deps?.logger ?? console; + + // Map menuItemId -> triggerId for fast lookup + const menuItemIdToTriggerId = new Map(); + const installed = new Map(); + let listening = false; + + /** + * Generate unique menu item ID for a trigger + */ + function generateMenuItemId(triggerId: TriggerId): string { + return `${MENU_ITEM_PREFIX}${triggerId}`; + } + + /** + * Handle chrome.contextMenus.onClicked event + */ + const onClicked = (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab): void => { + const menuItemId = String(info.menuItemId); + const triggerId = menuItemIdToTriggerId.get(menuItemId); + if (!triggerId) return; + + const trigger = installed.get(triggerId); + if (!trigger) return; + + // Fire and forget: chrome event listeners should not block + Promise.resolve( + fireCallback.onFire(triggerId, { + sourceTabId: tab?.id, + sourceUrl: info.pageUrl ?? tab?.url, + }), + ).catch((e) => { + logger.error(`[ContextMenuTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + }); + }; + + /** + * Ensure listener is registered + */ + function ensureListening(): void { + if (listening) return; + if (!chrome.contextMenus?.onClicked?.addListener) { + logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.onClicked is unavailable'); + return; + } + chrome.contextMenus.onClicked.addListener(onClicked); + listening = true; + } + + /** + * Stop listening + */ + function stopListening(): void { + if (!listening) return; + try { + chrome.contextMenus.onClicked.removeListener(onClicked); + } catch (e) { + logger.debug('[ContextMenuTriggerHandler] removeListener failed:', e); + } finally { + listening = false; + } + } + + /** + * Convert context types from spec to chrome API format + */ + function normalizeContexts( + contexts: ReadonlyArray | undefined, + ): chrome.contextMenus.ContextType[] { + if (!contexts || contexts.length === 0) { + return DEFAULT_CONTEXTS; + } + return contexts as chrome.contextMenus.ContextType[]; + } + + return { + kind: 'contextMenu', + + async install(trigger: ContextMenuTriggerSpec): Promise { + const { id, title, contexts } = trigger; + const menuItemId = generateMenuItemId(id); + + // Check if chrome.contextMenus.create is available + if (!chrome.contextMenus?.create) { + logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.create is unavailable'); + return; + } + + // Create menu item + await new Promise((resolve, reject) => { + chrome.contextMenus.create( + { + id: menuItemId, + title: title, + contexts: normalizeContexts(contexts), + }, + () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }, + ); + }); + + installed.set(id, { spec: trigger, menuItemId }); + menuItemIdToTriggerId.set(menuItemId, id); + ensureListening(); + }, + + async uninstall(triggerId: string): Promise { + const trigger = installed.get(triggerId as TriggerId); + if (!trigger) return; + + // Remove menu item + if (chrome.contextMenus?.remove) { + await new Promise((resolve) => { + chrome.contextMenus.remove(trigger.menuItemId, () => { + // Ignore errors (item may not exist) + if (chrome.runtime.lastError) { + logger.debug( + `[ContextMenuTriggerHandler] Failed to remove menu item: ${chrome.runtime.lastError.message}`, + ); + } + resolve(); + }); + }); + } + + menuItemIdToTriggerId.delete(trigger.menuItemId); + installed.delete(triggerId as TriggerId); + + if (installed.size === 0) { + stopListening(); + } + }, + + async uninstallAll(): Promise { + // Remove all menu items created by this handler + if (chrome.contextMenus?.remove) { + const removePromises = Array.from(installed.values()).map( + (trigger) => + new Promise((resolve) => { + chrome.contextMenus.remove(trigger.menuItemId, () => { + // Ignore errors + resolve(); + }); + }), + ); + await Promise.all(removePromises); + } + + installed.clear(); + menuItemIdToTriggerId.clear(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger.ts new file mode 100644 index 0000000..d67a917 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger.ts @@ -0,0 +1,583 @@ +/** + * @fileoverview Cron Trigger Handler (P4-07) + * @description + * Schedules cron triggers via `chrome.alarms` (MV3). + * + * Strategy: + * - One alarm per trigger (one-shot `when` alarm). + * - When fired: call `fireCallback.onFire(triggerId)` then compute and schedule next. + * + * Timezone: + * - Accepts IANA timezones (e.g. "UTC", "Asia/Shanghai"). + * - Validated via `Intl.DateTimeFormat(..., { timeZone })`. + * + * Cron parsing: + * - Delegated to an external library (recommended: `cron-parser`) to avoid DST edge cases. + * - Falls back to a minimal built-in parser if library not available. + */ + +import type { UnixMillis } from '../../domain/json'; +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +type CronTriggerSpec = TriggerSpecByKind<'cron'>; + +/** + * Function to compute next fire time from cron expression + */ +export type ComputeNextFireAtMs = (input: { + cron: string; + timezone?: string; + fromMs: UnixMillis; +}) => UnixMillis | Promise; + +export interface CronTriggerHandlerDeps { + logger?: Pick; + now?: () => UnixMillis; + computeNextFireAtMs?: ComputeNextFireAtMs; +} + +interface InstalledCronTrigger { + spec: CronTriggerSpec; + timezone?: string; + version: number; +} + +// ==================== Constants ==================== + +const ALARM_PREFIX = 'rr_v3_cron_'; + +// ==================== Utilities ==================== + +/** + * Normalize cron expression + */ +function normalizeCronExpression(value: unknown): string { + const raw = typeof value === 'string' ? value : String(value ?? ''); + const normalized = raw.trim().replace(/\s+/g, ' '); + if (!normalized) { + throw new Error('cron must be a non-empty string'); + } + return normalized; +} + +/** + * Validate and normalize timezone + */ +function normalizeTimezone(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') { + throw new Error('timezone must be a string'); + } + const trimmed = value.trim(); + if (!trimmed) return undefined; + + try { + // Throws RangeError for invalid IANA timezones + new Intl.DateTimeFormat('en-US', { timeZone: trimmed }).format(new Date(0)); + } catch { + throw new Error(`Invalid timezone: "${trimmed}"`); + } + + return trimmed; +} + +/** + * Generate alarm name for trigger + */ +function alarmNameForTrigger(triggerId: TriggerId): string { + return `${ALARM_PREFIX}${triggerId}`; +} + +/** + * Parse trigger ID from alarm name + */ +function parseTriggerIdFromAlarmName(name: string): TriggerId | null { + if (!name.startsWith(ALARM_PREFIX)) return null; + const id = name.slice(ALARM_PREFIX.length); + return id ? (id as TriggerId) : null; +} + +/** + * Simple cron expression parser (minimal subset) + * Supports: minute hour day-of-month month day-of-week + * Values: numbers, * (any), intervals (e.g., * /5) + * + * For production use with complex cron expressions, install 'cron-parser'. + */ +function parseSimpleCron(cron: string): { + minute: number[]; + hour: number[]; + dayOfMonth: number[]; + month: number[]; + dayOfWeek: number[]; +} { + const parts = cron.split(' '); + if (parts.length !== 5) { + throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`); + } + + function parseField(field: string, min: number, max: number): number[] { + const values: number[] = []; + + for (const part of field.split(',')) { + if (part === '*') { + for (let i = min; i <= max; i++) values.push(i); + } else if (part.includes('/')) { + const [range, stepStr] = part.split('/'); + const step = parseInt(stepStr, 10); + // Guard against infinite loop: step must be positive + if (!Number.isFinite(step) || step < 1) { + throw new Error(`Invalid step in cron field: "${part}" (step must be >= 1)`); + } + const start = range === '*' ? min : parseInt(range, 10); + if (!Number.isFinite(start) || start < min || start > max) { + throw new Error(`Invalid range start in cron field: "${part}"`); + } + for (let i = start; i <= max; i += step) values.push(i); + } else if (part.includes('-')) { + const [startStr, endStr] = part.split('-'); + const start = parseInt(startStr, 10); + const end = parseInt(endStr, 10); + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) { + throw new Error(`Invalid range in cron field: "${part}"`); + } + for (let i = start; i <= end; i++) values.push(i); + } else { + const num = parseInt(part, 10); + if (!Number.isFinite(num)) { + throw new Error(`Invalid number in cron field: "${part}"`); + } + values.push(num); + } + } + + // Validate all values are within bounds + for (const v of values) { + if (v < min || v > max) { + throw new Error(`Cron field value ${v} out of range [${min}, ${max}]`); + } + } + + return [...new Set(values)].sort((a, b) => a - b); + } + + return { + minute: parseField(parts[0], 0, 59), + hour: parseField(parts[1], 0, 23), + dayOfMonth: parseField(parts[2], 1, 31), + month: parseField(parts[3], 1, 12), + dayOfWeek: parseField(parts[4], 0, 6), + }; +} + +// ==================== Timezone Utilities ==================== + +interface ZonedTimeParts { + year: number; + month: number; + day: number; + hour: number; + minute: number; + dayOfWeek: number; +} + +// Cache DateTimeFormat instances per timezone for performance +const dtfCache = new Map(); + +/** + * Get or create cached DateTimeFormat for a timezone + */ +function getDateTimeFormat(timezone: string): Intl.DateTimeFormat { + let dtf = dtfCache.get(timezone); + if (!dtf) { + dtf = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + weekday: 'short', + }); + dtfCache.set(timezone, dtf); + } + return dtf; +} + +// Map weekday string to number (0=Sunday) +const WEEKDAY_MAP: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +/** + * Get time parts in a specific timezone using Intl.DateTimeFormat + */ +function getZonedTimeParts(utcMs: UnixMillis, timezone: string): ZonedTimeParts { + const dtf = getDateTimeFormat(timezone); + const parts = dtf.formatToParts(new Date(utcMs)); + const map: Record = Object.create(null); + for (const p of parts) { + if (p.type !== 'literal') map[p.type] = p.value; + } + + // Handle edge case: some environments emit "24" for midnight + const rawHour = Number(map.hour); + + return { + year: Number(map.year), + month: Number(map.month), + day: Number(map.day), + hour: rawHour === 24 ? 0 : rawHour, + minute: Number(map.minute), + dayOfWeek: WEEKDAY_MAP[map.weekday] ?? 0, + }; +} + +/** + * Calculate timezone offset in milliseconds at a given UTC timestamp + * Positive offset means timezone is ahead of UTC (e.g., Asia/Shanghai = +8h = +28800000ms) + */ +function getTimezoneOffsetMs(utcMs: UnixMillis, timezone: string): number { + const z = getZonedTimeParts(utcMs, timezone); + const asUtc = Date.UTC(z.year, z.month - 1, z.day, z.hour, z.minute, 0); + return asUtc - utcMs; +} + +/** + * Convert zoned datetime to UTC milliseconds + * Uses iterative refinement to handle DST transitions + */ +function zonedToUtcMs( + zoned: { year: number; month: number; day: number; hour: number; minute: number }, + timezone: string, +): UnixMillis { + // Start with the zoned time interpreted as UTC + const baseUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0); + + // Iteratively solve: utcMs = baseUtc - offset(utcMs) + let utcMs = baseUtc; + for (let i = 0; i < 3; i++) { + const offsetMs = getTimezoneOffsetMs(utcMs, timezone); + const next = baseUtc - offsetMs; + if (next === utcMs) break; + utcMs = next; + } + return utcMs; +} + +// ==================== Cron Computation ==================== + +/** + * Compute next fire time using built-in simple parser (local timezone) + */ +function computeNextFireAtMsLocal( + parsed: ReturnType, + fromMs: UnixMillis, +): UnixMillis { + const baseDate = new Date(fromMs + 1000); // Add 1 second to ensure next occurrence + + for (let dayOffset = 0; dayOffset < 366; dayOffset++) { + for (const hour of parsed.hour) { + for (const minute of parsed.minute) { + const candidate = new Date(baseDate); + candidate.setDate(candidate.getDate() + dayOffset); + candidate.setHours(hour, minute, 0, 0); + + if (candidate.getTime() <= fromMs) continue; + + const month = candidate.getMonth() + 1; + const dayOfMonth = candidate.getDate(); + const dayOfWeek = candidate.getDay(); + + if (!parsed.month.includes(month)) continue; + if (!parsed.dayOfMonth.includes(dayOfMonth) && !parsed.dayOfWeek.includes(dayOfWeek)) + continue; + + return candidate.getTime(); + } + } + } + + throw new Error('Failed to compute next cron fire time within 1 year'); +} + +/** + * Compute next fire time in a specific timezone + */ +function computeNextFireAtMsZoned( + parsed: ReturnType, + fromMs: UnixMillis, + timezone: string, +): UnixMillis { + const baseZoned = getZonedTimeParts(fromMs + 1000, timezone); + const dayCursor = new Date(Date.UTC(baseZoned.year, baseZoned.month - 1, baseZoned.day)); + + for (let dayOffset = 0; dayOffset < 366; dayOffset++) { + if (dayOffset > 0) dayCursor.setUTCDate(dayCursor.getUTCDate() + 1); + + const year = dayCursor.getUTCFullYear(); + const month = dayCursor.getUTCMonth() + 1; + const dayOfMonth = dayCursor.getUTCDate(); + const dayOfWeek = dayCursor.getUTCDay(); + + if (!parsed.month.includes(month)) continue; + if (!parsed.dayOfMonth.includes(dayOfMonth) && !parsed.dayOfWeek.includes(dayOfWeek)) continue; + + for (const hour of parsed.hour) { + for (const minute of parsed.minute) { + const candidateUtcMs = zonedToUtcMs( + { year, month, day: dayOfMonth, hour, minute }, + timezone, + ); + + if (candidateUtcMs <= fromMs) continue; + + // Validate conversion didn't drift (DST gaps/ambiguity can cause skipped times) + const candidateZoned = getZonedTimeParts(candidateUtcMs, timezone); + if ( + candidateZoned.year !== year || + candidateZoned.month !== month || + candidateZoned.day !== dayOfMonth || + candidateZoned.hour !== hour || + candidateZoned.minute !== minute + ) { + continue; // Skip DST gap times + } + + return candidateUtcMs; + } + } + } + + throw new Error('Failed to compute next cron fire time within 1 year'); +} + +/** + * Compute next fire time using built-in simple parser + */ +function computeNextFireAtMsSimple(input: { + cron: string; + timezone?: string; + fromMs: UnixMillis; +}): UnixMillis { + const parsed = parseSimpleCron(input.cron); + + if (input.timezone) { + return computeNextFireAtMsZoned(parsed, input.fromMs, input.timezone); + } + + return computeNextFireAtMsLocal(parsed, input.fromMs); +} + +/** + * Default compute next fire time function + * Uses simple built-in parser + */ +function defaultComputeNextFireAtMs(input: { + cron: string; + timezone?: string; + fromMs: UnixMillis; +}): UnixMillis { + return computeNextFireAtMsSimple(input); +} + +// ==================== Handler Implementation ==================== + +/** + * Create cron trigger handler factory + */ +export function createCronTriggerHandlerFactory( + deps?: CronTriggerHandlerDeps, +): TriggerHandlerFactory<'cron'> { + return (fireCallback) => createCronTriggerHandler(fireCallback, deps); +} + +/** + * Create cron trigger handler + */ +export function createCronTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: CronTriggerHandlerDeps, +): TriggerHandler<'cron'> { + const logger = deps?.logger ?? console; + const now = deps?.now ?? (() => Date.now()); + const computeNextFireAtMs: ComputeNextFireAtMs = + deps?.computeNextFireAtMs ?? defaultComputeNextFireAtMs; + + const installed = new Map(); + const versions = new Map(); + let listening = false; + + /** + * Bump version to invalidate pending operations + */ + function bumpVersion(triggerId: TriggerId): number { + const next = (versions.get(triggerId) ?? 0) + 1; + versions.set(triggerId, next); + return next; + } + + /** + * Clear alarm by name + */ + async function clearAlarmByName(name: string): Promise { + if (!chrome.alarms?.clear) return; + try { + await Promise.resolve(chrome.alarms.clear(name)); + } catch (e) { + logger.debug('[CronTriggerHandler] alarms.clear failed:', e); + } + } + + /** + * Clear all cron alarms + */ + async function clearAllCronAlarms(): Promise { + if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return; + try { + const alarms = await Promise.resolve(chrome.alarms.getAll()); + const list = Array.isArray(alarms) ? alarms : []; + await Promise.all( + list + .filter((a) => a?.name && a.name.startsWith(ALARM_PREFIX)) + .map((a) => clearAlarmByName(a.name)), + ); + } catch (e) { + logger.debug('[CronTriggerHandler] alarms.getAll failed:', e); + } + } + + /** + * Schedule next alarm for trigger + */ + async function scheduleNext(triggerId: TriggerId, expectedVersion: number): Promise { + if (!chrome.alarms?.create) { + logger.warn('[CronTriggerHandler] chrome.alarms.create is unavailable'); + return; + } + + const entry = installed.get(triggerId); + if (!entry || entry.version !== expectedVersion) return; + + const fromMs = now(); + const nextMs = await Promise.resolve( + computeNextFireAtMs({ + cron: entry.spec.cron, + timezone: entry.timezone, + fromMs, + }), + ); + + // Check version again after async + if (installed.get(triggerId)?.version !== expectedVersion) return; + + const name = alarmNameForTrigger(triggerId); + await Promise.resolve(chrome.alarms.create(name, { when: nextMs })); + } + + /** + * Handle alarm event + */ + const onAlarm = (alarm: chrome.alarms.Alarm): void => { + const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? ''); + if (!triggerId) return; + + const entry = installed.get(triggerId); + if (!entry) return; + + const expectedVersion = entry.version; + + void (async () => { + try { + await fireCallback.onFire(triggerId, { + sourceTabId: undefined, + sourceUrl: undefined, + }); + } catch (e) { + logger.error(`[CronTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + } finally { + // Reschedule if still valid + // eslint-disable-next-line no-unsafe-finally + if (installed.get(triggerId)?.version !== expectedVersion) return; + try { + await scheduleNext(triggerId, expectedVersion); + } catch (e) { + logger.error(`[CronTriggerHandler] Failed to reschedule trigger "${triggerId}":`, e); + } + } + })(); + }; + + function ensureListening(): void { + if (listening) return; + if (!chrome.alarms?.onAlarm?.addListener) { + logger.warn('[CronTriggerHandler] chrome.alarms.onAlarm is unavailable'); + return; + } + chrome.alarms.onAlarm.addListener(onAlarm); + listening = true; + } + + function stopListening(): void { + if (!listening) return; + try { + chrome.alarms.onAlarm.removeListener(onAlarm); + } catch (e) { + logger.debug('[CronTriggerHandler] alarms.onAlarm.removeListener failed:', e); + } finally { + listening = false; + } + } + + return { + kind: 'cron', + + async install(trigger: CronTriggerSpec): Promise { + const cron = normalizeCronExpression(trigger.cron); + const timezone = normalizeTimezone(trigger.timezone); + + const version = bumpVersion(trigger.id); + installed.set(trigger.id, { + spec: { ...trigger, cron }, + timezone, + version, + }); + + ensureListening(); + await scheduleNext(trigger.id, version); + }, + + async uninstall(triggerId: string): Promise { + const id = triggerId as TriggerId; + bumpVersion(id); + installed.delete(id); + await clearAlarmByName(alarmNameForTrigger(id)); + + if (installed.size === 0) { + stopListening(); + } + }, + + async uninstallAll(): Promise { + for (const id of installed.keys()) bumpVersion(id); + installed.clear(); + await clearAllCronAlarms(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger.ts new file mode 100644 index 0000000..5e889ee --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger.ts @@ -0,0 +1,398 @@ +/** + * @fileoverview DOM Trigger Handler (P4-06) + * @description + * Bridges DOM triggers to a content-script MutationObserver (`inject-scripts/dom-observer.js`). + * + * Contract: + * - Background -> content: { action: 'set_dom_triggers', triggers: [...] } + * - Content -> background: { action: 'dom_trigger_fired', triggerId, url } + * - Ping: { action: 'dom_observer_ping' } -> { status:'pong' } + * + * Design notes: + * - Reuses existing V2 dom observer script for consistency and auditability. + * - Single handler instance manages multiple triggers. + * - Sync is coalesced to avoid storms during TriggerManager.refresh(). + * - Top-frame only (no frameId in TriggerFireContext). + */ + +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import { CONTENT_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '../../../../../common/message-types'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +export interface DomTriggerHandlerDeps { + logger?: Pick; +} + +type DomTriggerSpec = TriggerSpecByKind<'dom'>; + +/** + * Payload sent to dom-observer content script + */ +interface DomObserverTriggerPayload { + id: string; + selector: string; + appear: boolean; + once: boolean; + debounceMs: number; +} + +/** + * Message received when DOM trigger fires + */ +interface DomTriggerFiredMessage { + action: string; + triggerId: string; + url?: string; +} + +// ==================== Constants ==================== + +const DOM_OBSERVER_SCRIPT_FILE = 'inject-scripts/dom-observer.js'; +const DEFAULT_DEBOUNCE_MS = 800; + +// ==================== Utilities ==================== + +function normalizeDebounceMs(value: unknown): number { + if (value === undefined || value === null) return DEFAULT_DEBOUNCE_MS; + if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_DEBOUNCE_MS; + return Math.max(0, Math.floor(value)); +} + +/** + * Build payload for dom-observer content script + */ +function buildDomObserverPayload( + installed: Map, +): DomObserverTriggerPayload[] { + const out: DomObserverTriggerPayload[] = []; + + for (const t of installed.values()) { + const selector = String(t.selector ?? '').trim(); + if (!selector) continue; + + out.push({ + id: t.id, + selector, + appear: t.appear !== false, // default true + once: t.once !== false, // default true + debounceMs: normalizeDebounceMs(t.debounceMs), + }); + } + + // Deterministic ordering for tests and debugging + out.sort((a, b) => a.id.localeCompare(b.id)); + return out; +} + +/** + * Check if URL is injectable (http/https/file) + */ +function isInjectableUrl(url: string): boolean { + return /^(https?:|file:)/i.test(url); +} + +/** + * Type guard for DOM trigger fired message + */ +function isDomTriggerFiredMessage(msg: unknown): msg is DomTriggerFiredMessage { + if (!msg || typeof msg !== 'object') return false; + const anyMsg = msg as Record; + return ( + anyMsg.action === TOOL_MESSAGE_TYPES.DOM_TRIGGER_FIRED && typeof anyMsg.triggerId === 'string' + ); +} + +// ==================== Handler Implementation ==================== + +/** + * Create DOM trigger handler factory + */ +export function createDomTriggerHandlerFactory( + deps?: DomTriggerHandlerDeps, +): TriggerHandlerFactory<'dom'> { + return (fireCallback) => createDomTriggerHandler(fireCallback, deps); +} + +/** + * Create DOM trigger handler + */ +export function createDomTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: DomTriggerHandlerDeps, +): TriggerHandler<'dom'> { + const logger = deps?.logger ?? console; + + const installed = new Map(); + + // Payload cache for efficiency + let payloadDirty = true; + let payloadCache: DomObserverTriggerPayload[] = []; + + // Listener states + let messageListening = false; + let navigationListening = false; + + // Coalesce sync to avoid storms (e.g. TriggerManager.refresh) + let syncPromise: Promise | null = null; + let pendingSync = false; + + function markPayloadDirty(): void { + payloadDirty = true; + } + + function getPayload(): DomObserverTriggerPayload[] { + if (!payloadDirty) return payloadCache; + payloadCache = buildDomObserverPayload(installed); + payloadDirty = false; + return payloadCache; + } + + /** + * Ping dom-observer to check if injected + */ + async function pingDomObserver(tabId: number): Promise { + try { + const resp = await chrome.tabs.sendMessage(tabId, { + action: CONTENT_MESSAGE_TYPES.DOM_OBSERVER_PING, + }); + return (resp as { status?: string } | undefined)?.status === 'pong'; + } catch { + return false; + } + } + + /** + * Inject dom-observer script if not present + */ + async function ensureDomObserverInjected(tabId: number): Promise { + const ok = await pingDomObserver(tabId); + if (ok) return; + + if (!chrome.scripting?.executeScript) { + logger.warn('[DomTriggerHandler] chrome.scripting.executeScript is unavailable'); + return; + } + + try { + await chrome.scripting.executeScript({ + target: { tabId }, + files: [DOM_OBSERVER_SCRIPT_FILE], + world: 'ISOLATED', + }); + } catch (e) { + // Best-effort: injection can fail on restricted pages (chrome://, etc.) + logger.debug('[DomTriggerHandler] executeScript failed:', e); + } + } + + /** + * Send triggers to dom-observer + */ + async function setDomTriggers( + tabId: number, + triggers: DomObserverTriggerPayload[], + ): Promise { + try { + await chrome.tabs.sendMessage(tabId, { + action: TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS, + triggers, + }); + } catch (e) { + // No receiver / restricted pages are expected; keep best-effort. + logger.debug('[DomTriggerHandler] set_dom_triggers sendMessage failed:', e); + } + } + + /** + * Sync triggers to a single tab + */ + async function syncTab(tabId: number, url: string | undefined): Promise { + if (typeof url === 'string' && url && !isInjectableUrl(url)) return; + + const payload = getPayload(); + if (payload.length > 0) { + await ensureDomObserverInjected(tabId); + } + await setDomTriggers(tabId, payload); + } + + /** + * Sync triggers to all tabs + */ + async function doSyncAllTabs(): Promise { + if (!chrome.tabs?.query) { + logger.warn('[DomTriggerHandler] chrome.tabs.query is unavailable'); + return; + } + + let tabs: chrome.tabs.Tab[] = []; + try { + tabs = await chrome.tabs.query({}); + } catch (e) { + logger.debug('[DomTriggerHandler] tabs.query failed:', e); + return; + } + + await Promise.all( + tabs + .filter((t) => typeof t.id === 'number') + .filter((t) => (typeof t.url === 'string' ? isInjectableUrl(t.url) : true)) + .map((t) => syncTab(t.id as number, t.url)), + ); + } + + /** + * Request sync (coalesced) + */ + async function requestSyncAllTabs(): Promise { + pendingSync = true; + if (!syncPromise) { + syncPromise = (async () => { + while (pendingSync) { + pendingSync = false; + await doSyncAllTabs(); + } + })().finally(() => { + syncPromise = null; + }); + } + return syncPromise; + } + + /** + * Handle runtime message (dom_trigger_fired) + */ + const onRuntimeMessage = ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, + ): boolean => { + if (!isDomTriggerFiredMessage(message)) return false; + + const triggerId = message.triggerId as TriggerId; + if (!installed.has(triggerId)) { + try { + sendResponse({ ok: false }); + } catch { + // ignore + } + return false; + } + + const sourceTabId = sender.tab?.id; + const sourceUrl = message.url ?? sender.tab?.url; + + // Fire-and-forget: do not block chrome messaging thread + Promise.resolve(fireCallback.onFire(triggerId, { sourceTabId, sourceUrl })).catch((e) => { + logger.error(`[DomTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + }); + + try { + sendResponse({ ok: true }); + } catch { + // ignore + } + return false; + }; + + /** + * Handle navigation completed (re-sync triggers to tab) + */ + const onNavigationCompleted = ( + details: chrome.webNavigation.WebNavigationFramedCallbackDetails, + ): void => { + if (details.frameId !== 0) return; // Top frame only + if (installed.size === 0) return; + if (typeof details.url === 'string' && details.url && !isInjectableUrl(details.url)) return; + + void syncTab(details.tabId, details.url).catch((e) => { + logger.debug('[DomTriggerHandler] syncTab on navigation failed:', e); + }); + }; + + function ensureMessageListening(): void { + if (messageListening) return; + if (!chrome.runtime?.onMessage?.addListener) { + logger.warn('[DomTriggerHandler] chrome.runtime.onMessage is unavailable'); + return; + } + chrome.runtime.onMessage.addListener(onRuntimeMessage); + messageListening = true; + } + + function stopMessageListening(): void { + if (!messageListening) return; + try { + chrome.runtime.onMessage.removeListener(onRuntimeMessage); + } catch (e) { + logger.debug('[DomTriggerHandler] runtime.onMessage.removeListener failed:', e); + } finally { + messageListening = false; + } + } + + function ensureNavigationListening(): void { + if (navigationListening) return; + if (!chrome.webNavigation?.onCompleted?.addListener) { + logger.warn('[DomTriggerHandler] chrome.webNavigation.onCompleted is unavailable'); + return; + } + chrome.webNavigation.onCompleted.addListener(onNavigationCompleted); + navigationListening = true; + } + + function stopNavigationListening(): void { + if (!navigationListening) return; + try { + chrome.webNavigation.onCompleted.removeListener(onNavigationCompleted); + } catch (e) { + logger.debug('[DomTriggerHandler] webNavigation.onCompleted.removeListener failed:', e); + } finally { + navigationListening = false; + } + } + + return { + kind: 'dom', + + async install(trigger: DomTriggerSpec): Promise { + installed.set(trigger.id, trigger); + markPayloadDirty(); + + // Ensure listeners are ready before pushing triggers + ensureMessageListening(); + ensureNavigationListening(); + + await requestSyncAllTabs(); + }, + + async uninstall(triggerId: string): Promise { + installed.delete(triggerId as TriggerId); + markPayloadDirty(); + + await requestSyncAllTabs(); + + if (installed.size === 0) { + stopNavigationListening(); + stopMessageListening(); + } + }, + + async uninstallAll(): Promise { + installed.clear(); + markPayloadDirty(); + + await requestSyncAllTabs(); + + stopNavigationListening(); + stopMessageListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/index.ts new file mode 100644 index 0000000..63c36f5 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/index.ts @@ -0,0 +1,12 @@ +/** + * @fileoverview Triggers 模块导出入口 + */ + +export * from './trigger-handler'; +export * from './trigger-manager'; +export * from './url-trigger'; +export * from './command-trigger'; +export * from './context-menu-trigger'; +export * from './dom-trigger'; +export * from './cron-trigger'; +export * from './manual-trigger'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts new file mode 100644 index 0000000..0bd6c4e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts @@ -0,0 +1,243 @@ +/** + * @fileoverview Interval Trigger Handler (M3.1) + * @description + * 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。 + * + * 策略: + * - 每个触发器对应一个重复 alarm + * - 使用 delayInMinutes 使首次触发在配置的间隔后 + */ + +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +type IntervalTriggerSpec = TriggerSpecByKind<'interval'>; + +export interface IntervalTriggerHandlerDeps { + logger?: Pick; +} + +interface InstalledIntervalTrigger { + spec: IntervalTriggerSpec; + periodMinutes: number; + version: number; +} + +// ==================== Constants ==================== + +const ALARM_PREFIX = 'rr_v3_interval_'; + +// ==================== Utilities ==================== + +/** + * 校验并规范化 periodMinutes + */ +function normalizePeriodMinutes(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error('periodMinutes must be a finite number'); + } + if (value < 1) { + throw new Error('periodMinutes must be >= 1'); + } + return value; +} + +/** + * 生成 alarm 名称 + */ +function alarmNameForTrigger(triggerId: TriggerId): string { + return `${ALARM_PREFIX}${triggerId}`; +} + +/** + * 从 alarm 名称解析 triggerId + */ +function parseTriggerIdFromAlarmName(name: string): TriggerId | null { + if (!name.startsWith(ALARM_PREFIX)) return null; + const id = name.slice(ALARM_PREFIX.length); + return id ? (id as TriggerId) : null; +} + +// ==================== Handler Implementation ==================== + +/** + * 创建 interval 触发器处理器工厂 + */ +export function createIntervalTriggerHandlerFactory( + deps?: IntervalTriggerHandlerDeps, +): TriggerHandlerFactory<'interval'> { + return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps); +} + +/** + * 创建 interval 触发器处理器 + */ +export function createIntervalTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: IntervalTriggerHandlerDeps, +): TriggerHandler<'interval'> { + const logger = deps?.logger ?? console; + + const installed = new Map(); + const versions = new Map(); + let listening = false; + + /** + * 递增版本号以使挂起的操作失效 + */ + function bumpVersion(triggerId: TriggerId): number { + const next = (versions.get(triggerId) ?? 0) + 1; + versions.set(triggerId, next); + return next; + } + + /** + * 清除指定 alarm + */ + async function clearAlarmByName(name: string): Promise { + if (!chrome.alarms?.clear) return; + try { + await Promise.resolve(chrome.alarms.clear(name)); + } catch (e) { + logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e); + } + } + + /** + * 清除所有 interval alarms + */ + async function clearAllIntervalAlarms(): Promise { + if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return; + try { + const alarms = await Promise.resolve(chrome.alarms.getAll()); + const list = Array.isArray(alarms) ? alarms : []; + await Promise.all( + list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)), + ); + } catch (e) { + logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e); + } + } + + /** + * 调度 alarm + */ + async function schedule(triggerId: TriggerId, expectedVersion: number): Promise { + if (!chrome.alarms?.create) { + logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable'); + return; + } + + const entry = installed.get(triggerId); + if (!entry || entry.version !== expectedVersion) return; + + const name = alarmNameForTrigger(triggerId); + const periodInMinutes = entry.periodMinutes; + + try { + // 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm + // 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发 + await Promise.resolve( + chrome.alarms.create(name, { + delayInMinutes: periodInMinutes, + periodInMinutes, + }), + ); + } catch (e) { + logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e); + } + } + + /** + * Alarm 事件处理 + */ + const onAlarm = (alarm: chrome.alarms.Alarm): void => { + const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? ''); + if (!triggerId) return; + + const entry = installed.get(triggerId); + if (!entry) return; + + // 触发回调 + Promise.resolve( + fireCallback.onFire(triggerId, { + sourceTabId: undefined, + sourceUrl: undefined, + }), + ).catch((e) => { + logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + }); + }; + + /** + * 确保正在监听 alarm 事件 + */ + function ensureListening(): void { + if (listening) return; + if (!chrome.alarms?.onAlarm?.addListener) { + logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable'); + return; + } + chrome.alarms.onAlarm.addListener(onAlarm); + listening = true; + } + + /** + * 停止监听 alarm 事件 + */ + function stopListening(): void { + if (!listening) return; + try { + chrome.alarms.onAlarm.removeListener(onAlarm); + } catch (e) { + logger.debug('[IntervalTriggerHandler] removeListener failed:', e); + } finally { + listening = false; + } + } + + return { + kind: 'interval', + + async install(trigger: IntervalTriggerSpec): Promise { + const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes); + + const version = bumpVersion(trigger.id); + installed.set(trigger.id, { + spec: { ...trigger, periodMinutes }, + periodMinutes, + version, + }); + + ensureListening(); + await schedule(trigger.id, version); + }, + + async uninstall(triggerId: string): Promise { + const id = triggerId as TriggerId; + bumpVersion(id); + installed.delete(id); + await clearAlarmByName(alarmNameForTrigger(id)); + + if (installed.size === 0) { + stopListening(); + } + }, + + async uninstallAll(): Promise { + for (const id of installed.keys()) { + bumpVersion(id); + } + installed.clear(); + await clearAllIntervalAlarms(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger.ts new file mode 100644 index 0000000..8108221 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Manual Trigger Handler (P4-08) + * @description + * Manual triggers are the simplest trigger type - they don't auto-fire. + * They're only triggered programmatically via RPC or UI. + * + * This handler just tracks installed triggers but doesn't set up any listeners. + * Manual triggers are fired by calling TriggerManager's fire method directly. + */ + +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +export interface ManualTriggerHandlerDeps { + logger?: Pick; +} + +type ManualTriggerSpec = TriggerSpecByKind<'manual'>; + +// ==================== Handler Implementation ==================== + +/** + * Create manual trigger handler factory + */ +export function createManualTriggerHandlerFactory( + deps?: ManualTriggerHandlerDeps, +): TriggerHandlerFactory<'manual'> { + return (fireCallback) => createManualTriggerHandler(fireCallback, deps); +} + +/** + * Create manual trigger handler + * + * Manual triggers don't auto-fire - they're only triggered via RPC. + * This handler just tracks which manual triggers are installed. + */ +export function createManualTriggerHandler( + _fireCallback: TriggerFireCallback, + _deps?: ManualTriggerHandlerDeps, +): TriggerHandler<'manual'> { + const installed = new Map(); + + return { + kind: 'manual', + + async install(trigger: ManualTriggerSpec): Promise { + installed.set(trigger.id, trigger); + }, + + async uninstall(triggerId: string): Promise { + installed.delete(triggerId as TriggerId); + }, + + async uninstallAll(): Promise { + installed.clear(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts new file mode 100644 index 0000000..fb14acf --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts @@ -0,0 +1,290 @@ +/** + * @fileoverview Once Trigger Handler (M3.1) + * @description + * 使用 chrome.alarms 的 when 参数实现一次性定时触发。 + * + * 行为: + * - 每个触发器对应一个一次性 alarm + * - 触发后自动将触发器禁用 (enabled=false) 并卸载 + */ + +import type { UnixMillis } from '../../domain/json'; +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind } from '../../domain/triggers'; +import { createTriggersStore } from '../../storage/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +type OnceTriggerSpec = TriggerSpecByKind<'once'>; + +export interface OnceTriggerHandlerDeps { + logger?: Pick; + /** + * 可选:自定义禁用触发器的方法 + * 如果未提供,将直接更新 TriggerStore + */ + disableTrigger?: (triggerId: TriggerId) => Promise; +} + +interface InstalledOnceTrigger { + spec: OnceTriggerSpec; + whenMs: UnixMillis; + version: number; +} + +// ==================== Constants ==================== + +const ALARM_PREFIX = 'rr_v3_once_'; + +// ==================== Utilities ==================== + +/** + * 校验并规范化 whenMs + */ +function normalizeWhenMs(value: unknown): UnixMillis { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error('whenMs must be a finite number'); + } + return Math.floor(value) as UnixMillis; +} + +/** + * 生成 alarm 名称 + */ +function alarmNameForTrigger(triggerId: TriggerId): string { + return `${ALARM_PREFIX}${triggerId}`; +} + +/** + * 从 alarm 名称解析 triggerId + */ +function parseTriggerIdFromAlarmName(name: string): TriggerId | null { + if (!name.startsWith(ALARM_PREFIX)) return null; + const id = name.slice(ALARM_PREFIX.length); + return id ? (id as TriggerId) : null; +} + +// ==================== Handler Implementation ==================== + +/** + * 创建 once 触发器处理器工厂 + */ +export function createOnceTriggerHandlerFactory( + deps?: OnceTriggerHandlerDeps, +): TriggerHandlerFactory<'once'> { + return (fireCallback) => createOnceTriggerHandler(fireCallback, deps); +} + +/** + * 创建 once 触发器处理器 + */ +export function createOnceTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: OnceTriggerHandlerDeps, +): TriggerHandler<'once'> { + const logger = deps?.logger ?? console; + + // 延迟创建 store,避免在测试环境中出问题 + let triggersStore: ReturnType | null = null; + const getTriggersStore = () => { + if (!triggersStore) { + triggersStore = createTriggersStore(); + } + return triggersStore; + }; + + const disableTrigger = + deps?.disableTrigger ?? + (async (triggerId: TriggerId) => { + const store = getTriggersStore(); + const existing = await store.get(triggerId); + if (!existing) return; + if (!existing.enabled) return; + await store.save({ ...existing, enabled: false }); + }); + + const installed = new Map(); + const versions = new Map(); + let listening = false; + + /** + * 递增版本号以使挂起的操作失效 + */ + function bumpVersion(triggerId: TriggerId): number { + const next = (versions.get(triggerId) ?? 0) + 1; + versions.set(triggerId, next); + return next; + } + + /** + * 清除指定 alarm + */ + async function clearAlarmByName(name: string): Promise { + if (!chrome.alarms?.clear) return; + try { + await Promise.resolve(chrome.alarms.clear(name)); + } catch (e) { + logger.debug('[OnceTriggerHandler] alarms.clear failed:', e); + } + } + + /** + * 清除所有 once alarms + */ + async function clearAllOnceAlarms(): Promise { + if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return; + try { + const alarms = await Promise.resolve(chrome.alarms.getAll()); + const list = Array.isArray(alarms) ? alarms : []; + await Promise.all( + list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)), + ); + } catch (e) { + logger.debug('[OnceTriggerHandler] alarms.getAll failed:', e); + } + } + + /** + * 调度 alarm + */ + async function schedule(triggerId: TriggerId, expectedVersion: number): Promise { + if (!chrome.alarms?.create) { + logger.warn('[OnceTriggerHandler] chrome.alarms.create is unavailable'); + return; + } + + const entry = installed.get(triggerId); + if (!entry || entry.version !== expectedVersion) return; + + const name = alarmNameForTrigger(triggerId); + + try { + await Promise.resolve(chrome.alarms.create(name, { when: entry.whenMs })); + } catch (e) { + logger.error(`[OnceTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e); + } + } + + /** + * 内部卸载逻辑(不触发外部 uninstall) + */ + async function uninstallInternal(triggerId: TriggerId): Promise { + bumpVersion(triggerId); + installed.delete(triggerId); + await clearAlarmByName(alarmNameForTrigger(triggerId)); + + if (installed.size === 0) { + stopListening(); + } + } + + /** + * Alarm 事件处理 + */ + const onAlarm = (alarm: chrome.alarms.Alarm): void => { + const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? ''); + if (!triggerId) return; + + const entry = installed.get(triggerId); + if (!entry) return; + + const expectedVersion = entry.version; + + void (async () => { + try { + await fireCallback.onFire(triggerId, { + sourceTabId: undefined, + sourceUrl: undefined, + }); + } catch (e) { + logger.error(`[OnceTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + } finally { + // 检查版本是否仍然有效 + if (installed.get(triggerId)?.version === expectedVersion) { + // 禁用触发器 + try { + await disableTrigger(triggerId); + } catch (e) { + logger.error( + `[OnceTriggerHandler] Failed to disable trigger "${triggerId}" after fire:`, + e, + ); + } + + // 卸载触发器 + try { + await uninstallInternal(triggerId); + } catch (e) { + logger.error( + `[OnceTriggerHandler] Failed to uninstall trigger "${triggerId}" after fire:`, + e, + ); + } + } + } + })(); + }; + + /** + * 确保正在监听 alarm 事件 + */ + function ensureListening(): void { + if (listening) return; + if (!chrome.alarms?.onAlarm?.addListener) { + logger.warn('[OnceTriggerHandler] chrome.alarms.onAlarm is unavailable'); + return; + } + chrome.alarms.onAlarm.addListener(onAlarm); + listening = true; + } + + /** + * 停止监听 alarm 事件 + */ + function stopListening(): void { + if (!listening) return; + try { + chrome.alarms.onAlarm.removeListener(onAlarm); + } catch (e) { + logger.debug('[OnceTriggerHandler] removeListener failed:', e); + } finally { + listening = false; + } + } + + return { + kind: 'once', + + async install(trigger: OnceTriggerSpec): Promise { + const whenMs = normalizeWhenMs(trigger.whenMs); + + const version = bumpVersion(trigger.id); + installed.set(trigger.id, { + spec: { ...trigger, whenMs }, + whenMs, + version, + }); + + ensureListening(); + await schedule(trigger.id, version); + }, + + async uninstall(triggerId: string): Promise { + await uninstallInternal(triggerId as TriggerId); + }, + + async uninstallAll(): Promise { + for (const id of installed.keys()) { + bumpVersion(id); + } + installed.clear(); + await clearAllOnceAlarms(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler.ts new file mode 100644 index 0000000..fcd9e8a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler.ts @@ -0,0 +1,66 @@ +/** + * @fileoverview 触发器处理器接口定义 + * @description 定义各类触发器的统一接口 + */ + +import type { TriggerSpec, TriggerKind } from '../../domain/triggers'; + +/** + * 触发器处理器接口 + * @description 每种触发器类型需要实现此接口 + */ +export interface TriggerHandler { + /** 触发器类型 */ + readonly kind: K; + + /** + * 安装触发器 + * @description 注册 chrome API 监听器等 + * @param trigger 触发器规范 + */ + install(trigger: Extract): Promise; + + /** + * 卸载触发器 + * @description 移除 chrome API 监听器等 + * @param triggerId 触发器 ID + */ + uninstall(triggerId: string): Promise; + + /** + * 卸载所有触发器 + * @description 清理所有此类型的触发器 + */ + uninstallAll(): Promise; + + /** + * 获取已安装的触发器 ID 列表 + */ + getInstalledIds(): string[]; +} + +/** + * 触发器触发回调 + * @description TriggerManager 注入给各 Handler 的回调 + */ +export interface TriggerFireCallback { + /** + * 触发器被触发时调用 + * @param triggerId 触发器 ID + * @param context 触发上下文 + */ + onFire( + triggerId: string, + context: { + sourceTabId?: number; + sourceUrl?: string; + }, + ): Promise; +} + +/** + * 触发器处理器工厂 + */ +export type TriggerHandlerFactory = ( + fireCallback: TriggerFireCallback, +) => TriggerHandler; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager.ts new file mode 100644 index 0000000..c9a9316 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager.ts @@ -0,0 +1,427 @@ +/** + * @fileoverview 触发器管理器 + * @description + * TriggerManager 负责管理所有触发器 Handler 的生命周期: + * - 从 TriggerStore 加载触发器并安装 + * - 处理触发器触发事件,调用 enqueueRun + * - 提供防风暴机制 (cooldown + maxQueued) + * + * 设计理由: + * - Orchestrator 模式:TriggerManager 不直接实现各类触发器逻辑,而是委托给 per-kind Handler + * - Handler 工厂模式:TriggerManager 在构造时创建 Handler 实例,注入 fireCallback + * - 防风暴:cooldown (per-trigger) + maxQueued (global best-effort) + */ + +import type { UnixMillis } from '../../domain/json'; +import type { RunId, TriggerId } from '../../domain/ids'; +import type { TriggerFireContext, TriggerKind, TriggerSpec } from '../../domain/triggers'; +import type { StoragePort } from '../storage/storage-port'; +import type { EventsBus } from '../transport/events-bus'; +import type { RunScheduler } from '../queue/scheduler'; +import { enqueueRun, type EnqueueRunResult } from '../queue/enqueue-run'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +/** + * Handler 工厂映射 + */ +export type TriggerHandlerFactories = Partial<{ + [K in TriggerKind]: TriggerHandlerFactory; +}>; + +/** + * 防风暴配置 + */ +export interface TriggerManagerStormControl { + /** + * 同一触发器两次触发之间的最小间隔 (ms) + * - 0 或 undefined 表示禁用冷却 + */ + cooldownMs?: number; + + /** + * 全局最大排队 Run 数量 + * - 达到上限时拒绝新的触发 + * - undefined 表示禁用上限检查 + * - 注意:这是 best-effort 检查,非原子性 + */ + maxQueued?: number; +} + +/** + * TriggerManager 依赖 + */ +export interface TriggerManagerDeps { + /** 存储层 */ + storage: Pick; + /** 事件总线 */ + events: Pick; + /** 调度器 (可选) */ + scheduler?: Pick; + /** Handler 工厂映射 */ + handlerFactories: TriggerHandlerFactories; + /** 防风暴配置 */ + storm?: TriggerManagerStormControl; + /** RunId 生成器 (用于测试注入) */ + generateRunId?: () => RunId; + /** 时间源 (用于测试注入) */ + now?: () => UnixMillis; + /** 日志器 */ + logger?: Pick; +} + +/** + * TriggerManager 状态 + */ +export interface TriggerManagerState { + /** 是否已启动 */ + started: boolean; + /** 已安装的触发器 ID 列表 */ + installedTriggerIds: TriggerId[]; +} + +/** + * TriggerManager 接口 + */ +export interface TriggerManager { + /** 启动管理器,加载并安装所有启用的触发器 */ + start(): Promise; + /** 停止管理器,卸载所有触发器 */ + stop(): Promise; + /** 刷新触发器,重新从存储加载并安装 */ + refresh(): Promise; + /** + * 手动触发一个触发器 + * @description 仅供 RPC/UI 调用,用于 manual 触发器 + */ + fire( + triggerId: TriggerId, + context?: { sourceTabId?: number; sourceUrl?: string }, + ): Promise; + /** 销毁管理器 */ + dispose(): Promise; + /** 获取当前状态 */ + getState(): TriggerManagerState; +} + +// ==================== Utilities ==================== + +/** + * 校验非负整数 + */ +function normalizeNonNegativeInt(value: unknown, fallback: number, fieldName: string): number { + if (value === undefined || value === null) return fallback; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${fieldName} must be a finite number`); + } + return Math.max(0, Math.floor(value)); +} + +/** + * 校验正整数 + */ +function normalizePositiveInt(value: unknown, fieldName: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${fieldName} must be a finite number`); + } + const intValue = Math.floor(value); + if (intValue < 1) { + throw new Error(`${fieldName} must be >= 1`); + } + return intValue; +} + +// ==================== Implementation ==================== + +/** + * 创建 TriggerManager + */ +export function createTriggerManager(deps: TriggerManagerDeps): TriggerManager { + const logger = deps.logger ?? console; + const now = deps.now ?? (() => Date.now()); + + // 防风暴参数 + const cooldownMs = normalizeNonNegativeInt(deps.storm?.cooldownMs, 0, 'storm.cooldownMs'); + const maxQueued = + deps.storm?.maxQueued === undefined || deps.storm?.maxQueued === null + ? undefined + : normalizePositiveInt(deps.storm.maxQueued, 'storm.maxQueued'); + + // 状态 + const installed = new Map(); + const lastFireAt = new Map(); + let started = false; + let inFlightEnqueues = 0; + + // 防止 refresh 重入 + let refreshPromise: Promise | null = null; + let pendingRefresh = false; + + // Handler 实例 + const handlers = new Map>(); + + // 触发回调 + const fireCallback: TriggerFireCallback = { + onFire: async (triggerId, context) => { + // 捕获所有异常,避免抛入 chrome API 监听器 + try { + await handleFire(triggerId as TriggerId, context); + } catch (e) { + logger.error('[TriggerManager] onFire failed:', e); + } + }, + }; + + // 初始化 Handler 实例 + for (const [kind, factory] of Object.entries(deps.handlerFactories) as Array< + [TriggerKind, TriggerHandlerFactory | undefined] + >) { + if (!factory) continue; // Skip undefined factory values + + const handler = factory(fireCallback) as TriggerHandler; + if (handler.kind !== kind) { + throw new Error( + `[TriggerManager] Handler kind mismatch: factory key is "${kind}", but handler.kind is "${handler.kind}"`, + ); + } + handlers.set(kind, handler); + } + + /** + * 处理触发器触发(内部方法) + * @param throwOnDrop 如果为 true,则在 cooldown/maxQueued 等情况下抛出错误 + * @returns EnqueueRunResult 或 null(静默丢弃) + */ + async function handleFire( + triggerId: TriggerId, + context: { sourceTabId?: number; sourceUrl?: string }, + options?: { throwOnDrop?: boolean }, + ): Promise { + if (!started) { + if (options?.throwOnDrop) { + throw new Error('TriggerManager is not started'); + } + return null; + } + + const trigger = installed.get(triggerId); + if (!trigger) { + if (options?.throwOnDrop) { + throw new Error(`Trigger "${triggerId}" is not installed`); + } + return null; + } + + const t = now(); + + // Per-trigger cooldown 检查 + const prevLastFireAt = lastFireAt.get(triggerId); + if (cooldownMs > 0 && prevLastFireAt !== undefined && t - prevLastFireAt < cooldownMs) { + logger.debug(`[TriggerManager] Dropping trigger "${triggerId}" (cooldown ${cooldownMs}ms)`); + if (options?.throwOnDrop) { + throw new Error(`Trigger "${triggerId}" dropped (cooldown ${cooldownMs}ms)`); + } + return null; + } + + // Global maxQueued 检查 (best-effort) + // 注意:在 cooldown 设置前检查,避免因 maxQueued drop 而误设 cooldown + if (maxQueued !== undefined) { + const queued = await deps.storage.queue.list('queued'); + if (queued.length + inFlightEnqueues >= maxQueued) { + logger.warn( + `[TriggerManager] Dropping trigger "${triggerId}" (queued=${queued.length}, inFlight=${inFlightEnqueues}, maxQueued=${maxQueued})`, + ); + if (options?.throwOnDrop) { + throw new Error(`Trigger "${triggerId}" dropped (maxQueued=${maxQueued})`); + } + return null; + } + } + + // 设置 lastFireAt 以抑制并发触发(在 maxQueued 检查通过后) + if (cooldownMs > 0) { + lastFireAt.set(triggerId, t); + } + + // 构建触发上下文 + const triggerContext: TriggerFireContext = { + triggerId: trigger.id, + kind: trigger.kind, + firedAt: t, + sourceTabId: context.sourceTabId, + sourceUrl: context.sourceUrl, + }; + + inFlightEnqueues += 1; + try { + const result = await enqueueRun( + { + storage: deps.storage, + events: deps.events, + scheduler: deps.scheduler, + generateRunId: deps.generateRunId, + now, + }, + { + flowId: trigger.flowId, + args: trigger.args, + trigger: triggerContext, + }, + ); + return result; + } catch (e) { + // 入队失败时回滚 cooldown 标记 + if (cooldownMs > 0) { + if (prevLastFireAt === undefined) { + lastFireAt.delete(triggerId); + } else { + lastFireAt.set(triggerId, prevLastFireAt); + } + } + const msg = e instanceof Error ? e.message : String(e); + logger.error(`[TriggerManager] enqueueRun failed for trigger "${triggerId}":`, e); + if (options?.throwOnDrop) { + throw new Error(`enqueueRun failed for trigger "${triggerId}": ${msg}`); + } + return null; + } finally { + inFlightEnqueues -= 1; + } + } + + /** + * 手动触发一个触发器(对外暴露) + * @description 用于 RPC/UI 调用,会抛出错误而不是静默丢弃 + */ + async function fire( + triggerId: TriggerId, + context: { sourceTabId?: number; sourceUrl?: string } = {}, + ): Promise { + const result = await handleFire(triggerId, context, { throwOnDrop: true }); + if (!result) { + throw new Error(`Trigger "${triggerId}" did not enqueue a run`); + } + return result; + } + + /** + * 执行刷新 + */ + async function doRefresh(): Promise { + const triggers = await deps.storage.triggers.list(); + if (!started) return; + + // 先卸载所有,再重新安装 (简单策略,保证一致性) + // Best-effort: 单个 handler 卸载失败不影响其他 + for (const handler of handlers.values()) { + try { + await handler.uninstallAll(); + } catch (e) { + logger.warn(`[TriggerManager] Error during uninstallAll for kind "${handler.kind}":`, e); + } + } + installed.clear(); + + // 安装启用的触发器 + for (const trigger of triggers) { + if (!started) return; + if (!trigger.enabled) continue; + + const handler = handlers.get(trigger.kind); + if (!handler) { + logger.warn(`[TriggerManager] No handler registered for kind "${trigger.kind}"`); + continue; + } + + try { + await handler.install(trigger as Parameters[0]); + installed.set(trigger.id, trigger); + } catch (e) { + logger.error(`[TriggerManager] Failed to install trigger "${trigger.id}":`, e); + } + } + } + + /** + * 刷新触发器 (合并并发调用) + */ + async function refresh(): Promise { + if (!started) { + throw new Error('TriggerManager is not started'); + } + + pendingRefresh = true; + if (!refreshPromise) { + refreshPromise = (async () => { + while (started && pendingRefresh) { + pendingRefresh = false; + await doRefresh(); + } + })().finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; + } + + /** + * 启动管理器 + */ + async function start(): Promise { + if (started) return; + started = true; + await refresh(); + } + + /** + * 停止管理器 + */ + async function stop(): Promise { + if (!started) return; + + started = false; + pendingRefresh = false; + + // 等待进行中的 refresh 完成 + if (refreshPromise) { + try { + await refreshPromise; + } catch { + // 忽略 refresh 错误 + } + } + + // 卸载所有触发器 + for (const handler of handlers.values()) { + try { + await handler.uninstallAll(); + } catch (e) { + logger.warn('[TriggerManager] Error uninstalling handler:', e); + } + } + installed.clear(); + lastFireAt.clear(); + } + + /** + * 销毁管理器 + */ + async function dispose(): Promise { + await stop(); + } + + /** + * 获取状态 + */ + function getState(): TriggerManagerState { + return { + started, + installedTriggerIds: Array.from(installed.keys()), + }; + } + + return { start, stop, refresh, fire, dispose, getState }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts new file mode 100644 index 0000000..fa4dddc --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts @@ -0,0 +1,261 @@ +/** + * @fileoverview URL Trigger Handler (P4-03) + * @description + * Listens to `chrome.webNavigation.onCompleted` and fires installed URL triggers. + * + * URL matching semantics: + * - kind:'url' - Full URL prefix match (allows query/hash variations) + * - kind:'domain' - Safe subdomain match (hostname === domain OR hostname.endsWith('.' + domain)) + * - kind:'path' - Pathname prefix match + * + * Design rationale: + * - No regex/wildcards for performance and auditability + * - Domain matching uses safe subdomain logic to avoid false positives (e.g. 'notexample.com') + * - Single listener instance manages multiple triggers efficiently + */ + +import type { TriggerId } from '../../domain/ids'; +import type { TriggerSpecByKind, UrlMatchRule } from '../../domain/triggers'; +import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler'; + +// ==================== Types ==================== + +export interface UrlTriggerHandlerDeps { + logger?: Pick; +} + +type UrlTriggerSpec = TriggerSpecByKind<'url'>; + +/** + * Compiled URL match rules for efficient matching + */ +interface CompiledUrlRules { + /** Full URL prefixes */ + urlPrefixes: string[]; + /** Normalized domains (lowercase, no leading/trailing dots) */ + domains: string[]; + /** Normalized path prefixes (always starts with '/') */ + pathPrefixes: string[]; +} + +interface InstalledUrlTrigger { + spec: UrlTriggerSpec; + rules: CompiledUrlRules; +} + +// ==================== Normalization Utilities ==================== + +/** + * Normalize domain value + * - Trim whitespace + * - Convert to lowercase + * - Remove leading/trailing dots + */ +function normalizeDomain(value: string): string | null { + const normalized = value.trim().toLowerCase().replace(/^\.+/, '').replace(/\.+$/, ''); + return normalized || null; +} + +/** + * Normalize path prefix + * - Trim whitespace + * - Ensure starts with '/' + */ +function normalizePathPrefix(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + +/** + * Normalize URL prefix + * - Trim whitespace only + */ +function normalizeUrlPrefix(value: string): string | null { + const trimmed = value.trim(); + return trimmed || null; +} + +/** + * Compile URL match rules from spec + */ +function compileUrlMatchRules(match: UrlMatchRule[] | undefined): CompiledUrlRules { + const urlPrefixes: string[] = []; + const domains: string[] = []; + const pathPrefixes: string[] = []; + + for (const rule of match ?? []) { + const { kind } = rule; + const raw = typeof rule.value === 'string' ? rule.value : String(rule.value ?? ''); + + switch (kind) { + case 'url': { + const normalized = normalizeUrlPrefix(raw); + if (normalized) urlPrefixes.push(normalized); + break; + } + case 'domain': { + const normalized = normalizeDomain(raw); + if (normalized) domains.push(normalized); + break; + } + case 'path': { + const normalized = normalizePathPrefix(raw); + if (normalized) pathPrefixes.push(normalized); + break; + } + } + } + + return { urlPrefixes, domains, pathPrefixes }; +} + +// ==================== Matching Logic ==================== + +/** + * Check if hostname matches domain (exact or subdomain) + */ +function hostnameMatchesDomain(hostname: string, domain: string): boolean { + if (hostname === domain) return true; + return hostname.endsWith(`.${domain}`); +} + +/** + * Check if URL matches any of the compiled rules + */ +function matchesRules(compiled: CompiledUrlRules, urlString: string, parsed: URL): boolean { + // URL prefix match + for (const prefix of compiled.urlPrefixes) { + if (urlString.startsWith(prefix)) return true; + } + + // Domain match + const hostname = parsed.hostname.toLowerCase(); + for (const domain of compiled.domains) { + if (hostnameMatchesDomain(hostname, domain)) return true; + } + + // Path prefix match + const pathname = parsed.pathname || '/'; + for (const prefix of compiled.pathPrefixes) { + if (pathname.startsWith(prefix)) return true; + } + + return false; +} + +// ==================== Handler Implementation ==================== + +/** + * Create URL trigger handler factory + */ +export function createUrlTriggerHandlerFactory( + deps?: UrlTriggerHandlerDeps, +): TriggerHandlerFactory<'url'> { + return (fireCallback) => createUrlTriggerHandler(fireCallback, deps); +} + +/** + * Create URL trigger handler + */ +export function createUrlTriggerHandler( + fireCallback: TriggerFireCallback, + deps?: UrlTriggerHandlerDeps, +): TriggerHandler<'url'> { + const logger = deps?.logger ?? console; + + const installed = new Map(); + let listening = false; + + /** + * Handle webNavigation.onCompleted event + */ + const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails): void => { + // Only handle main frame navigations + if (details.frameId !== 0) return; + + const urlString = details.url; + + // Parse URL + let parsed: URL; + try { + parsed = new URL(urlString); + } catch { + return; // Invalid URL, skip + } + + if (installed.size === 0) return; + + // Snapshot to avoid iteration hazards during concurrent install/uninstall + const snapshot = Array.from(installed.entries()); + + for (const [triggerId, trigger] of snapshot) { + if (!matchesRules(trigger.rules, urlString, parsed)) continue; + + // Fire and forget: chrome event listeners should not block navigation + Promise.resolve( + fireCallback.onFire(triggerId, { + sourceTabId: details.tabId, + sourceUrl: urlString, + }), + ).catch((e) => { + logger.error(`[UrlTriggerHandler] onFire failed for trigger "${triggerId}":`, e); + }); + } + }; + + /** + * Ensure listener is registered + */ + function ensureListening(): void { + if (listening) return; + if (!chrome.webNavigation?.onCompleted?.addListener) { + logger.warn('[UrlTriggerHandler] chrome.webNavigation.onCompleted is unavailable'); + return; + } + chrome.webNavigation.onCompleted.addListener(onCompleted); + listening = true; + } + + /** + * Stop listening + */ + function stopListening(): void { + if (!listening) return; + try { + chrome.webNavigation.onCompleted.removeListener(onCompleted); + } catch (e) { + logger.debug('[UrlTriggerHandler] removeListener failed:', e); + } finally { + listening = false; + } + } + + return { + kind: 'url', + + async install(trigger: UrlTriggerSpec): Promise { + installed.set(trigger.id, { + spec: trigger, + rules: compileUrlMatchRules(trigger.match), + }); + ensureListening(); + }, + + async uninstall(triggerId: string): Promise { + installed.delete(triggerId as TriggerId); + if (installed.size === 0) { + stopListening(); + } + }, + + async uninstallAll(): Promise { + installed.clear(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/index.ts new file mode 100644 index 0000000..1e7aad7 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/index.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview Record-Replay V3 公共 API 入口 + * @description 导出所有公共类型和接口 + */ + +// ==================== Domain ==================== +export * from './domain'; + +// ==================== Engine ==================== +export * from './engine'; + +// ==================== Storage ==================== +export * from './storage'; + +// ==================== Factory Functions ==================== + +import type { StoragePort } from './engine/storage/storage-port'; +import { createFlowsStore } from './storage/flows'; +import { createRunsStore } from './storage/runs'; +import { createEventsStore } from './storage/events'; +import { createQueueStore } from './storage/queue'; +import { createPersistentVarsStore } from './storage/persistent-vars'; +import { createTriggersStore } from './storage/triggers'; + +/** + * 创建完整的 StoragePort 实现 + */ +export function createStoragePort(): StoragePort { + return { + flows: createFlowsStore(), + runs: createRunsStore(), + events: createEventsStore(), + queue: createQueueStore(), + persistentVars: createPersistentVarsStore(), + triggers: createTriggersStore(), + }; +} + +// ==================== Version ==================== + +/** V3 API 版本 */ +export const RR_V3_VERSION = '3.0.0' as const; + +/** 是否为 V3 API */ +export const IS_RR_V3 = true as const; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts new file mode 100644 index 0000000..cac040e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts @@ -0,0 +1,231 @@ +/** + * @fileoverview V3 IndexedDB 数据库定义 + * @description 定义 rr_v3 数据库的 schema 和初始化逻辑 + */ + +/** 数据库名称 */ +export const RR_V3_DB_NAME = 'rr_v3'; + +/** 数据库版本 */ +export const RR_V3_DB_VERSION = 1; + +/** + * Store 名称常量 + */ +export const RR_V3_STORES = { + FLOWS: 'flows', + RUNS: 'runs', + EVENTS: 'events', + QUEUE: 'queue', + PERSISTENT_VARS: 'persistent_vars', + TRIGGERS: 'triggers', +} as const; + +/** + * Store 配置 + */ +export interface StoreConfig { + keyPath: string | string[]; + autoIncrement?: boolean; + indexes?: Array<{ + name: string; + keyPath: string | string[]; + options?: IDBIndexParameters; + }>; +} + +/** + * V3 Store Schema 定义 + * @description 包含 Phase 1-3 所需的所有索引,避免后续升级 + */ +export const RR_V3_STORE_SCHEMAS: Record = { + [RR_V3_STORES.FLOWS]: { + keyPath: 'id', + indexes: [ + { name: 'name', keyPath: 'name' }, + { name: 'updatedAt', keyPath: 'updatedAt' }, + ], + }, + [RR_V3_STORES.RUNS]: { + keyPath: 'id', + indexes: [ + { name: 'status', keyPath: 'status' }, + { name: 'flowId', keyPath: 'flowId' }, + { name: 'createdAt', keyPath: 'createdAt' }, + { name: 'updatedAt', keyPath: 'updatedAt' }, + // Compound index for listing runs by flow and status + { name: 'flowId_status', keyPath: ['flowId', 'status'] }, + ], + }, + [RR_V3_STORES.EVENTS]: { + keyPath: ['runId', 'seq'], + indexes: [ + { name: 'runId', keyPath: 'runId' }, + { name: 'type', keyPath: 'type' }, + // Compound index for filtering events by run and type + { name: 'runId_type', keyPath: ['runId', 'type'] }, + ], + }, + [RR_V3_STORES.QUEUE]: { + keyPath: 'id', + indexes: [ + { name: 'status', keyPath: 'status' }, + { name: 'priority', keyPath: 'priority' }, + { name: 'createdAt', keyPath: 'createdAt' }, + { name: 'flowId', keyPath: 'flowId' }, + // Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC. + { name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] }, + // Phase 3: Lease expiration tracking + { name: 'lease_expiresAt', keyPath: 'lease.expiresAt' }, + ], + }, + [RR_V3_STORES.PERSISTENT_VARS]: { + keyPath: 'key', + indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }], + }, + [RR_V3_STORES.TRIGGERS]: { + keyPath: 'id', + indexes: [ + { name: 'kind', keyPath: 'kind' }, + { name: 'flowId', keyPath: 'flowId' }, + { name: 'enabled', keyPath: 'enabled' }, + // Compound index for listing enabled triggers by kind + { name: 'kind_enabled', keyPath: ['kind', 'enabled'] }, + ], + }, +}; + +/** + * 数据库升级处理器 + */ +export function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void { + // Version 0 -> 1: 创建所有 stores + if (oldVersion < 1) { + for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) { + const store = db.createObjectStore(storeName, { + keyPath: config.keyPath, + autoIncrement: config.autoIncrement, + }); + + // 创建索引 + if (config.indexes) { + for (const index of config.indexes) { + store.createIndex(index.name, index.keyPath, index.options); + } + } + } + } +} + +/** 全局数据库实例 */ +let dbInstance: IDBDatabase | null = null; +let dbPromise: Promise | null = null; + +/** + * 打开 V3 数据库 + * @description 单例模式,确保只有一个数据库连接 + */ +export async function openRrV3Db(): Promise { + if (dbInstance) { + return dbInstance; + } + + if (dbPromise) { + return dbPromise; + } + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION); + + request.onerror = () => { + dbPromise = null; + reject(new Error(`Failed to open database: ${request.error?.message}`)); + }; + + request.onsuccess = () => { + dbInstance = request.result; + + // 处理版本变更(其他 tab 升级了数据库) + dbInstance.onversionchange = () => { + dbInstance?.close(); + dbInstance = null; + dbPromise = null; + }; + + resolve(dbInstance); + }; + + request.onupgradeneeded = (event) => { + const db = request.result; + const oldVersion = event.oldVersion; + const newVersion = event.newVersion ?? RR_V3_DB_VERSION; + handleUpgrade(db, oldVersion, newVersion); + }; + }); + + return dbPromise; +} + +/** + * 关闭数据库连接 + * @description 主要用于测试 + */ +export function closeRrV3Db(): void { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + dbPromise = null; + } +} + +/** + * 删除数据库 + * @description 主要用于测试 + */ +export async function deleteRrV3Db(): Promise { + closeRrV3Db(); + + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(RR_V3_DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * 执行事务 + * @param storeNames Store 名称(单个或多个) + * @param mode 事务模式 + * @param callback 事务回调 + */ +export async function withTransaction( + storeNames: string | string[], + mode: IDBTransactionMode, + callback: (stores: Record) => Promise | T, +): Promise { + const db = await openRrV3Db(); + const names = Array.isArray(storeNames) ? storeNames : [storeNames]; + const tx = db.transaction(names, mode); + + const stores: Record = {}; + for (const name of names) { + stores[name] = tx.objectStore(name); + } + + return new Promise((resolve, reject) => { + let result: T; + + tx.oncomplete = () => resolve(result); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error || new Error('Transaction aborted')); + + Promise.resolve(callback(stores)) + .then((r) => { + result = r; + }) + .catch((err) => { + tx.abort(); + reject(err); + }); + }); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts new file mode 100644 index 0000000..e6d6ffd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview RunEvent 持久化 + * @description 实现事件的原子 seq 分配和存储 + */ + +import type { RunId } from '../domain/ids'; +import type { RunEvent, RunEventInput, RunRecordV3 } from '../domain/events'; +import { RR_ERROR_CODES, createRRError } from '../domain/errors'; +import type { EventsStore } from '../engine/storage/storage-port'; +import { RR_V3_STORES, withTransaction } from './db'; + +/** + * IDB request helper - promisify IDBRequest with RRError wrapping + */ +function idbRequest(request: IDBRequest, context: string): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + const error = request.error; + reject( + createRRError( + RR_ERROR_CODES.INTERNAL, + `IDB error in ${context}: ${error?.message ?? 'unknown'}`, + ), + ); + }; + }); +} + +/** + * 创建 EventsStore 实现 + * @description + * - append() 在单个事务中原子分配 seq + * - seq 由 RunRecordV3.nextSeq 作为单一事实来源 + */ +export function createEventsStore(): EventsStore { + return { + /** + * 追加事件并原子分配 seq + * @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq + */ + async append(input: RunEventInput): Promise { + return withTransaction( + [RR_V3_STORES.RUNS, RR_V3_STORES.EVENTS], + 'readwrite', + async (stores) => { + const runsStore = stores[RR_V3_STORES.RUNS]; + const eventsStore = stores[RR_V3_STORES.EVENTS]; + + // Step 1: Read nextSeq from RunRecordV3 (single source of truth) + const run = await idbRequest( + runsStore.get(input.runId), + `append.getRun(${input.runId})`, + ); + + if (!run) { + throw createRRError( + RR_ERROR_CODES.INTERNAL, + `Run "${input.runId}" not found when appending event`, + ); + } + + const seq = run.nextSeq; + + // Validate seq integrity + if (!Number.isSafeInteger(seq) || seq < 0) { + throw createRRError( + RR_ERROR_CODES.INVARIANT_VIOLATION, + `Invalid nextSeq for run "${input.runId}": ${String(seq)}`, + ); + } + + // Step 2: Create complete event with allocated seq + const event: RunEvent = { + ...input, + seq, + ts: input.ts ?? Date.now(), + } as RunEvent; + + // Step 3: Write event to events store + await idbRequest(eventsStore.add(event), `append.addEvent(${input.runId}, seq=${seq})`); + + // Step 4: Increment nextSeq in runs store (same transaction) + const updatedRun: RunRecordV3 = { + ...run, + nextSeq: seq + 1, + updatedAt: Date.now(), + }; + + await idbRequest( + runsStore.put(updatedRun), + `append.updateNextSeq(${input.runId}, nextSeq=${seq + 1})`, + ); + + return event; + }, + ); + }, + + /** + * 列出事件 + * @description 利用复合主键 [runId, seq] 实现高效范围查询 + */ + async list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise { + return withTransaction(RR_V3_STORES.EVENTS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.EVENTS]; + const fromSeq = opts?.fromSeq ?? 0; + const limit = opts?.limit; + + // Early return for zero limit + if (limit === 0) { + return []; + } + + return new Promise((resolve, reject) => { + const results: RunEvent[] = []; + + // Use compound primary key [runId, seq] for efficient range query + // This yields events in seq-ascending order naturally + const range = IDBKeyRange.bound([runId, fromSeq], [runId, Number.MAX_SAFE_INTEGER]); + + const request = store.openCursor(range); + + request.onsuccess = () => { + const cursor = request.result; + + if (!cursor) { + resolve(results); + return; + } + + const event = cursor.value as RunEvent; + results.push(event); + + // Check limit + if (limit !== undefined && results.length >= limit) { + resolve(results); + return; + } + + cursor.continue(); + }; + + request.onerror = () => reject(request.error); + }); + }); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts new file mode 100644 index 0000000..360e024 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts @@ -0,0 +1,114 @@ +/** + * @fileoverview FlowV3 持久化 + * @description 实现 Flow 的 CRUD 操作 + */ + +import type { FlowId } from '../domain/ids'; +import type { FlowV3 } from '../domain/flow'; +import { FLOW_SCHEMA_VERSION } from '../domain/flow'; +import { RR_ERROR_CODES, createRRError } from '../domain/errors'; +import type { FlowsStore } from '../engine/storage/storage-port'; +import { RR_V3_STORES, withTransaction } from './db'; + +/** + * 校验 Flow 结构 + */ +function validateFlow(flow: FlowV3): void { + // 校验 schema 版本 + if (flow.schemaVersion !== FLOW_SCHEMA_VERSION) { + throw createRRError( + RR_ERROR_CODES.VALIDATION_ERROR, + `Invalid schema version: expected ${FLOW_SCHEMA_VERSION}, got ${flow.schemaVersion}`, + ); + } + + // 校验必填字段 + if (!flow.id) { + throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow id is required'); + } + if (!flow.name) { + throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow name is required'); + } + if (!flow.entryNodeId) { + throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow entryNodeId is required'); + } + + // 校验 entryNodeId 存在 + const nodeIds = new Set(flow.nodes.map((n) => n.id)); + if (!nodeIds.has(flow.entryNodeId)) { + throw createRRError( + RR_ERROR_CODES.VALIDATION_ERROR, + `Entry node "${flow.entryNodeId}" does not exist in flow`, + ); + } + + // 校验边引用 + for (const edge of flow.edges) { + if (!nodeIds.has(edge.from)) { + throw createRRError( + RR_ERROR_CODES.VALIDATION_ERROR, + `Edge "${edge.id}" references non-existent source node "${edge.from}"`, + ); + } + if (!nodeIds.has(edge.to)) { + throw createRRError( + RR_ERROR_CODES.VALIDATION_ERROR, + `Edge "${edge.id}" references non-existent target node "${edge.to}"`, + ); + } + } +} + +/** + * 创建 FlowsStore 实现 + */ +export function createFlowsStore(): FlowsStore { + return { + async list(): Promise { + return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.FLOWS]; + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result as FlowV3[]); + request.onerror = () => reject(request.error); + }); + }); + }, + + async get(id: FlowId): Promise { + return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.FLOWS]; + return new Promise((resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => resolve((request.result as FlowV3) ?? null); + request.onerror = () => reject(request.error); + }); + }); + }, + + async save(flow: FlowV3): Promise { + // 校验 + validateFlow(flow); + + return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.FLOWS]; + return new Promise((resolve, reject) => { + const request = store.put(flow); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async delete(id: FlowId): Promise { + return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.FLOWS]; + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/index.ts new file mode 100644 index 0000000..ca8a67c --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Import 模块导出入口 + */ + +export * from './v2-reader'; +export * from './v2-to-v3'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-reader.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-reader.ts new file mode 100644 index 0000000..4e7897d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-reader.ts @@ -0,0 +1,35 @@ +/** + * @fileoverview V2 数据读取器 + * @description 读取 V2 格式的数据(占位实现) + */ + +/** + * V2 数据读取器接口 + * @description Phase 5+ 实现 + */ +export interface V2Reader { + /** 读取 V2 Flows */ + readFlows(): Promise; + /** 读取 V2 Runs */ + readRuns(): Promise; + /** 读取 V2 Triggers */ + readTriggers(): Promise; + /** 读取 V2 Schedules */ + readSchedules(): Promise; +} + +/** + * 创建 NotImplemented 的 V2Reader + */ +export function createNotImplementedV2Reader(): V2Reader { + const notImplemented = async () => { + throw new Error('V2Reader not implemented'); + }; + + return { + readFlows: notImplemented, + readRuns: notImplemented, + readTriggers: notImplemented, + readSchedules: notImplemented, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts new file mode 100644 index 0000000..bd1e671 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts @@ -0,0 +1,671 @@ +/** + * @fileoverview V2 到 V3 数据转换器 + * @description 将 V2 格式数据转换为 V3 格式,支持双向转换 + */ + +import type { FlowV3, NodeV3, EdgeV3, FlowBinding } from '../../domain/flow'; +import type { TriggerSpec } from '../../domain/triggers'; +import type { VariableDefinition } from '../../domain/variables'; +import type { NodeId, FlowId, EdgeId } from '../../domain/ids'; +import type { ISODateTimeString } from '../../domain/json'; +import { FLOW_SCHEMA_VERSION } from '../../domain/flow'; + +// ==================== V2 Types (imported from record-replay) ==================== + +/** V2 Node type definition */ +interface V2Node { + id: string; + type: string; + name?: string; + disabled?: boolean; + config?: Record; + ui?: { x: number; y: number }; +} + +/** V2 Edge type definition */ +interface V2Edge { + id: string; + from: string; + to: string; + label?: string; +} + +/** V2 Variable definition */ +interface V2VariableDef { + key: string; + label?: string; + sensitive?: boolean; + default?: unknown; + type?: string; + rules?: { required?: boolean; pattern?: string; enum?: string[] }; +} + +/** V2 Flow binding */ +interface V2Binding { + type: 'domain' | 'path' | 'url'; + value: string; +} + +/** V2 Flow definition */ +interface V2Flow { + id: string; + name: string; + description?: string; + version: number; + meta?: { + createdAt?: string; + updatedAt?: string; + domain?: string; + tags?: string[]; + bindings?: V2Binding[]; + tool?: { category?: string; description?: string }; + exposedOutputs?: Array<{ nodeId: string; as: string }>; + }; + variables?: V2VariableDef[]; + nodes?: V2Node[]; + edges?: V2Edge[]; + subflows?: Record; +} + +// ==================== Conversion Result Types ==================== + +export interface ConversionResult { + success: boolean; + data?: T; + errors: string[]; + warnings: string[]; +} + +// ==================== V2 -> V3 Conversion ==================== + +/** + * 将 V2 Flow 转换为 V3 Flow + * @param v2Flow V2 格式的 Flow + * @returns 转换结果,包含成功/失败状态、数据和错误/警告信息 + */ +export function convertFlowV2ToV3(v2Flow: V2Flow): ConversionResult { + const errors: string[] = []; + const warnings: string[] = []; + + // 1. 基础字段验证 + if (!v2Flow.id) { + errors.push('V2 Flow missing required field: id'); + } + if (!v2Flow.name) { + errors.push('V2 Flow missing required field: name'); + } + if (!v2Flow.nodes || v2Flow.nodes.length === 0) { + errors.push('V2 Flow has no nodes'); + } + + // 2. 检查不支持的特性 + if (v2Flow.subflows && Object.keys(v2Flow.subflows).length > 0) { + errors.push( + 'V3 does not support subflows yet. Flow contains subflows: ' + + Object.keys(v2Flow.subflows).join(', '), + ); + } + + // 检查 foreach/while 节点 + const unsupportedNodes = (v2Flow.nodes || []).filter( + (n) => n.type === 'foreach' || n.type === 'while', + ); + if (unsupportedNodes.length > 0) { + errors.push( + 'V3 does not support foreach/while nodes yet. Found: ' + + unsupportedNodes.map((n) => `${n.id} (${n.type})`).join(', '), + ); + } + + // 如果有致命错误,直接返回 + if (errors.length > 0) { + return { success: false, errors, warnings }; + } + + // 3. 转换节点 + const nodes: NodeV3[] = []; + for (const v2Node of v2Flow.nodes || []) { + const node = convertNodeV2ToV3(v2Node); + if (node) { + nodes.push(node); + } else { + warnings.push(`Skipped invalid node: ${v2Node.id}`); + } + } + + // 4. 转换边 + const edges: EdgeV3[] = []; + for (const v2Edge of v2Flow.edges || []) { + const edge = convertEdgeV2ToV3(v2Edge); + if (edge) { + edges.push(edge); + } else { + warnings.push(`Skipped invalid edge: ${v2Edge.id}`); + } + } + + // 5. 计算 entryNodeId + const entryResult = findEntryNodeId(nodes, edges); + warnings.push(...entryResult.warnings); + if (!entryResult.nodeId) { + errors.push('Could not determine entry node. No valid root node found.'); + return { success: false, errors, warnings }; + } + const entryNodeId = entryResult.nodeId; + + // 6. 转换变量 + const variables = convertVariablesV2ToV3(v2Flow.variables || []); + + // 7. 转换元数据 + const meta = convertMetaV2ToV3(v2Flow.meta); + + // 8. 构建 V3 Flow + const now = new Date().toISOString() as ISODateTimeString; + const v3Flow: FlowV3 = { + schemaVersion: FLOW_SCHEMA_VERSION, + id: v2Flow.id as FlowId, + name: v2Flow.name, + createdAt: (v2Flow.meta?.createdAt as ISODateTimeString) || now, + updatedAt: (v2Flow.meta?.updatedAt as ISODateTimeString) || now, + entryNodeId, + nodes, + edges, + }; + + // 可选字段 + if (v2Flow.description) { + v3Flow.description = v2Flow.description; + } + if (variables.length > 0) { + v3Flow.variables = variables; + } + if (meta) { + v3Flow.meta = meta; + } + + return { success: true, data: v3Flow, errors, warnings }; +} + +/** + * 转换单个 V2 Node 为 V3 Node + */ +function convertNodeV2ToV3(v2Node: V2Node): NodeV3 | null { + if (!v2Node.id || !v2Node.type) { + return null; + } + + const node: NodeV3 = { + id: v2Node.id as NodeId, + kind: v2Node.type, // V2 type -> V3 kind + config: (v2Node.config as Record) || {}, + }; + + // 可选字段 + if (v2Node.name) { + node.name = v2Node.name; + } + if (v2Node.disabled) { + node.disabled = v2Node.disabled; + } + if (v2Node.ui) { + node.ui = v2Node.ui; + } + + return node; +} + +/** + * 转换单个 V2 Edge 为 V3 Edge + */ +function convertEdgeV2ToV3(v2Edge: V2Edge): EdgeV3 | null { + if (!v2Edge.id || !v2Edge.from || !v2Edge.to) { + return null; + } + + const edge: EdgeV3 = { + id: v2Edge.id as EdgeId, + from: v2Edge.from as NodeId, + to: v2Edge.to as NodeId, + }; + + // label 直接传递 + if (v2Edge.label) { + edge.label = v2Edge.label as EdgeV3['label']; + } + + return edge; +} + +/** entryNodeId 计算结果 */ +interface EntryNodeResult { + nodeId: NodeId | null; + warnings: string[]; +} + +/** + * 找到入口节点 ID + * + * 规则: + * 1. 排除 trigger 类型节点(这些是 UI 节点,不参与执行) + * 2. 只统计「可执行节点 -> 可执行节点」的边来计算入度(忽略 trigger 指出的边) + * 3. 找到入度为 0 的节点作为候选 + * 4. 如果有多个候选,使用稳定选择规则: + * - 优先选择 UI 坐标最靠左上的节点(按 x 升序,x 相同按 y 升序) + * - 如果无 UI 坐标,按 ID 字典序取第一个 + */ +function findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): EntryNodeResult { + const warnings: string[] = []; + + // 1. 排除 trigger 节点,获取可执行节点 + const executableNodes = nodes.filter((n) => n.kind !== 'trigger'); + if (executableNodes.length === 0) { + warnings.push('No executable nodes found; cannot determine entry node'); + return { nodeId: null, warnings }; + } + + const executableNodeIds = new Set(executableNodes.map((n) => n.id)); + + // 2. 计算入度(只统计可执行节点之间的边) + const inDegree = new Map(); + for (const node of executableNodes) { + inDegree.set(node.id, 0); + } + for (const edge of edges) { + // 忽略从非可执行节点(如 trigger)指出的边 + if (!executableNodeIds.has(edge.from)) { + continue; + } + // 忽略指向非可执行节点的边 + if (!executableNodeIds.has(edge.to)) { + continue; + } + inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1); + } + + // 3. 找入度为 0 的节点 + const rootNodes = executableNodes.filter((n) => inDegree.get(n.id) === 0); + + if (rootNodes.length === 0) { + // 没有入度为 0 的节点,说明图中存在环,使用稳定选择器选择 fallback + const fallbackResult = selectStableRootNode(executableNodes); + warnings.push( + `No inDegree=0 executable node found (graph may contain cycles); ` + + `falling back to "${fallbackResult.node.id}" by ${fallbackResult.rule}`, + ); + return { nodeId: fallbackResult.node.id, warnings }; + } + + // 4. 单个根节点,直接返回 + if (rootNodes.length === 1) { + return { nodeId: rootNodes[0].id, warnings }; + } + + // 5. 多个根节点,使用稳定选择规则 + const selectedResult = selectStableRootNode(rootNodes); + const candidateIds = rootNodes + .map((n) => n.id) + .sort((a, b) => a.localeCompare(b)) + .join(', '); + warnings.push( + `Multiple inDegree=0 executable nodes (${candidateIds}); ` + + `selected "${selectedResult.node.id}" by ${selectedResult.rule}`, + ); + + return { nodeId: selectedResult.node.id, warnings }; +} + +/** 稳定选择结果 */ +interface StableSelectionResult { + node: NodeV3; + rule: string; +} + +/** + * 从多个根节点中选择一个稳定的入口节点 + * 优先按 UI 坐标(左上角优先),其次按 ID 字典序 + */ +function selectStableRootNode(nodes: NodeV3[]): StableSelectionResult { + // 检查节点是否有有效的 UI 坐标 + const hasValidUi = (n: NodeV3): n is NodeV3 & { ui: { x: number; y: number } } => + !!n.ui && Number.isFinite(n.ui.x) && Number.isFinite(n.ui.y); + + const nodesWithUi = nodes.filter(hasValidUi); + + if (nodesWithUi.length > 0) { + // 按 UI 坐标排序:x 升序 -> y 升序 -> id 字典序(作为 tie-breaker) + nodesWithUi.sort((a, b) => { + if (a.ui.x !== b.ui.x) return a.ui.x - b.ui.x; + if (a.ui.y !== b.ui.y) return a.ui.y - b.ui.y; + return a.id.localeCompare(b.id); + }); + const selected = nodesWithUi[0]; + return { + node: selected, + rule: `ui(x=${selected.ui.x}, y=${selected.ui.y})`, + }; + } + + // 无 UI 坐标,按 ID 字典序 + const sortedById = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); + return { node: sortedById[0], rule: 'id' }; +} + +/** + * 转换变量定义 + */ +function convertVariablesV2ToV3(v2Variables: V2VariableDef[]): VariableDefinition[] { + return v2Variables + .filter((v) => v.key) + .map((v) => { + const variable: VariableDefinition = { + name: v.key, + }; + + if (v.label) { + variable.label = v.label; + } + if (v.sensitive) { + variable.sensitive = v.sensitive; + } + if (v.default !== undefined) { + variable.default = v.default; + } + if (v.rules?.required) { + variable.required = v.rules.required; + } + + return variable; + }); +} + +/** + * 转换元数据 + */ +function convertMetaV2ToV3(v2Meta: V2Flow['meta']): FlowV3['meta'] | undefined { + if (!v2Meta) return undefined; + + const meta: FlowV3['meta'] = {}; + + if (v2Meta.tags && v2Meta.tags.length > 0) { + meta.tags = v2Meta.tags; + } + + if (v2Meta.bindings && v2Meta.bindings.length > 0) { + meta.bindings = v2Meta.bindings.map((b) => ({ + kind: b.type, // V2 type -> V3 kind + value: b.value, + })); + } + + // 如果 meta 为空对象,返回 undefined + if (Object.keys(meta).length === 0) { + return undefined; + } + + return meta; +} + +// ==================== V3 -> V2 Conversion ==================== + +/** + * 将 V3 Flow 转换为 V2 Flow(用于在 V2 Builder 中编辑) + * @param v3Flow V3 格式的 Flow + * @returns 转换结果 + */ +export function convertFlowV3ToV2(v3Flow: FlowV3): ConversionResult { + const errors: string[] = []; + const warnings: string[] = []; + + // 1. 转换节点 + const nodes: V2Node[] = v3Flow.nodes.map((n) => ({ + id: n.id, + type: n.kind, // V3 kind -> V2 type + name: n.name, + disabled: n.disabled, + config: n.config as Record, + ui: n.ui, + })); + + // 2. 转换边 + const edges: V2Edge[] = v3Flow.edges.map((e) => ({ + id: e.id, + from: e.from, + to: e.to, + label: e.label, + })); + + // 3. 转换变量 + const variables: V2VariableDef[] = (v3Flow.variables || []).map((v) => ({ + key: v.name, + label: v.label, + sensitive: v.sensitive, + default: v.default, + rules: v.required ? { required: v.required } : undefined, + })); + + // 4. 转换元数据 + const meta: V2Flow['meta'] = { + createdAt: v3Flow.createdAt, + updatedAt: v3Flow.updatedAt, + }; + + if (v3Flow.meta?.tags) { + meta.tags = v3Flow.meta.tags; + } + + if (v3Flow.meta?.bindings) { + meta.bindings = v3Flow.meta.bindings.map((b) => ({ + type: b.kind, // V3 kind -> V2 type + value: b.value, + })); + } + + // 5. 构建 V2 Flow + const v2Flow: V2Flow = { + id: v3Flow.id, + name: v3Flow.name, + description: v3Flow.description, + version: 2, // V2 版本 + meta, + variables: variables.length > 0 ? variables : undefined, + nodes, + edges, + }; + + return { success: true, data: v2Flow, errors, warnings }; +} + +// ==================== Trigger Conversion ==================== + +/** V2 Trigger 定义 */ +interface V2Trigger { + id: string; + type: 'url' | 'command' | 'manual' | 'schedule' | 'element'; + flowId: string; + enabled?: boolean; + match?: Array<{ kind: string; value: string }>; + title?: string; + commandKey?: string; + selector?: string; + appear?: boolean; + once?: boolean; + debounceMs?: number; + schedule?: { + type: 'interval' | 'daily' | 'weekly'; + intervalMs?: number; + time?: string; + days?: number[]; + }; +} + +/** + * 将 V2 Trigger 转换为 V3 TriggerSpec + * @param v2Trigger V2 格式的 Trigger + * @returns 转换结果 + */ +export function convertTriggerV2ToV3(v2Trigger: V2Trigger): ConversionResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!v2Trigger.id) { + errors.push('V2 Trigger missing required field: id'); + } + if (!v2Trigger.flowId) { + errors.push('V2 Trigger missing required field: flowId'); + } + if (!v2Trigger.type) { + errors.push('V2 Trigger missing required field: type'); + } + + if (errors.length > 0) { + return { success: false, errors, warnings }; + } + + // 根据 type 构建不同的 TriggerSpec + let trigger: TriggerSpec; + + switch (v2Trigger.type) { + case 'manual': + trigger = { + id: v2Trigger.id, + kind: 'manual', + flowId: v2Trigger.flowId as FlowId, + enabled: v2Trigger.enabled ?? true, + }; + break; + + case 'command': + trigger = { + id: v2Trigger.id, + kind: 'command', + flowId: v2Trigger.flowId as FlowId, + enabled: v2Trigger.enabled ?? true, + command: v2Trigger.commandKey || 'run_workflow', + }; + break; + + case 'url': + trigger = { + id: v2Trigger.id, + kind: 'url', + flowId: v2Trigger.flowId as FlowId, + enabled: v2Trigger.enabled ?? true, + patterns: (v2Trigger.match || []).map((m) => m.value), + }; + break; + + case 'schedule': { // 将 V2 schedule 转换为 cron 表达式 + const cron = convertScheduleToCron(v2Trigger.schedule); + if (!cron) { + errors.push('Could not convert V2 schedule to cron expression'); + return { success: false, errors, warnings }; + } + trigger = { + id: v2Trigger.id, + kind: 'cron', + flowId: v2Trigger.flowId as FlowId, + enabled: v2Trigger.enabled ?? true, + cron, + }; + break; + } + + case 'element': + warnings.push('Element trigger is not fully supported in V3, converting to manual'); + trigger = { + id: v2Trigger.id, + kind: 'manual', + flowId: v2Trigger.flowId as FlowId, + enabled: v2Trigger.enabled ?? true, + }; + break; + + default: + errors.push(`Unknown V2 trigger type: ${v2Trigger.type}`); + return { success: false, errors, warnings }; + } + + return { success: true, data: trigger, errors, warnings }; +} + +/** + * 将 V2 schedule 配置转换为 cron 表达式 + */ +function convertScheduleToCron(schedule: V2Trigger['schedule']): string | null { + if (!schedule) return null; + + switch (schedule.type) { + case 'interval': { // 将间隔转换为近似 cron(每 N 分钟) + const intervalMinutes = Math.max(1, Math.round((schedule.intervalMs || 60000) / 60000)); + if (intervalMinutes < 60) { + return `*/${intervalMinutes} * * * *`; + } else { + const hours = Math.round(intervalMinutes / 60); + return `0 */${hours} * * *`; + } + } + + case 'daily': + // 每天指定时间 + if (schedule.time) { + const [hour, minute] = schedule.time.split(':').map(Number); + return `${minute || 0} ${hour || 0} * * *`; + } + return '0 0 * * *'; // 默认每天 0:00 + + case 'weekly': { // 每周指定天数和时间 + const days = (schedule.days || [0]).join(','); + if (schedule.time) { + const [hour, minute] = schedule.time.split(':').map(Number); + return `${minute || 0} ${hour || 0} * * ${days}`; + } + return `0 0 * * ${days}`; + } + + default: + return null; + } +} + +// ==================== Converter Interface ==================== + +/** + * V2 到 V3 转换器接口 + */ +export interface V2ToV3Converter { + /** 转换 Flow */ + convertFlow(v2Flow: unknown): FlowV3; + /** 转换 Trigger */ + convertTrigger(v2Trigger: unknown): TriggerSpec; +} + +/** + * 创建 V2ToV3Converter 实例 + */ +export function createV2ToV3Converter(): V2ToV3Converter { + return { + convertFlow(v2Flow: unknown): FlowV3 { + const result = convertFlowV2ToV3(v2Flow as V2Flow); + if (!result.success || !result.data) { + throw new Error(`Flow conversion failed: ${result.errors.join('; ')}`); + } + return result.data; + }, + + convertTrigger(v2Trigger: unknown): TriggerSpec { + const result = convertTriggerV2ToV3(v2Trigger as V2Trigger); + if (!result.success || !result.data) { + throw new Error(`Trigger conversion failed: ${result.errors.join('; ')}`); + } + return result.data; + }, + }; +} + +/** + * 创建 NotImplemented 的 V2ToV3Converter(向后兼容) + * @deprecated 使用 createV2ToV3Converter() 替代 + */ +export function createNotImplementedV2ToV3Converter(): V2ToV3Converter { + return createV2ToV3Converter(); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/index.ts new file mode 100644 index 0000000..c7fd8c1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/index.ts @@ -0,0 +1,12 @@ +/** + * @fileoverview Storage 层导出入口 + */ + +export * from './db'; +export * from './flows'; +export * from './runs'; +export * from './events'; +export * from './queue'; +export * from './persistent-vars'; +export * from './triggers'; +export * from './import'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/persistent-vars.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/persistent-vars.ts new file mode 100644 index 0000000..4e87b16 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/persistent-vars.ts @@ -0,0 +1,88 @@ +/** + * @fileoverview 持久化变量存储 + * @description 实现 $ 前缀变量的持久化,使用 LWW(Last-Write-Wins)策略 + */ + +import type { PersistentVarRecord, PersistentVariableName } from '../domain/variables'; +import type { JsonValue } from '../domain/json'; +import type { PersistentVarsStore } from '../engine/storage/storage-port'; +import { RR_V3_STORES, withTransaction } from './db'; + +/** + * 创建 PersistentVarsStore 实现 + */ +export function createPersistentVarsStore(): PersistentVarsStore { + return { + async get(key: PersistentVariableName): Promise { + return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.PERSISTENT_VARS]; + return new Promise((resolve, reject) => { + const request = store.get(key); + request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined); + request.onerror = () => reject(request.error); + }); + }); + }, + + async set(key: PersistentVariableName, value: JsonValue): Promise { + return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.PERSISTENT_VARS]; + + // 先读取现有记录(用于 version 递增) + const existing = await new Promise((resolve, reject) => { + const request = store.get(key); + request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined); + request.onerror = () => reject(request.error); + }); + + const now = Date.now(); + const record: PersistentVarRecord = { + key, + value, + updatedAt: now, + version: (existing?.version ?? 0) + 1, + }; + + await new Promise((resolve, reject) => { + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + return record; + }); + }, + + async delete(key: PersistentVariableName): Promise { + return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.PERSISTENT_VARS]; + return new Promise((resolve, reject) => { + const request = store.delete(key); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async list(prefix?: PersistentVariableName): Promise { + return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.PERSISTENT_VARS]; + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => { + let results = request.result as PersistentVarRecord[]; + + // 如果指定了前缀,过滤结果 + if (prefix) { + results = results.filter((r) => r.key.startsWith(prefix)); + } + + resolve(results); + }; + request.onerror = () => reject(request.error); + }); + }); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/queue.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/queue.ts new file mode 100644 index 0000000..5b51cc9 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/queue.ts @@ -0,0 +1,526 @@ +/** + * @fileoverview RunQueue 持久化 + * @description 实现队列的 CRUD 操作和原子 claim + */ + +import type { RunId } from '../domain/ids'; +import { + DEFAULT_QUEUE_CONFIG, + type EnqueueInput, + type QueueItemStatus, + type RunQueue, + type RunQueueItem, +} from '../engine/queue/queue'; +import { RR_V3_STORES, withTransaction } from './db'; + +/** Default lease TTL in milliseconds (from shared config to avoid drift) */ +const DEFAULT_LEASE_TTL_MS = DEFAULT_QUEUE_CONFIG.leaseTtlMs; + +/** + * IDB key range bounds for numeric fields. + * Use MAX_VALUE to cover the full range of finite numbers (not just safe integers). + */ +const IDB_NUMBER_MIN = -Number.MAX_VALUE; +const IDB_NUMBER_MAX = Number.MAX_VALUE; + +/** + * 创建 RunQueue 持久化实现 + * @description 实现队列持久化,包括 Phase 3 原子 claim + */ +export function createQueueStore(): RunQueue { + return { + async enqueue(input: EnqueueInput): Promise { + const now = Date.now(); + const item: RunQueueItem = { + ...input, + priority: input.priority ?? 0, + maxAttempts: input.maxAttempts ?? 1, + status: 'queued', + createdAt: now, + updatedAt: now, + attempt: 0, + }; + + await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + return new Promise((resolve, reject) => { + const request = store.add(item); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + + return item; + }, + + async claimNext(ownerId: string, now: number): Promise { + // Validate inputs + if (!ownerId) { + throw new Error('ownerId is required'); + } + if (!Number.isFinite(now)) { + throw new Error(`Invalid now: ${String(now)}`); + } + + return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + const index = store.index('status_priority_createdAt'); + + /** + * Atomic claim implementation using two-step cursor approach: + * + * Desired ordering: priority DESC, createdAt ASC (FIFO within same priority) + * + * IndexedDB compound indexes only support single sort direction for the entire tuple. + * The index ['status', 'priority', 'createdAt'] is stored ASC. + * + * Strategy: + * 1. Use 'prev' cursor to find the highest priority (overall DESC) + * 2. Use 'next' cursor within that priority to find earliest createdAt (FIFO) + * + * Both operations are within the same readwrite transaction, ensuring atomicity + * since IndexedDB serializes readwrite transactions on the same store. + */ + + // Step 1: Find the highest priority among queued items + const queuedRange = IDBKeyRange.bound( + ['queued', IDB_NUMBER_MIN, IDB_NUMBER_MIN], + ['queued', IDB_NUMBER_MAX, IDB_NUMBER_MAX], + ); + + const highestPriority = await new Promise((resolve, reject) => { + const request = index.openCursor(queuedRange, 'prev'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(null); + return; + } + const item = cursor.value as RunQueueItem; + resolve(item.priority); + }; + }); + + // No queued items available + if (highestPriority === null) { + return null; + } + + // Step 2: Find the earliest createdAt within the highest priority (FIFO) + const fifoRange = IDBKeyRange.bound( + ['queued', highestPriority, IDB_NUMBER_MIN], + ['queued', highestPriority, IDB_NUMBER_MAX], + ); + + return new Promise((resolve, reject) => { + const request = index.openCursor(fifoRange, 'next'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + // No items found (should not happen given step 1 succeeded) + resolve(null); + return; + } + + const existing = cursor.value as RunQueueItem; + + // Defensive check: ensure status is still queued + if (existing.status !== 'queued') { + resolve(null); + return; + } + + // Atomically update to running with lease + const updated: RunQueueItem = { + ...existing, + status: 'running', + updatedAt: now, + attempt: existing.attempt + 1, + lease: { + ownerId, + expiresAt: now + DEFAULT_LEASE_TTL_MS, + }, + }; + + const updateRequest = cursor.update(updated); + updateRequest.onerror = () => reject(updateRequest.error); + updateRequest.onsuccess = () => resolve(updated); + }; + }); + }); + }, + + async heartbeat(ownerId: string, now: number): Promise { + // Validate inputs + if (!ownerId) { + throw new Error('ownerId is required'); + } + if (!Number.isFinite(now)) { + throw new Error(`Invalid now: ${String(now)}`); + } + + await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + const statusIndex = store.index('status'); + + /** + * Renew leases for all items owned by ownerId in the given status. + * Uses cursor iteration to update each item atomically. + */ + const renewForStatus = async (status: QueueItemStatus): Promise => { + await new Promise((resolve, reject) => { + const request = statusIndex.openCursor(IDBKeyRange.only(status)); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; + } + + const item = cursor.value as RunQueueItem; + const lease = item.lease; + + // Skip items not owned by this ownerId + if (!lease || lease.ownerId !== ownerId) { + cursor.continue(); + return; + } + + // Renew the lease + const updated: RunQueueItem = { + ...item, + updatedAt: now, + lease: { + ...lease, + expiresAt: now + DEFAULT_LEASE_TTL_MS, + }, + }; + + const updateRequest = cursor.update(updated); + updateRequest.onerror = () => reject(updateRequest.error); + updateRequest.onsuccess = () => cursor.continue(); + }; + }); + }; + + // Renew both running and paused items for the owner. + // Paused items also need renewal to prevent TTL expiration during debug/manual pause. + await renewForStatus('running'); + await renewForStatus('paused'); + }); + }, + + async reclaimExpiredLeases(now: number): Promise { + if (!Number.isFinite(now)) { + throw new Error(`Invalid now: ${String(now)}`); + } + + return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + const leaseIndex = store.index('lease_expiresAt'); + + // Scan all items where lease.expiresAt < now (strictly less than) + const expiredRange = IDBKeyRange.upperBound(now, true); + + return new Promise((resolve, reject) => { + const reclaimed: RunId[] = []; + const request = leaseIndex.openCursor(expiredRange); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(reclaimed); + return; + } + + const item = cursor.value as RunQueueItem; + const expiresAtKey = cursor.key; + + // Defensive: index key should be a finite number (Unix millis) + if (typeof expiresAtKey !== 'number' || !Number.isFinite(expiresAtKey)) { + cursor.continue(); + return; + } + + // The key range already guarantees expiresAtKey < now, but keep a guard + // to be resilient to non-standard IndexedDB implementations. + if (expiresAtKey >= now) { + cursor.continue(); + return; + } + + const isReclaimable = item.status === 'running' || item.status === 'paused'; + + // Reclaim policy: + // - running/paused + expired lease => move back to queued, drop lease + // - any other status + expired lease => drop lease defensively (shouldn't happen) + // Note: attempt is NOT reset on reclaim - preserves retry history. + const { lease: _droppedLease, ...itemWithoutLease } = item; + const updated: RunQueueItem = isReclaimable + ? { ...itemWithoutLease, status: 'queued', updatedAt: now } + : { ...itemWithoutLease, updatedAt: now }; + + const updateRequest = cursor.update(updated); + updateRequest.onerror = () => reject(updateRequest.error); + updateRequest.onsuccess = () => { + if (isReclaimable) { + reclaimed.push(item.id); + } + cursor.continue(); + }; + }; + }); + }); + }, + + async recoverOrphanLeases( + ownerId: string, + now: number, + ): Promise<{ + requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>; + adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>; + }> { + // Validate inputs + if (!ownerId) { + throw new Error('ownerId is required'); + } + if (!Number.isFinite(now)) { + throw new Error(`Invalid now: ${String(now)}`); + } + + return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + const statusIndex = store.index('status'); + + const requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = []; + const adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = []; + + /** + * 扫描并回收孤儿 running 项 + * @description + * - 孤儿定义:无租约或 lease.ownerId !== currentOwnerId + * - 回收策略:status -> queued,清除 lease,保留 attempt + */ + const recoverRunningItems = (): Promise => + new Promise((resolve, reject) => { + const request = statusIndex.openCursor(IDBKeyRange.only('running')); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; + } + + const item = cursor.value as RunQueueItem; + const prevOwnerId = item.lease?.ownerId; + + // 非孤儿:lease 存在且属于当前 ownerId + const isOrphan = !item.lease || item.lease.ownerId !== ownerId; + if (!isOrphan) { + cursor.continue(); + return; + } + + // 回收:移除 lease,状态改为 queued + const { lease: _droppedLease, ...itemWithoutLease } = item; + const updated: RunQueueItem = { + ...itemWithoutLease, + status: 'queued', + updatedAt: now, + }; + + const updateRequest = cursor.update(updated); + updateRequest.onerror = () => reject(updateRequest.error); + updateRequest.onsuccess = () => { + requeuedRunning.push({ + runId: item.id, + ...(prevOwnerId ? { prevOwnerId } : {}), + }); + cursor.continue(); + }; + }; + }); + + /** + * 扫描并接管孤儿 paused 项 + * @description + * - 孤儿定义:无租约或 lease.ownerId !== currentOwnerId + * - 接管策略:保持 status=paused,更新 lease.ownerId 为新 ownerId,续约 TTL + */ + const recoverPausedItems = (): Promise => + new Promise((resolve, reject) => { + const request = statusIndex.openCursor(IDBKeyRange.only('paused')); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; + } + + const item = cursor.value as RunQueueItem; + const prevOwnerId = item.lease?.ownerId; + + // 非孤儿:lease 存在且属于当前 ownerId + const isOrphan = !item.lease || item.lease.ownerId !== ownerId; + if (!isOrphan) { + cursor.continue(); + return; + } + + // 接管:更新 lease 为新 ownerId,续约 TTL + const updated: RunQueueItem = { + ...item, + updatedAt: now, + lease: { + ownerId, + expiresAt: now + DEFAULT_LEASE_TTL_MS, + }, + }; + + const updateRequest = cursor.update(updated); + updateRequest.onerror = () => reject(updateRequest.error); + updateRequest.onsuccess = () => { + adoptedPaused.push({ + runId: item.id, + ...(prevOwnerId ? { prevOwnerId } : {}), + }); + cursor.continue(); + }; + }; + }); + + // 顺序执行:先处理 running,再处理 paused + await recoverRunningItems(); + await recoverPausedItems(); + + return { requeuedRunning, adoptedPaused }; + }); + }, + + async markRunning(runId: RunId, ownerId: string, now: number): Promise { + await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + + const existing = await new Promise((resolve, reject) => { + const request = store.get(runId); + request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null); + request.onerror = () => reject(request.error); + }); + + if (!existing) { + throw new Error(`Queue item "${runId}" not found`); + } + + // Attempt semantics: + // - queued -> running: attempt + 1 (a new scheduling attempt) + // - paused/running -> running: attempt unchanged (resume/idempotent) + const nextAttempt = existing.status === 'queued' ? existing.attempt + 1 : existing.attempt; + + const updated: RunQueueItem = { + ...existing, + status: 'running', + updatedAt: now, + attempt: nextAttempt, + lease: { + ownerId, + expiresAt: now + DEFAULT_LEASE_TTL_MS, + }, + }; + + return new Promise((resolve, reject) => { + const request = store.put(updated); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async markPaused(runId: RunId, ownerId: string, now: number): Promise { + await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + + const existing = await new Promise((resolve, reject) => { + const request = store.get(runId); + request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null); + request.onerror = () => reject(request.error); + }); + + if (!existing) { + throw new Error(`Queue item "${runId}" not found`); + } + + const updated: RunQueueItem = { + ...existing, + status: 'paused', + updatedAt: now, + lease: { + ownerId, + expiresAt: now + DEFAULT_LEASE_TTL_MS, + }, + }; + + return new Promise((resolve, reject) => { + const request = store.put(updated); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async markDone(runId: RunId, now: number): Promise { + await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + return new Promise((resolve, reject) => { + const request = store.delete(runId); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async cancel(runId: RunId, _now: number, _reason?: string): Promise { + // 从队列中删除 + await this.markDone(runId, _now); + }, + + async get(runId: RunId): Promise { + return withTransaction(RR_V3_STORES.QUEUE, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + return new Promise((resolve, reject) => { + const request = store.get(runId); + request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null); + request.onerror = () => reject(request.error); + }); + }); + }, + + async list(status?: QueueItemStatus): Promise { + return withTransaction(RR_V3_STORES.QUEUE, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.QUEUE]; + + if (status) { + // 使用索引查询 + const index = store.index('status'); + return new Promise((resolve, reject) => { + const request = index.getAll(IDBKeyRange.only(status)); + request.onsuccess = () => resolve(request.result as RunQueueItem[]); + request.onerror = () => reject(request.error); + }); + } + + // 获取所有 + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result as RunQueueItem[]); + request.onerror = () => reject(request.error); + }); + }); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts new file mode 100644 index 0000000..39753bf --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts @@ -0,0 +1,110 @@ +/** + * @fileoverview RunRecordV3 持久化 + * @description 实现 Run 记录的 CRUD 操作 + */ + +import type { RunId } from '../domain/ids'; +import type { RunRecordV3 } from '../domain/events'; +import { RUN_SCHEMA_VERSION } from '../domain/events'; +import { RR_ERROR_CODES, createRRError } from '../domain/errors'; +import type { RunsStore } from '../engine/storage/storage-port'; +import { RR_V3_STORES, withTransaction } from './db'; + +/** + * 校验 Run 记录结构 + */ +function validateRunRecord(record: RunRecordV3): void { + // 校验 schema 版本 + if (record.schemaVersion !== RUN_SCHEMA_VERSION) { + throw createRRError( + RR_ERROR_CODES.VALIDATION_ERROR, + `Invalid schema version: expected ${RUN_SCHEMA_VERSION}, got ${record.schemaVersion}`, + ); + } + + // 校验必填字段 + if (!record.id) { + throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run id is required'); + } + if (!record.flowId) { + throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run flowId is required'); + } + if (!record.status) { + throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run status is required'); + } +} + +/** + * 创建 RunsStore 实现 + */ +export function createRunsStore(): RunsStore { + return { + async list(): Promise { + return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.RUNS]; + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result as RunRecordV3[]); + request.onerror = () => reject(request.error); + }); + }); + }, + + async get(id: RunId): Promise { + return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.RUNS]; + return new Promise((resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null); + request.onerror = () => reject(request.error); + }); + }); + }, + + async save(record: RunRecordV3): Promise { + // 校验 + validateRunRecord(record); + + return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.RUNS]; + return new Promise((resolve, reject) => { + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async patch(id: RunId, patch: Partial): Promise { + return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.RUNS]; + + // 先读取现有记录 + const existing = await new Promise((resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null); + request.onerror = () => reject(request.error); + }); + + if (!existing) { + throw createRRError(RR_ERROR_CODES.INTERNAL, `Run "${id}" not found`); + } + + // 合并并更新 + const updated: RunRecordV3 = { + ...existing, + ...patch, + id: existing.id, // 确保 id 不变 + schemaVersion: existing.schemaVersion, // 确保版本不变 + updatedAt: Date.now(), + }; + + return new Promise((resolve, reject) => { + const request = store.put(updated); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/triggers.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/triggers.ts new file mode 100644 index 0000000..a9152b7 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/triggers.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview 触发器存储 + * @description 实现触发器的 CRUD 操作(Phase 4 完整实现) + */ + +import type { TriggerId } from '../domain/ids'; +import type { TriggerSpec } from '../domain/triggers'; +import type { TriggersStore } from '../engine/storage/storage-port'; +import { RR_V3_STORES, withTransaction } from './db'; + +/** + * 创建 TriggersStore 实现 + */ +export function createTriggersStore(): TriggersStore { + return { + async list(): Promise { + return withTransaction(RR_V3_STORES.TRIGGERS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.TRIGGERS]; + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result as TriggerSpec[]); + request.onerror = () => reject(request.error); + }); + }); + }, + + async get(id: TriggerId): Promise { + return withTransaction(RR_V3_STORES.TRIGGERS, 'readonly', async (stores) => { + const store = stores[RR_V3_STORES.TRIGGERS]; + return new Promise((resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => resolve((request.result as TriggerSpec) ?? null); + request.onerror = () => reject(request.error); + }); + }); + }, + + async save(spec: TriggerSpec): Promise { + return withTransaction(RR_V3_STORES.TRIGGERS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.TRIGGERS]; + return new Promise((resolve, reject) => { + const request = store.put(spec); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + async delete(id: TriggerId): Promise { + return withTransaction(RR_V3_STORES.TRIGGERS, 'readwrite', async (stores) => { + const store = stores[RR_V3_STORES.TRIGGERS]; + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts new file mode 100644 index 0000000..e35a718 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts @@ -0,0 +1,513 @@ +/** + * Adapter Layer: Step ↔ Action + * + * Provides conversion utilities between the legacy Step system and the new Action system. + * This adapter enables gradual migration while maintaining backward compatibility. + * + * Architecture: + * - `stepToAction`: Converts a Step to an ExecutableAction + * - `execCtxToActionCtx`: Converts ExecCtx to ActionExecutionContext + * - `actionResultToExecResult`: Converts ActionExecutionResult to ExecResult + * - `createStepExecutor`: Factory for a Step executor backed by ActionRegistry + */ + +import type { ExecCtx, ExecResult } from '../nodes/types'; +import type { Step } from '../types'; +import type { ActionRegistry } from './registry'; +import type { + ActionExecutionContext, + ActionExecutionResult, + ActionPolicy, + ExecutableAction, + ExecutableActionType, + ExecutionFlags, + VariableStore, +} from './types'; + +// ================================ +// Type Mapping +// ================================ + +/** + * Map legacy step types to new action types + * Most types map 1:1, but some require special handling + */ +const STEP_TYPE_TO_ACTION_TYPE: Record = { + // Interaction + click: 'click', + dblclick: 'dblclick', + fill: 'fill', + key: 'key', + scroll: 'scroll', + drag: 'drag', + + // Timing + wait: 'wait', + delay: 'delay', + + // Validation + assert: 'assert', + + // Data + extract: 'extract', + script: 'script', + http: 'http', + screenshot: 'screenshot', + + // Navigation / Tabs + navigate: 'navigate', + openTab: 'openTab', + switchTab: 'switchTab', + closeTab: 'closeTab', + handleDownload: 'handleDownload', + + // Control Flow + if: 'if', + foreach: 'foreach', + while: 'while', + switchFrame: 'switchFrame', + + // TODO: Add when handlers are implemented + // triggerEvent: 'triggerEvent', + // setAttribute: 'setAttribute', + // loopElements: 'loopElements', + // executeFlow: 'executeFlow', +}; + +// ================================ +// Context Conversion +// ================================ + +/** + * Convert legacy ExecCtx to ActionExecutionContext + */ +export function execCtxToActionCtx( + ctx: ExecCtx, + tabId: number, + options?: { + stepId?: string; + runId?: string; + pushLog?: (entry: unknown) => void; + /** Execution flags to pass to action handlers */ + execution?: ExecutionFlags; + }, +): ActionExecutionContext { + // Use provided stepId for proper log attribution, fallback to 'action' only if not provided + const logStepId = options?.stepId || 'action'; + return { + vars: ctx.vars as VariableStore, + tabId, + frameId: ctx.frameId, + runId: options?.runId, + log: (message: string, level?: 'info' | 'warn' | 'error') => { + ctx.logger({ + stepId: logStepId, + status: level === 'error' ? 'failed' : level === 'warn' ? 'warning' : 'success', + message, + }); + }, + pushLog: options?.pushLog, + execution: options?.execution, + }; +} + +// ================================ +// Step → Action Conversion +// ================================ + +/** + * Convert a legacy Step to an ExecutableAction + * + * The conversion maps step properties to action params and policy. + * Unknown step types are passed through as-is for forward compatibility. + */ +export function stepToAction(step: Step): ExecutableAction | null { + const actionType = STEP_TYPE_TO_ACTION_TYPE[step.type]; + + if (!actionType) { + // Unsupported step type + return null; + } + + // Build policy if step has timeout or retry config + let policy: ActionPolicy | undefined; + if (step.timeoutMs || step.retry) { + policy = {}; + + if (step.timeoutMs) { + policy.timeout = { ms: step.timeoutMs }; + } + + if (step.retry) { + policy.retry = { + retries: step.retry.count ?? 0, + intervalMs: step.retry.intervalMs ?? 0, + // Step backoff only supports 'none' | 'exp', map to Action backoff type + backoff: step.retry.backoff === 'exp' ? 'exp' : 'none', + }; + } + } + + // Build base action - use type assertion for generic action + // Note: Step doesn't have name/disabled at base level, they are on NodeBase + const action = { + id: step.id, + type: actionType, + params: extractParams(step), + policy, + } as ExecutableAction; + + return action; +} + +/** + * Legacy SelectorCandidate format: { type, value, weight? } + * Action SelectorCandidate format: { type, selector/xpath/text/etc, weight? } + */ +interface LegacySelectorCandidate { + type: string; + value: string; + weight?: number; +} + +interface LegacyTargetLocator { + ref?: string; + candidates: LegacySelectorCandidate[]; + // Additional fields from recorder + selector?: string; + tag?: string; +} + +/** + * Parse legacy ARIA value format + * Formats: + * - "role[name=...]" (e.g., "button[name=\"Submit\"]") + * - "aria-label=..." (role-less, just name) + */ +function parseAriaValue(value: string): { role?: string; name: string } { + // Try "role[name=...]" format + const roleMatch = value.match(/^([a-zA-Z]+)\[name=["']?(.+?)["']?\]$/); + if (roleMatch) { + return { role: roleMatch[1], name: roleMatch[2] }; + } + + // Try "aria-label=..." format + const labelMatch = value.match(/^aria-label=["']?(.+?)["']?$/); + if (labelMatch) { + return { name: labelMatch[1] }; + } + + // Fallback: treat entire value as name + return { name: value }; +} + +/** + * Convert legacy SelectorCandidate to Action SelectorCandidate + */ +function convertSelectorCandidate(legacy: LegacySelectorCandidate): Record { + const base: Record = { type: legacy.type }; + if (typeof legacy.weight === 'number') { + base.weight = legacy.weight; + } + + switch (legacy.type) { + case 'css': + case 'attr': + // CSS and attr use 'selector' field + base.selector = legacy.value; + break; + case 'xpath': + // XPath uses 'xpath' field + base.xpath = legacy.value; + break; + case 'text': + // Text uses 'text' field + base.text = legacy.value; + break; + case 'aria': { + // ARIA: parse "role[name=...]" or "aria-label=..." format + const parsed = parseAriaValue(legacy.value); + if (parsed.role) { + base.role = parsed.role; + } + base.name = parsed.name; + break; + } + default: + // Unknown type, pass through as-is + base.value = legacy.value; + } + + return base; +} + +/** + * Convert legacy TargetLocator to Action ElementTarget + * Preserves additional fields like selector and tag for locator optimization + */ +function convertTargetLocator(target: LegacyTargetLocator): Record { + const result: Record = {}; + + if (target.ref) { + result.ref = target.ref; + } + + // Preserve selector field for fast-path (e.g., #id selectors) + if (typeof target.selector === 'string' && target.selector.trim()) { + result.selector = target.selector; + } + + // Preserve tag hint for text/aria matching + if (typeof target.tag === 'string' && target.tag.trim()) { + result.hint = { tagName: target.tag }; + } + + if (Array.isArray(target.candidates) && target.candidates.length > 0) { + result.candidates = target.candidates.map(convertSelectorCandidate); + } + + return result; +} + +/** + * Check if a value looks like a legacy TargetLocator that needs conversion + * + * Detection criteria: + * 1. Must be an object with candidates array + * 2. Candidates must use legacy format (has 'value' field, not 'selector'/'xpath'/'text') + * + * This prevents double-conversion of already-converted Action format targets. + */ +function isLegacyTargetLocator(value: unknown): value is LegacyTargetLocator { + if (!value || typeof value !== 'object') return false; + const obj = value as Record; + + // Must have candidates array + if (!Array.isArray(obj.candidates)) { + // If only has ref without candidates, check if it's legacy format + return typeof obj.ref === 'string' && !obj.hint; + } + + // Check first candidate to determine format + const firstCandidate = obj.candidates[0]; + if (!firstCandidate || typeof firstCandidate !== 'object') { + return false; + } + + const candidate = firstCandidate as Record; + // Legacy format uses 'value' field + // Action format uses 'selector', 'xpath', 'text', etc. (NOT 'value') + const hasValueField = typeof candidate.value === 'string'; + const hasActionFields = + typeof candidate.selector === 'string' || + typeof candidate.xpath === 'string' || + typeof candidate.text === 'string' || + typeof candidate.name === 'string'; + + // It's legacy if it has 'value' field and doesn't have action-specific fields + return hasValueField && !hasActionFields; +} + +/** + * Extract action params from step + * Each step type has its own param structure + * + * This function also converts legacy data structures to Action-compatible formats: + * - TargetLocator.candidates: { type, value } -> { type, selector/xpath/text } + */ +function extractParams(step: Step): Record { + // The step already contains params inline, so we extract them + // excluding common fields that go into the action base + // Use unknown first to satisfy TypeScript's type narrowing + const stepObj = step as unknown as Record; + const { id, type, timeoutMs, retry, screenshotOnFail, ...params } = stepObj; + + // Convert TargetLocator fields if present + const converted: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (key === 'target' && isLegacyTargetLocator(value)) { + converted[key] = convertTargetLocator(value); + } else if (key === 'start' && isLegacyTargetLocator(value)) { + // For drag step + converted[key] = convertTargetLocator(value); + } else if (key === 'end' && isLegacyTargetLocator(value)) { + // For drag step + converted[key] = convertTargetLocator(value); + } else { + converted[key] = value; + } + } + + return converted; +} + +// ================================ +// Result Conversion +// ================================ + +/** + * Convert ActionExecutionResult to legacy ExecResult + */ +export function actionResultToExecResult(result: ActionExecutionResult): ExecResult { + const execResult: ExecResult = {}; + + // Map nextLabel for control flow + if (result.nextLabel) { + execResult.nextLabel = result.nextLabel; + } + + // Map control directives + if (result.control) { + execResult.control = result.control; + } + + // If action already handled logging, mark it + if (result.status === 'success') { + execResult.alreadyLogged = false; // Let StepRunner handle logging + } + + return execResult; +} + +// ================================ +// Executor Factory +// ================================ + +/** + * Result from attempting to execute a step via actions + */ +export type StepExecutionAttempt = + | { supported: true; result: ExecResult } + | { supported: false; reason: string }; + +/** + * Options for step executor + */ +export interface StepExecutorOptions { + runId?: string; + pushLog?: (entry: unknown) => void; + /** + * If true, throws on unsupported step types instead of returning { supported: false } + * Use this in strict mode where all steps must go through ActionRegistry + */ + strict?: boolean; + /** + * Skip ActionRegistry retry policy. + * When true, the action's retry policy is removed before execution. + * Use this when StepRunner already handles retry via withRetry(). + */ + skipRetry?: boolean; + /** + * Skip navigation waiting inside action handlers. + * When true, handlers like click/navigate skip their internal nav-wait logic. + * Use this when StepRunner already handles navigation waiting. + */ + skipNavWait?: boolean; +} + +/** + * Create a step executor that uses ActionRegistry + * + * This is the main integration point - it creates a function that can + * replace the legacy `executeStep` call in StepRunner. + * + * The executor returns a discriminated union indicating whether the step + * was supported by ActionRegistry. This allows hybrid mode to fall back + * to legacy execution gracefully. + */ +export function createStepExecutor(registry: ActionRegistry) { + return async function executeStepViaActions( + ctx: ExecCtx, + step: Step, + tabId: number, + options?: StepExecutorOptions, + ): Promise { + // Convert step to action + let action = stepToAction(step); + + if (!action) { + const reason = `Unsupported step type for ActionRegistry: ${step.type}`; + if (options?.strict) { + throw new Error(reason); + } + return { supported: false, reason }; + } + + // Skip retry policy if StepRunner handles it + // This avoids double retry: StepRunner.withRetry() + ActionRegistry.retry + if (options?.skipRetry === true && action.policy?.retry) { + action = { ...action, policy: { ...action.policy, retry: undefined } }; + } + + // Check if handler exists + const handler = registry.get(action.type); + if (!handler) { + const reason = `No handler registered for action type: ${action.type}`; + if (options?.strict) { + throw new Error(reason); + } + return { supported: false, reason }; + } + + // Build execution flags for handlers + const execution: ExecutionFlags | undefined = + options?.skipNavWait === true ? { skipNavWait: true } : undefined; + + // Convert context with proper stepId for log attribution + const actionCtx = execCtxToActionCtx(ctx, tabId, { + stepId: step.id, + runId: options?.runId, + pushLog: options?.pushLog, + execution, + }); + + // Execute via registry (includes retry, timeout, hooks) + const result = await registry.execute(actionCtx, action); + + // Handle failure - still return as supported, but throw the error + if (result.status === 'failed') { + const error = result.error; + throw new Error( + error?.message || `Action ${action.type} failed: ${error?.code || 'UNKNOWN'}`, + ); + } + + // Sync vars back (in case action modified them) + Object.assign(ctx.vars, actionCtx.vars); + + // Sync frameId back (in case switchFrame modified it) + if (actionCtx.frameId !== undefined) { + ctx.frameId = actionCtx.frameId; + } + + // Sync tabId back (in case openTab/switchTab changed it) + // Chrome tabId is always a positive safe integer + if (result.status === 'success') { + const nextTabId = result.newTabId; + if (typeof nextTabId === 'number' && Number.isSafeInteger(nextTabId) && nextTabId > 0) { + ctx.tabId = nextTabId; + } + } + + // Convert result + return { supported: true, result: actionResultToExecResult(result) }; + }; +} + +// ================================ +// Type Guards +// ================================ + +/** + * Check if a step type is supported by ActionRegistry + */ +export function isActionSupported(stepType: string): boolean { + return stepType in STEP_TYPE_TO_ACTION_TYPE; +} + +/** + * Get the action type for a step type + */ +export function getActionType(stepType: string): ExecutableActionType | undefined { + return STEP_TYPE_TO_ACTION_TYPE[stepType]; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts new file mode 100644 index 0000000..bff322e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts @@ -0,0 +1,356 @@ +/** + * Assert Action Handler + * + * Validates page state against specified conditions: + * - exists: Selector can be resolved to an element + * - visible: Element exists and has non-zero dimensions + * - textPresent: Text appears in the page content + * - attribute: Element attribute equals/matches/exists + */ + +import { failed, invalid, ok, tryResolveString } from '../registry'; +import type { ActionHandler, Assertion, VariableStore } from '../types'; + +/** Default timeout for polling assertions (ms) */ +const DEFAULT_ASSERT_TIMEOUT_MS = 5000; + +/** Polling interval for retry assertions (ms) */ +const POLL_INTERVAL_MS = 200; + +/** Maximum attribute name length */ +const MAX_ATTR_NAME_LENGTH = 256; + +/** + * Validates assertion configuration at build time + */ +function validateAssertion(assert: Assertion): { ok: true } | { ok: false; error: string } { + switch (assert.kind) { + case 'exists': + case 'visible': + if (assert.selector === undefined) { + return { ok: false, error: `Assertion "${assert.kind}" requires a selector` }; + } + break; + + case 'textPresent': + if (assert.text === undefined) { + return { ok: false, error: 'Assertion "textPresent" requires a text value' }; + } + break; + + case 'attribute': + if (assert.selector === undefined) { + return { ok: false, error: 'Assertion "attribute" requires a selector' }; + } + if (assert.name === undefined) { + return { ok: false, error: 'Assertion "attribute" requires an attribute name' }; + } + // Must have at least equals or matches (or neither for existence check) + break; + + default: { + const exhaustive: never = assert; + return { ok: false, error: `Unknown assertion kind: ${(exhaustive as Assertion).kind}` }; + } + } + + return { ok: true }; +} + +/** + * Resolve assertion parameters at runtime + */ +function resolveAssertionParams( + assert: Assertion, + vars: VariableStore, +): { ok: true; resolved: ResolvedAssertion } | { ok: false; error: string } { + switch (assert.kind) { + case 'exists': + case 'visible': { + const selectorResult = tryResolveString(assert.selector, vars); + if (!selectorResult.ok) return selectorResult; + const selector = selectorResult.value.trim(); + if (!selector) return { ok: false, error: `Empty selector for "${assert.kind}" assertion` }; + return { + ok: true, + resolved: { kind: assert.kind, selector }, + }; + } + + case 'textPresent': { + const textResult = tryResolveString(assert.text, vars); + if (!textResult.ok) return textResult; + const text = textResult.value; + if (!text) return { ok: false, error: 'Empty text for "textPresent" assertion' }; + return { + ok: true, + resolved: { kind: 'textPresent', text }, + }; + } + + case 'attribute': { + const selectorResult = tryResolveString(assert.selector, vars); + if (!selectorResult.ok) return selectorResult; + const selector = selectorResult.value.trim(); + if (!selector) return { ok: false, error: 'Empty selector for "attribute" assertion' }; + + const nameResult = tryResolveString(assert.name, vars); + if (!nameResult.ok) return nameResult; + const attrName = nameResult.value.trim(); + if (!attrName) return { ok: false, error: 'Empty attribute name' }; + if (attrName.length > MAX_ATTR_NAME_LENGTH) { + return { ok: false, error: `Attribute name exceeds ${MAX_ATTR_NAME_LENGTH} characters` }; + } + + let equals: string | undefined; + let matches: string | undefined; + + if (assert.equals !== undefined) { + const equalsResult = tryResolveString(assert.equals, vars); + if (!equalsResult.ok) return equalsResult; + equals = equalsResult.value; + } + + if (assert.matches !== undefined) { + const matchesResult = tryResolveString(assert.matches, vars); + if (!matchesResult.ok) return matchesResult; + matches = matchesResult.value; + // Validate regex + try { + new RegExp(matches); + } catch { + return { ok: false, error: `Invalid regex pattern: ${matches}` }; + } + } + + return { + ok: true, + resolved: { kind: 'attribute', selector, attrName, equals, matches }, + }; + } + } +} + +/** + * Resolved assertion with all variables interpolated + */ +type ResolvedAssertion = + | { kind: 'exists'; selector: string } + | { kind: 'visible'; selector: string } + | { kind: 'textPresent'; text: string } + | { kind: 'attribute'; selector: string; attrName: string; equals?: string; matches?: string }; + +/** + * Execute assertion check in page context + */ +async function checkAssertionInPage( + tabId: number, + frameId: number | undefined, + resolved: ResolvedAssertion, +): Promise<{ passed: boolean; message?: string }> { + const frameIds = typeof frameId === 'number' ? [frameId] : undefined; + + try { + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world: 'MAIN', + func: (assertion: ResolvedAssertion) => { + try { + switch (assertion.kind) { + case 'exists': { + const el = document.querySelector(assertion.selector); + return el ? { passed: true } : { passed: false, message: 'Element not found' }; + } + + case 'visible': { + const el = document.querySelector(assertion.selector); + if (!el) return { passed: false, message: 'Element not found' }; + const rect = el.getBoundingClientRect(); + const hasSize = rect.width > 0 && rect.height > 0; + if (!hasSize) return { passed: false, message: 'Element has zero dimensions' }; + + // Check if element is visible in viewport + const style = window.getComputedStyle(el); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' + ) { + return { passed: false, message: 'Element is hidden via CSS' }; + } + return { passed: true }; + } + + case 'textPresent': { + const text = assertion.text; + const bodyText = document.body?.textContent || ''; + if (bodyText.includes(text)) return { passed: true }; + return { passed: false, message: `Text "${text}" not found in page` }; + } + + case 'attribute': { + const el = document.querySelector(assertion.selector); + if (!el) return { passed: false, message: 'Element not found' }; + + const attrValue = el.getAttribute(assertion.attrName); + + // Check existence only + if (assertion.equals === undefined && assertion.matches === undefined) { + return attrValue !== null + ? { passed: true } + : { passed: false, message: `Attribute "${assertion.attrName}" not found` }; + } + + // Check equals + if (assertion.equals !== undefined) { + if (attrValue === assertion.equals) return { passed: true }; + return { + passed: false, + message: `Attribute "${assertion.attrName}" is "${attrValue}", expected "${assertion.equals}"`, + }; + } + + // Check matches (regex) + if (assertion.matches !== undefined) { + if (attrValue === null) { + return { passed: false, message: `Attribute "${assertion.attrName}" not found` }; + } + const regex = new RegExp(assertion.matches); + if (regex.test(attrValue)) return { passed: true }; + return { + passed: false, + message: `Attribute "${assertion.attrName}" value "${attrValue}" does not match pattern "${assertion.matches}"`, + }; + } + + return { passed: true }; + } + } + } catch (e) { + return { passed: false, message: e instanceof Error ? e.message : String(e) }; + } + }, + args: [resolved], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + if (!result || typeof result !== 'object') { + return { passed: false, message: 'Assertion script returned invalid result' }; + } + + return result as { passed: boolean; message?: string }; + } catch (e) { + return { + passed: false, + message: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +/** + * Poll assertion until it passes or timeout + */ +async function pollAssertion( + tabId: number, + frameId: number | undefined, + resolved: ResolvedAssertion, + timeoutMs: number, +): Promise<{ passed: boolean; message?: string }> { + const startTime = Date.now(); + let lastResult: { passed: boolean; message?: string } = { + passed: false, + message: 'Timeout before first check', + }; + + while (Date.now() - startTime < timeoutMs) { + lastResult = await checkAssertionInPage(tabId, frameId, resolved); + if (lastResult.passed) return lastResult; + + // Wait before next poll + const remaining = timeoutMs - (Date.now() - startTime); + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining))); + } + } + + return { + passed: false, + message: `${lastResult.message || 'Assertion failed'} (timeout: ${timeoutMs}ms)`, + }; +} + +export const assertHandler: ActionHandler<'assert'> = { + type: 'assert', + + validate: (action) => { + const validation = validateAssertion(action.params.assert); + if (!validation.ok) { + return invalid(validation.error); + } + return ok(); + }, + + describe: (action) => { + const assert = action.params.assert; + switch (assert.kind) { + case 'exists': + return `Assert exists: ${truncate(String(assert.selector), 30)}`; + case 'visible': + return `Assert visible: ${truncate(String(assert.selector), 30)}`; + case 'textPresent': + return `Assert text: "${truncate(String(assert.text), 25)}"`; + case 'attribute': + return `Assert attr: ${truncate(String(assert.name), 15)}`; + default: + return 'Assert'; + } + }, + + run: async (ctx, action) => { + const tabId = ctx.tabId; + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for assert action'); + } + + // Resolve assertion parameters + const resolved = resolveAssertionParams(action.params.assert, ctx.vars); + if (!resolved.ok) { + return failed('VALIDATION_ERROR', resolved.error); + } + + // Determine timeout from policy or default + const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_ASSERT_TIMEOUT_MS; + const failStrategy = action.params.failStrategy ?? 'stop'; + + // Execute assertion with polling + const result = await pollAssertion(tabId, ctx.frameId, resolved.resolved, timeoutMs); + + if (result.passed) { + return { status: 'success' }; + } + + // Handle failure based on strategy + const errorMessage = result.message || 'Assertion failed'; + + switch (failStrategy) { + case 'warn': + ctx.log(`Assertion warning: ${errorMessage}`, 'warn'); + return { status: 'success' }; + + case 'retry': + // Return failed with retryable error code + // The scheduler should handle retry based on policy + return failed('ASSERTION_FAILED', errorMessage); + + case 'stop': + default: + return failed('ASSERTION_FAILED', errorMessage); + } + }, +}; + +/** Truncate string for display */ +function truncate(str: string, maxLen: number): string { + if (typeof str !== 'string') return '(dynamic)'; + return str.length > maxLen ? str.slice(0, maxLen) + '...' : str; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts new file mode 100644 index 0000000..b4acbe2 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts @@ -0,0 +1,193 @@ +/** + * Click and Double-Click Action Handlers + * + * Handles click interactions: + * - Single click + * - Double click + * - Post-click navigation/network wait + * - Selector fallback with logging + */ + +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { ENGINE_CONSTANTS } from '../../engine/constants'; +import { + maybeQuickWaitForNav, + waitForNavigationDone, + waitForNetworkIdle, +} from '../../engine/policies/wait'; +import { failed, invalid, ok } from '../registry'; +import type { + Action, + ActionExecutionContext, + ActionExecutionResult, + ActionHandler, +} from '../types'; +import { + clampInt, + ensureElementVisible, + logSelectorFallback, + readTabUrl, + selectorLocator, + toSelectorTarget, +} from './common'; + +/** + * Shared click execution logic for both click and dblclick + */ +async function executeClick( + ctx: ActionExecutionContext, + action: Action, +): Promise> { + const vars = ctx.vars; + const tabId = ctx.tabId; + // Check if StepRunner owns nav-wait (skip internal nav-wait logic) + const skipNavWait = ctx.execution?.skipNavWait === true; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found'); + } + + // Ensure page is read before locating element + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + + // Only read beforeUrl if we need to do nav-wait + const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId); + const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget( + action.params.target, + vars, + ); + + // Locate element using shared selector locator + const located = await selectorLocator.locate(tabId, selectorTarget, { + frameId: ctx.frameId, + preferRef: false, + }); + + const frameId = located?.frameId ?? ctx.frameId; + const refToUse = located?.ref ?? selectorTarget.ref; + const selectorToUse = !located?.ref ? firstCssOrAttr : undefined; + + if (!refToUse && !selectorToUse) { + return failed('TARGET_NOT_FOUND', 'Could not locate target element'); + } + + // Verify element visibility if we have a ref + if (located?.ref) { + const isVisible = await ensureElementVisible(tabId, located.ref, frameId); + if (!isVisible) { + return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible'); + } + } + + // Execute click with tool timeout + const toolTimeout = clampInt(action.policy?.timeout?.ms ?? 10000, 1000, 30000); + + const clickResult = await handleCallTool({ + name: TOOL_NAMES.BROWSER.CLICK, + args: { + ref: refToUse, + selector: selectorToUse, + waitForNavigation: false, + timeout: toolTimeout, + frameId, + tabId, + double: action.type === 'dblclick', + }, + }); + + if ((clickResult as { isError?: boolean })?.isError) { + const errorContent = (clickResult as { content?: Array<{ text?: string }> })?.content; + const errorMsg = errorContent?.[0]?.text || `${action.type} action failed`; + return failed('UNKNOWN', errorMsg); + } + + // Log selector fallback if used + const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : ''); + const fallbackUsed = + resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType; + + if (fallbackUsed) { + logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy)); + } + + // Skip post-click wait if StepRunner handles it + if (skipNavWait) { + return { status: 'success' }; + } + + // Post-click wait handling (only when handler owns nav-wait) + const waitMs = clampInt( + action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, + 0, + ENGINE_CONSTANTS.MAX_WAIT_MS, + ); + const after = action.params.after ?? {}; + + if (after.waitForNavigation) { + await waitForNavigationDone(beforeUrl, waitMs); + } else if (after.waitForNetworkIdle) { + const totalMs = clampInt(waitMs, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS); + const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3))); + await waitForNetworkIdle(totalMs, idleMs); + } else { + // Quick sniff for navigation that might have been triggered + await maybeQuickWaitForNav(beforeUrl, waitMs); + } + + return { status: 'success' }; +} + +/** + * Validate click target configuration + */ +function validateClickTarget(target: { + ref?: string; + candidates?: unknown[]; +}): { ok: true } | { ok: false; errors: [string, ...string[]] } { + const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0; + const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0; + + if (hasRef || hasCandidates) { + return ok(); + } + return invalid('Missing target selector or ref'); +} + +export const clickHandler: ActionHandler<'click'> = { + type: 'click', + + validate: (action) => + validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }), + + describe: (action) => { + const target = action.params.target; + if (typeof (target as { ref?: string }).ref === 'string') { + return `Click element ${(target as { ref: string }).ref}`; + } + return 'Click element'; + }, + + run: async (ctx, action) => { + return await executeClick(ctx, action); + }, +}; + +export const dblclickHandler: ActionHandler<'dblclick'> = { + type: 'dblclick', + + validate: (action) => + validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }), + + describe: (action) => { + const target = action.params.target; + if (typeof (target as { ref?: string }).ref === 'string') { + return `Double-click element ${(target as { ref: string }).ref}`; + } + return 'Double-click element'; + }, + + run: async (ctx, action) => { + return await executeClick(ctx, action); + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts new file mode 100644 index 0000000..cc0e0a0 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts @@ -0,0 +1,332 @@ +/** + * Common utilities for Action handlers + * + * Shared helpers for: + * - Variable resolution and template interpolation + * - Selector target conversion + * - Element visibility verification + * - Logging utilities + */ + +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { + createChromeSelectorLocator, + type SelectorCandidate as SharedSelectorCandidate, + type SelectorCandidateSource, + type SelectorStability, + type SelectorTarget, +} from '@/shared/selector'; +import { tryResolveString } from '../registry'; +import type { ActionExecutionContext, ElementTarget, Resolvable, VariableStore } from '../types'; + +// ================================ +// Selector Locator Instance +// ================================ + +export const selectorLocator = createChromeSelectorLocator(); + +// ================================ +// String Resolution Utilities +// ================================ + +/** + * Interpolate {varName} placeholders in a string using variable store + */ +export function interpolateBraces(template: string, vars: VariableStore): string { + return String(template || '').replace(/\{([^}]+)\}/g, (_match, key) => { + const value = (vars as Record)[key]; + return value == null ? '' : String(value); + }); +} + +/** + * Resolve a Resolvable value with template interpolation + */ +export function resolveString( + value: Resolvable, + vars: VariableStore, +): { ok: true; value: string } | { ok: false; error: string } { + const resolved = tryResolveString(value, vars); + if (!resolved.ok) return resolved; + return { ok: true, value: interpolateBraces(resolved.value, vars) }; +} + +/** + * Resolve an optional Resolvable value + */ +export function resolveOptionalString( + value: Resolvable | undefined, + vars: VariableStore, +): string | undefined { + if (value === undefined) return undefined; + const resolved = resolveString(value, vars); + if (!resolved.ok) return undefined; + const out = resolved.value.trim(); + return out.length > 0 ? out : undefined; +} + +// ================================ +// Number Utilities +// ================================ + +/** + * Clamp a number to a range with integer conversion + */ +export function clampInt(value: number, min: number, max: number): number { + const n = Number(value); + if (!Number.isFinite(n)) return min; + return Math.min(max, Math.max(min, Math.floor(n))); +} + +// ================================ +// Selector Target Conversion +// ================================ + +export interface ConvertedSelectorTarget { + selectorTarget: SelectorTarget; + /** Type of the first candidate (for fallback logging) */ + firstCandidateType?: string; + /** First CSS or attr selector value (for tool fallback) */ + firstCssOrAttr?: string; +} + +/** + * Convert Action ElementTarget to shared SelectorTarget + * + * Handles: + * - Resolvable candidate values + * - Template interpolation + * - Weight assignment for locator priority + */ +export function toSelectorTarget( + target: ElementTarget, + vars: VariableStore, +): ConvertedSelectorTarget { + const srcCandidates = Array.isArray(target.candidates) ? target.candidates : []; + const firstCandidateType = + srcCandidates.length > 0 + ? String((srcCandidates[0] as { type?: string })?.type || '') || undefined + : undefined; + + // Find first CSS/attr selector for tool fallback + let firstCssOrAttr: string | undefined; + for (const c of srcCandidates) { + if (c.type !== 'css' && c.type !== 'attr') continue; + const resolved = resolveString(c.selector, vars); + if (resolved.ok && resolved.value.trim()) { + firstCssOrAttr = resolved.value; + break; + } + } + + // Extract selector from target if present + const primaryRaw = + typeof (target as { selector?: string }).selector === 'string' + ? String((target as { selector?: string }).selector).trim() + : ''; + const selectorInterpolated = primaryRaw ? interpolateBraces(primaryRaw, vars).trim() : ''; + const selector = selectorInterpolated || undefined; + + // Extract tagName hint + const tagName = + typeof (target as { tag?: string }).tag === 'string' + ? String((target as { tag?: string }).tag) + : typeof (target as { hint?: { tagName?: string } }).hint?.tagName === 'string' + ? String((target as { hint?: { tagName?: string } }).hint!.tagName) + : undefined; + + // Convert candidates with weight assignment + // Preserve user-defined weights while keeping text candidates as last resort + let nonTextIndex = 0; + let textIndex = 0; + const candidates: SharedSelectorCandidate[] = []; + + for (const c of srcCandidates) { + const idx = c.type === 'text' ? textIndex++ : nonTextIndex++; + // Respect user-defined weight if present, otherwise use position-based weight + const userWeight = + typeof (c as { weight?: number }).weight === 'number' && + Number.isFinite((c as { weight?: number }).weight) + ? (c as { weight: number }).weight + : 0; + // Non-text candidates get higher base weight + const weightBase = c.type === 'text' ? 0 : 1000; + const weight = weightBase + userWeight - idx; + + // Preserve source and stability metadata from original candidate + // Type-safely extract optional source and stability fields + const rawSource = (c as { source?: SelectorCandidateSource }).source; + const rawStability = (c as { stability?: SelectorStability }).stability; + const meta: Pick = { + weight, + ...(rawSource && { source: rawSource }), + ...(rawStability && { stability: rawStability }), + }; + + switch (c.type) { + case 'css': { + const resolved = resolveString(c.selector, vars); + if (!resolved.ok) continue; + candidates.push({ type: 'css', value: resolved.value, ...meta }); + break; + } + case 'attr': { + const resolved = resolveString(c.selector, vars); + if (!resolved.ok) continue; + candidates.push({ type: 'attr', value: resolved.value, ...meta }); + break; + } + case 'xpath': { + const resolved = resolveString(c.xpath, vars); + if (!resolved.ok) continue; + candidates.push({ type: 'xpath', value: resolved.value, ...meta }); + break; + } + case 'text': { + const resolved = resolveString(c.text, vars); + if (!resolved.ok) continue; + candidates.push({ + type: 'text', + value: resolved.value, + ...meta, + match: c.match, + tagNameHint: c.tagNameHint ?? tagName, + }); + break; + } + case 'aria': { + const role = resolveOptionalString(c.role, vars); + const name = resolveOptionalString(c.name, vars); + // Skip aria candidate if no name provided (would produce useless selector) + if (!name) break; + // Avoid injecting fake role; use aria-label format when role is not specified + const value = role + ? `${role}[name=${JSON.stringify(name)}]` + : `aria-label=${JSON.stringify(name)}`; + candidates.push({ type: 'aria', value, ...meta, role, name }); + break; + } + } + } + + // Ensure at least one candidate + const ensuredCandidates: [SharedSelectorCandidate, ...SharedSelectorCandidate[]] = + candidates.length > 0 + ? (candidates as [SharedSelectorCandidate, ...SharedSelectorCandidate[]]) + : [{ type: 'css', value: '' }]; + + return { + selectorTarget: { + selector, + candidates: ensuredCandidates, + tagName, + ref: + typeof (target as { ref?: string }).ref === 'string' + ? String((target as { ref?: string }).ref) + : undefined, + }, + firstCandidateType, + firstCssOrAttr, + }; +} + +// ================================ +// Chrome Message Utilities +// ================================ + +/** + * Result type for sendMessageToTab + */ +export type SendMessageResult = { ok: true; value: T } | { ok: false; error: string }; + +/** + * Send message to tab with optional frameId + * Returns structured result to avoid silent failures + */ +export async function sendMessageToTab( + tabId: number, + message: unknown, + frameId?: number, +): Promise> { + try { + let response: T; + if (typeof frameId === 'number') { + response = await chrome.tabs.sendMessage(tabId, message, { frameId }); + } else { + response = await chrome.tabs.sendMessage(tabId, message); + } + return { ok: true, value: response }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +// ================================ +// Element Verification +// ================================ + +/** + * Verify element is visible by checking its bounding rect + */ +export async function ensureElementVisible( + tabId: number, + ref: string, + frameId: number | undefined, +): Promise { + const result = await sendMessageToTab<{ rect?: { width: number; height: number } }>( + tabId, + { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref }, + frameId, + ); + if (!result.ok) return false; + const rect = result.value?.rect; + return !!rect && rect.width > 0 && rect.height > 0; +} + +/** + * Get current tab URL + */ +export async function readTabUrl(tabId: number): Promise { + try { + const tab = await chrome.tabs.get(tabId); + return tab?.url || ''; + } catch { + return ''; + } +} + +// ================================ +// Logging Utilities +// ================================ + +export interface FallbackLogEntry { + stepId: string; + status: 'success'; + message: string; + fallbackUsed: boolean; + fallbackFrom: string; + fallbackTo: string; +} + +/** + * Log selector fallback usage for debugging + */ +export function logSelectorFallback( + ctx: Pick, + actionId: string, + from: string, + to: string, +): void { + try { + ctx.pushLog?.({ + stepId: actionId, + status: 'success', + message: `Selector fallback used (${from} -> ${to})`, + fallbackUsed: true, + fallbackFrom: from, + fallbackTo: to, + }); + } catch { + // Ignore logging errors + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts new file mode 100644 index 0000000..696a3b3 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts @@ -0,0 +1,382 @@ +/** + * Control Flow Action Handlers + * + * Handles flow control operations: + * - if: Conditional branching + * - foreach: Loop over array + * - while: Loop with condition + * - switchFrame: Switch to a different frame + * + * Note: The actual loop iteration is handled by the Scheduler. + * These handlers return control directives that tell the Scheduler how to proceed. + */ + +import { + failed, + invalid, + ok, + tryResolveNumber, + tryResolveString, + tryResolveValue, +} from '../registry'; +import type { + ActionHandler, + Condition, + ControlDirective, + EdgeLabel, + VariableStore, +} from '../types'; + +/** Default max iterations for while loops */ +const DEFAULT_MAX_ITERATIONS = 1000; + +// ================================ +// Condition Evaluation +// ================================ + +/** + * Evaluate a condition against variables + */ +function evaluateCondition(condition: Condition, vars: VariableStore): boolean { + switch (condition.kind) { + case 'expr': { + // Expression evaluation not supported in default resolver + // Return false for safety + return false; + } + + case 'compare': { + const leftResult = tryResolveValue(condition.left, vars); + const rightResult = tryResolveValue(condition.right, vars); + + if (!leftResult.ok || !rightResult.ok) return false; + + const left = leftResult.value; + const right = rightResult.value; + + switch (condition.op) { + case 'eq': + return left === right; + case 'eqi': + return String(left).toLowerCase() === String(right).toLowerCase(); + case 'neq': + return left !== right; + case 'gt': + return Number(left) > Number(right); + case 'gte': + return Number(left) >= Number(right); + case 'lt': + return Number(left) < Number(right); + case 'lte': + return Number(left) <= Number(right); + case 'contains': + return String(left).includes(String(right)); + case 'containsI': + return String(left).toLowerCase().includes(String(right).toLowerCase()); + case 'notContains': + return !String(left).includes(String(right)); + case 'notContainsI': + return !String(left).toLowerCase().includes(String(right).toLowerCase()); + case 'startsWith': + return String(left).startsWith(String(right)); + case 'endsWith': + return String(left).endsWith(String(right)); + case 'regex': { + try { + const regex = new RegExp(String(right)); + return regex.test(String(left)); + } catch { + return false; + } + } + default: + return false; + } + } + + case 'truthy': { + const result = tryResolveValue(condition.value, vars); + if (!result.ok) return false; + return Boolean(result.value); + } + + case 'falsy': { + const result = tryResolveValue(condition.value, vars); + if (!result.ok) return true; + return !result.value; + } + + case 'not': + return !evaluateCondition(condition.condition, vars); + + case 'and': + return condition.conditions.every((c) => evaluateCondition(c, vars)); + + case 'or': + return condition.conditions.some((c) => evaluateCondition(c, vars)); + + default: + return false; + } +} + +// ================================ +// if Handler +// ================================ + +export const ifHandler: ActionHandler<'if'> = { + type: 'if', + + validate: (action) => { + const params = action.params; + + if (params.mode === 'binary') { + if (!params.condition) { + return invalid('Binary if requires a condition'); + } + } else if (params.mode === 'branches') { + if (!params.branches || params.branches.length === 0) { + return invalid('Branches if requires at least one branch'); + } + } else { + return invalid(`Unknown if mode: ${String((params as { mode: string }).mode)}`); + } + + return ok(); + }, + + describe: (action) => { + if (action.params.mode === 'binary') { + return 'If condition'; + } + const branchCount = action.params.mode === 'branches' ? action.params.branches.length : 0; + return `If (${branchCount} branches)`; + }, + + run: async (ctx, action) => { + const params = action.params; + + if (params.mode === 'binary') { + const result = evaluateCondition(params.condition, ctx.vars); + const label: EdgeLabel = result + ? (params.trueLabel ?? 'true') + : (params.falseLabel ?? 'false'); + return { status: 'success', nextLabel: label }; + } + + // Branches mode + if (params.mode === 'branches') { + for (const branch of params.branches) { + if (evaluateCondition(branch.condition, ctx.vars)) { + return { status: 'success', nextLabel: branch.label }; + } + } + // No branch matched, use else label + const elseLabel = params.elseLabel ?? 'default'; + return { status: 'success', nextLabel: elseLabel }; + } + + return failed('VALIDATION_ERROR', 'Invalid if mode'); + }, +}; + +// ================================ +// foreach Handler +// ================================ + +export const foreachHandler: ActionHandler<'foreach'> = { + type: 'foreach', + + validate: (action) => { + const params = action.params; + + if (!params.listVar) { + return invalid('foreach requires a listVar'); + } + + if (!params.subflowId) { + return invalid('foreach requires a subflowId'); + } + + return ok(); + }, + + describe: (action) => { + return `For each in ${action.params.listVar}`; + }, + + run: async (ctx, action) => { + const params = action.params; + + // Check if listVar exists and is an array + const list = ctx.vars[params.listVar]; + if (!Array.isArray(list)) { + return failed('VALIDATION_ERROR', `Variable "${params.listVar}" is not an array`); + } + + if (list.length === 0) { + // Empty list, nothing to iterate + return { status: 'success' }; + } + + // Return control directive for scheduler to handle + const directive: ControlDirective = { + kind: 'foreach', + listVar: params.listVar, + itemVar: params.itemVar || 'item', + subflowId: params.subflowId, + concurrency: params.concurrency, + }; + + return { status: 'success', control: directive }; + }, +}; + +// ================================ +// while Handler +// ================================ + +export const whileHandler: ActionHandler<'while'> = { + type: 'while', + + validate: (action) => { + const params = action.params; + + if (!params.condition) { + return invalid('while requires a condition'); + } + + if (!params.subflowId) { + return invalid('while requires a subflowId'); + } + + return ok(); + }, + + describe: () => { + return 'While loop'; + }, + + run: async (ctx, action) => { + const params = action.params; + + // Check if condition is currently true + const conditionResult = evaluateCondition(params.condition, ctx.vars); + + if (!conditionResult) { + // Condition is false, don't enter loop + return { status: 'success' }; + } + + // Return control directive for scheduler to handle + const directive: ControlDirective = { + kind: 'while', + condition: params.condition, + subflowId: params.subflowId, + maxIterations: params.maxIterations ?? DEFAULT_MAX_ITERATIONS, + }; + + return { status: 'success', control: directive }; + }, +}; + +// ================================ +// switchFrame Handler +// ================================ + +export const switchFrameHandler: ActionHandler<'switchFrame'> = { + type: 'switchFrame', + + validate: (action) => { + const target = action.params.target; + + if (!target) { + return invalid('switchFrame requires a target'); + } + + if (target.kind !== 'top' && target.kind !== 'index' && target.kind !== 'urlContains') { + return invalid(`Unknown frame target kind: ${String((target as { kind: string }).kind)}`); + } + + return ok(); + }, + + describe: (action) => { + const target = action.params.target; + if (target.kind === 'top') return 'Switch to top frame'; + if (target.kind === 'index') return `Switch to frame #${target.index}`; + if (target.kind === 'urlContains') return 'Switch frame (by URL)'; + return 'Switch frame'; + }, + + run: async (ctx, action) => { + const target = action.params.target; + const tabId = ctx.tabId; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found'); + } + + try { + if (target.kind === 'top') { + // Reset to main frame (frameId = 0) + ctx.frameId = 0; + return { status: 'success' }; + } + + // Get all frames in the tab + const frames = await chrome.webNavigation.getAllFrames({ tabId }); + if (!frames || frames.length === 0) { + return failed('FRAME_NOT_FOUND', 'No frames found in tab'); + } + + let targetFrame: chrome.webNavigation.GetAllFrameResultDetails | undefined; + + if (target.kind === 'index') { + const indexResult = tryResolveNumber(target.index, ctx.vars); + if (!indexResult.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve frame index: ${indexResult.error}`); + } + const index = Math.floor(indexResult.value); + + // Find frame by index (excluding main frame which is 0) + const childFrames = frames.filter((f) => f.frameId !== 0); + if (index < 0 || index >= childFrames.length) { + return failed( + 'FRAME_NOT_FOUND', + `Frame index ${index} out of bounds (${childFrames.length} frames)`, + ); + } + targetFrame = childFrames[index]; + } else if (target.kind === 'urlContains') { + const urlResult = tryResolveString(target.value, ctx.vars); + if (!urlResult.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve URL pattern: ${urlResult.error}`); + } + const urlPattern = urlResult.value.trim().toLowerCase(); + + // Empty pattern is invalid + if (!urlPattern) { + return failed('VALIDATION_ERROR', 'URL pattern cannot be empty'); + } + + targetFrame = frames.find((f) => f.url && f.url.toLowerCase().includes(urlPattern)); + } + + if (!targetFrame) { + return failed('FRAME_NOT_FOUND', 'No matching frame found'); + } + + // The frameId will be used by subsequent actions + // Store it in context (this is typically handled by scheduler) + ctx.frameId = targetFrame.frameId; + + return { status: 'success' }; + } catch (e) { + return failed( + 'FRAME_NOT_FOUND', + `Failed to switch frame: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts new file mode 100644 index 0000000..4495fd1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts @@ -0,0 +1,43 @@ +/** + * Delay Action Handler + * + * Provides a simple pause in execution flow. + * Supports variable resolution for dynamic delay times. + */ + +import { failed, invalid, ok, tryResolveNumber } from '../registry'; +import type { ActionHandler } from '../types'; + +/** Maximum delay time to prevent integer overflow in setTimeout */ +const MAX_DELAY_MS = 2_147_483_647; + +export const delayHandler: ActionHandler<'delay'> = { + type: 'delay', + + validate: (action) => { + if (action.params.sleep === undefined) { + return invalid('Missing sleep parameter'); + } + return ok(); + }, + + describe: (action) => { + const ms = typeof action.params.sleep === 'number' ? action.params.sleep : '(dynamic)'; + return `Delay ${ms}ms`; + }, + + run: async (ctx, action) => { + const resolved = tryResolveNumber(action.params.sleep, ctx.vars); + if (!resolved.ok) { + return failed('VALIDATION_ERROR', resolved.error); + } + + const ms = Math.max(0, Math.min(MAX_DELAY_MS, Math.floor(resolved.value))); + + if (ms > 0) { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts new file mode 100644 index 0000000..3080d95 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts @@ -0,0 +1,427 @@ +/** + * DOM Tools Action Handlers + * + * Handles DOM manipulation actions: + * - triggerEvent: Dispatch a custom DOM Event on an element + * - setAttribute: Set or remove an attribute on an element + * + * Design notes: + * - Both handlers follow the same pattern as click.ts + * - Element location uses selectorLocator from shared code + * - CSS selector resolution supports ref fallback + */ + +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { failed, invalid, ok, tryResolveJson } from '../registry'; +import type { + ActionExecutionResult, + ActionHandler, + ElementTarget, + JsonValue, + VariableStore, +} from '../types'; +import { + interpolateBraces, + logSelectorFallback, + resolveString, + selectorLocator, + sendMessageToTab, + toSelectorTarget, +} from './common'; + +// ================================ +// Type Definitions +// ================================ + +interface ResolveRefResponse { + success?: boolean; + selector?: string; + error?: string; +} + +interface DomScriptResult { + success: boolean; + error?: string; +} + +interface ResolvedTarget { + selector: string; + frameId: number | undefined; + firstCandidateType?: string; + resolvedBy?: string; +} + +// ================================ +// Shared Utilities +// ================================ + +/** + * Check if target has valid ref or candidates + * Accepts unknown to safely handle malformed input in validate() + */ +function hasValidTarget(target: unknown): boolean { + if (typeof target !== 'object' || target === null) return false; + const t = target as { ref?: unknown; candidates?: unknown }; + const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0; + const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0; + return hasRef || hasCandidates; +} + +/** + * Strip frame prefix from composite selector (e.g., "frame|>selector" -> "selector") + */ +function stripCompositePrefix(selector: string): string { + const raw = String(selector || '').trim(); + if (!raw.includes('|>')) return raw; + + const parts = raw + .split('|>') + .map((p) => p.trim()) + .filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : raw; +} + +/** + * Resolve ElementTarget to a CSS selector string + * + * Resolution order: + * 1. Try to locate element using selectorLocator + * 2. If ref found, resolve it to CSS selector via content script + * 3. Fall back to first CSS/attr candidate if no ref + */ +async function resolveTargetSelector( + tabId: number, + target: ElementTarget, + vars: VariableStore, + contextFrameId: number | undefined, +): Promise<{ ok: true; value: ResolvedTarget } | { ok: false; error: string }> { + const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars); + + // Locate element using shared selector locator + const located = await selectorLocator.locate(tabId, selectorTarget, { + frameId: contextFrameId, + preferRef: false, + }); + + const frameId = located?.frameId ?? contextFrameId; + const refToUse = located?.ref ?? selectorTarget.ref; + + // Must have either ref or CSS/attr candidate + if (!refToUse && !firstCssOrAttr) { + return { ok: false, error: 'Could not locate target element' }; + } + + let selector: string | undefined; + + // Try to resolve ref to CSS selector + if (refToUse) { + const resolved = await sendMessageToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse }, + frameId, + ); + + if (resolved.ok && resolved.value?.success !== false && resolved.value?.selector) { + const sel = resolved.value.selector.trim(); + if (sel) selector = sel; + } + } + + // Fall back to CSS/attr candidate + if (!selector && firstCssOrAttr) { + const stripped = stripCompositePrefix(firstCssOrAttr); + if (stripped) selector = stripped; + } + + if (!selector) { + return { ok: false, error: 'Could not resolve a CSS selector for the target element' }; + } + + return { + ok: true, + value: { + selector, + frameId, + firstCandidateType, + // Only mark as 'ref' if locator actually resolved via ref + resolvedBy: located?.resolvedBy || (located?.ref ? 'ref' : undefined), + }, + }; +} + +/** + * Log selector fallback if a different selector type was used + */ +function maybeLogFallback( + ctx: Parameters[0], + actionId: string, + resolved: ResolvedTarget, +): void { + const { resolvedBy, firstCandidateType } = resolved; + + const fallbackUsed = + resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType; + + if (fallbackUsed) { + logSelectorFallback(ctx, actionId, String(firstCandidateType), String(resolvedBy)); + } +} + +// ================================ +// triggerEvent Handler +// ================================ + +export const triggerEventHandler: ActionHandler<'triggerEvent'> = { + type: 'triggerEvent', + + validate: (action) => { + if (!hasValidTarget(action.params.target)) { + return invalid('triggerEvent requires a target ref or selector candidates'); + } + + const event = action.params.event; + if (event === undefined || event === null) { + return invalid('Missing event parameter'); + } + if (typeof event === 'string' && event.trim().length === 0) { + return invalid('event must be a non-empty string'); + } + + return ok(); + }, + + describe: (action) => { + const ev = typeof action.params.event === 'string' ? action.params.event : '(dynamic)'; + const display = ev.length > 30 ? ev.slice(0, 30) + '...' : ev; + return `Trigger event "${display}"`; + }, + + run: async (ctx, action): Promise> => { + const { tabId, vars, frameId } = ctx; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for triggerEvent action'); + } + + // Resolve event type + const eventResolved = resolveString(action.params.event, vars); + if (!eventResolved.ok) { + return failed('VALIDATION_ERROR', eventResolved.error); + } + + const eventType = eventResolved.value.trim(); + if (!eventType) { + return failed('VALIDATION_ERROR', 'Event type is empty'); + } + + // Event options + const bubbles = action.params.bubbles !== false; + const cancelable = action.params.cancelable === true; + + // Ensure page is read for element location + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } }); + + // Resolve target selector + const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId); + if (!targetResolved.ok) { + return failed('TARGET_NOT_FOUND', targetResolved.error); + } + + const { selector, frameId: resolvedFrameId } = targetResolved.value; + const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined; + + // Execute event dispatch in page context + try { + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world: 'MAIN', + func: ( + sel: string, + type: string, + bubbles: boolean, + cancelable: boolean, + ): DomScriptResult => { + try { + const el = document.querySelector(sel); + if (!el) { + // Use special error code to distinguish from script execution errors + return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` }; + } + + const event = new Event(type, { bubbles, cancelable }); + el.dispatchEvent(event); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + args: [selector, eventType, bubbles, cancelable], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + if (!result || typeof result !== 'object') { + return failed('SCRIPT_FAILED', 'triggerEvent script returned invalid result'); + } + + const typed = result as DomScriptResult; + if (!typed.success) { + // Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...") + const errorMsg = typed.error || `Failed to dispatch "${eventType}"`; + const code = errorMsg.startsWith('[TARGET_NOT_FOUND]') + ? 'TARGET_NOT_FOUND' + : 'SCRIPT_FAILED'; + return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, '')); + } + } catch (e) { + return failed( + 'SCRIPT_FAILED', + `Failed to trigger event "${eventType}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + + maybeLogFallback(ctx, action.id, targetResolved.value); + + return { status: 'success' }; + }, +}; + +// ================================ +// setAttribute Handler +// ================================ + +export const setAttributeHandler: ActionHandler<'setAttribute'> = { + type: 'setAttribute', + + validate: (action) => { + if (!hasValidTarget(action.params.target)) { + return invalid('setAttribute requires a target ref or selector candidates'); + } + + const name = action.params.name; + if (name === undefined || name === null) { + return invalid('Missing name parameter'); + } + if (typeof name === 'string' && name.trim().length === 0) { + return invalid('name must be a non-empty string'); + } + + return ok(); + }, + + describe: (action) => { + const name = typeof action.params.name === 'string' ? action.params.name : '(dynamic)'; + const display = name.length > 30 ? name.slice(0, 30) + '...' : name; + return action.params.remove ? `Remove attribute "${display}"` : `Set attribute "${display}"`; + }, + + run: async (ctx, action): Promise> => { + const { tabId, vars, frameId } = ctx; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for setAttribute action'); + } + + // Resolve attribute name + const nameResolved = resolveString(action.params.name, vars); + if (!nameResolved.ok) { + return failed('VALIDATION_ERROR', nameResolved.error); + } + + const attrName = nameResolved.value.trim(); + if (!attrName) { + return failed('VALIDATION_ERROR', 'Attribute name is empty'); + } + + const remove = action.params.remove === true; + + // Resolve attribute value (only if not removing) + let attrValue: JsonValue = null; + if (!remove && action.params.value !== undefined) { + const valueResolved = tryResolveJson(action.params.value, vars); + if (!valueResolved.ok) { + return failed('VALIDATION_ERROR', valueResolved.error); + } + + // Apply template interpolation for string values + attrValue = + typeof valueResolved.value === 'string' + ? interpolateBraces(valueResolved.value, vars) + : valueResolved.value; + } + + // Ensure page is read for element location + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } }); + + // Resolve target selector + const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId); + if (!targetResolved.ok) { + return failed('TARGET_NOT_FOUND', targetResolved.error); + } + + const { selector, frameId: resolvedFrameId } = targetResolved.value; + const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined; + + // Execute attribute modification in page context + try { + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world: 'MAIN', + func: (sel: string, name: string, value: JsonValue, remove: boolean): DomScriptResult => { + try { + const el = document.querySelector(sel); + if (!el) { + // Use special error code to distinguish from script execution errors + return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` }; + } + + if (remove) { + el.removeAttribute(name); + } else { + // Convert value to string for setAttribute + const strValue = + value === null || value === undefined + ? '' + : typeof value === 'string' + ? value + : String(value); + el.setAttribute(name, strValue); + } + + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + args: [selector, attrName, attrValue, remove], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + if (!result || typeof result !== 'object') { + return failed('SCRIPT_FAILED', 'setAttribute script returned invalid result'); + } + + const typed = result as DomScriptResult; + if (!typed.success) { + const actionDesc = remove ? 'remove' : 'set'; + // Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...") + const errorMsg = typed.error || `Failed to ${actionDesc} attribute "${attrName}"`; + const code = errorMsg.startsWith('[TARGET_NOT_FOUND]') + ? 'TARGET_NOT_FOUND' + : 'SCRIPT_FAILED'; + return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, '')); + } + } catch (e) { + const actionDesc = remove ? 'remove' : 'set'; + return failed( + 'SCRIPT_FAILED', + `Failed to ${actionDesc} attribute "${attrName}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + + maybeLogFallback(ctx, action.id, targetResolved.value); + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts new file mode 100644 index 0000000..798cf47 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts @@ -0,0 +1,249 @@ +/** + * Drag Action Handler + * + * Performs a left-click drag from a start target to an end target. + * + * Features: + * - Locates start/end via shared SelectorLocator (ref + candidates) + * - Executes via chrome_computer with action="left_click_drag" (CDP-based) + * - Uses optional `path` endpoints as a fallback for coordinates + * - Validates element visibility before drag + */ + +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { failed, invalid, ok } from '../registry'; +import type { ActionHandler, ElementTarget, Point, VariableStore } from '../types'; +import { + ensureElementVisible, + logSelectorFallback, + selectorLocator, + toSelectorTarget, +} from './common'; + +interface Coordinates { + x: number; + y: number; +} + +/** Check if target has valid selector specification */ +function hasTargetSpec(target: unknown): boolean { + if (!target || typeof target !== 'object') return false; + const t = target as { ref?: unknown; candidates?: unknown }; + const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0; + const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0; + return hasRef || hasCandidates; +} + +/** Check if value is a finite number */ +function isFiniteNumber(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +/** Extract start/end coordinates from path array */ +function getPathEndpoints( + path: ReadonlyArray | undefined, +): { startCoordinates: Coordinates; endCoordinates: Coordinates } | null { + if (!Array.isArray(path) || path.length < 2) return null; + + const first = path[0]; + const last = path[path.length - 1]; + + if (!first || !last) return null; + if (!isFiniteNumber(first.x) || !isFiniteNumber(first.y)) return null; + if (!isFiniteNumber(last.x) || !isFiniteNumber(last.y)) return null; + + return { + startCoordinates: { x: first.x, y: first.y }, + endCoordinates: { x: last.x, y: last.y }, + }; +} + +/** Extract error text from tool result */ +function extractToolError(result: unknown, fallback: string): string { + const content = (result as { content?: Array<{ text?: string }> })?.content; + return content?.find((c) => typeof c?.text === 'string')?.text || fallback; +} + +/** Locate target and verify visibility */ +async function locateTarget( + tabId: number, + frameId: number | undefined, + target: ElementTarget | undefined, + vars: VariableStore, + role: 'start' | 'end', +): Promise< + | { ok: true; ref?: string; firstCandidateType?: string; resolvedBy?: string } + | { ok: false; error: string; code: 'TARGET_NOT_FOUND' | 'ELEMENT_NOT_VISIBLE' } +> { + if (!target || !hasTargetSpec(target)) { + return { ok: true }; + } + + const { selectorTarget, firstCandidateType } = toSelectorTarget(target, vars); + + const located = await selectorLocator.locate(tabId, selectorTarget, { + frameId, + preferRef: false, + }); + + const locatedFrameId = located?.frameId ?? frameId; + const ref = located?.ref ?? selectorTarget.ref; + const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : ''); + + // Verify visibility for freshly located refs + if (located?.ref) { + const visible = await ensureElementVisible(tabId, located.ref, locatedFrameId); + if (!visible) { + return { + ok: false, + error: `Drag ${role} element is not visible`, + code: 'ELEMENT_NOT_VISIBLE', + }; + } + } + + return { ok: true, ref, firstCandidateType, resolvedBy }; +} + +export const dragHandler: ActionHandler<'drag'> = { + type: 'drag', + + validate: (action) => { + const pathEndpoints = getPathEndpoints(action.params.path); + + // If path is present, it must be well-formed + if (action.params.path !== undefined && action.params.path.length > 0 && !pathEndpoints) { + return invalid('path must contain at least two points with finite x/y coordinates'); + } + + const hasStart = hasTargetSpec(action.params.start); + const hasEnd = hasTargetSpec(action.params.end); + const hasPath = !!pathEndpoints; + + // Must have either target spec or path coordinates + if (!hasStart && !hasPath) { + return invalid('Drag start must include a non-empty ref or selector candidates'); + } + if (!hasEnd && !hasPath) { + return invalid('Drag end must include a non-empty ref or selector candidates'); + } + + return ok(); + }, + + describe: (action) => { + const startRef = (action.params.start as { ref?: unknown })?.ref; + const endRef = (action.params.end as { ref?: unknown })?.ref; + + const s = typeof startRef === 'string' && startRef.trim() ? startRef.trim() : ''; + const e = typeof endRef === 'string' && endRef.trim() ? endRef.trim() : ''; + + if (s && e) { + const truncS = s.length > 15 ? s.slice(0, 15) + '...' : s; + const truncE = e.length > 15 ? e.slice(0, 15) + '...' : e; + return `Drag ${truncS} → ${truncE}`; + } + if (s) return `Drag from ${s.length > 20 ? s.slice(0, 20) + '...' : s}`; + if (e) return `Drag to ${e.length > 20 ? e.slice(0, 20) + '...' : e}`; + + const pathEndpoints = getPathEndpoints(action.params.path); + if (pathEndpoints) { + const { startCoordinates, endCoordinates } = pathEndpoints; + return `Drag (${startCoordinates.x},${startCoordinates.y}) → (${endCoordinates.x},${endCoordinates.y})`; + } + + return 'Drag'; + }, + + run: async (ctx, action) => { + const tabId = ctx.tabId; + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for drag action'); + } + + // Ensure element refs are fresh before locating + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } }); + + // Get path coordinates as fallback + const pathEndpoints = getPathEndpoints(action.params.path); + const startCoordinates = pathEndpoints?.startCoordinates; + const endCoordinates = pathEndpoints?.endCoordinates; + + // Locate start target + const startResult = await locateTarget( + tabId, + ctx.frameId, + action.params.start, + ctx.vars, + 'start', + ); + if (!startResult.ok) { + return failed(startResult.code, startResult.error); + } + + // Locate end target + const endResult = await locateTarget(tabId, ctx.frameId, action.params.end, ctx.vars, 'end'); + if (!endResult.ok) { + return failed(endResult.code, endResult.error); + } + + // Validate we have at least one way to identify start and end + if (!startResult.ref && !startCoordinates) { + return failed('TARGET_NOT_FOUND', 'Could not resolve drag start (ref or path coordinates)'); + } + if (!endResult.ref && !endCoordinates) { + return failed('TARGET_NOT_FOUND', 'Could not resolve drag end (ref or path coordinates)'); + } + + // Execute drag via chrome_computer tool + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.COMPUTER, + args: { + action: 'left_click_drag', + tabId, + startRef: startResult.ref, + ref: endResult.ref, + startCoordinates, + coordinates: endCoordinates, + }, + }); + + if ((res as { isError?: boolean })?.isError) { + return failed('UNKNOWN', extractToolError(res, 'Drag action failed')); + } + + // Log selector fallback after successful execution + const startFallbackUsed = + startResult.resolvedBy && + startResult.firstCandidateType && + startResult.resolvedBy !== 'ref' && + startResult.resolvedBy !== startResult.firstCandidateType; + + if (startFallbackUsed) { + logSelectorFallback( + ctx, + action.id, + `start:${String(startResult.firstCandidateType)}`, + `start:${String(startResult.resolvedBy)}`, + ); + } + + const endFallbackUsed = + endResult.resolvedBy && + endResult.firstCandidateType && + endResult.resolvedBy !== 'ref' && + endResult.resolvedBy !== endResult.firstCandidateType; + + if (endFallbackUsed) { + logSelectorFallback( + ctx, + action.id, + `end:${String(endResult.firstCandidateType)}`, + `end:${String(endResult.resolvedBy)}`, + ); + } + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts new file mode 100644 index 0000000..519e325 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts @@ -0,0 +1,271 @@ +/** + * Extract Action Handler + * + * Extracts data from the page and stores in variables: + * - selector mode: Extract text/attribute from elements + * - js mode: Execute JavaScript and capture return value + */ + +import { failed, invalid, ok, tryResolveString } from '../registry'; +import type { ActionHandler, BrowserWorld, JsonValue, VariableStore } from '../types'; + +/** Default attribute to extract */ +const DEFAULT_EXTRACT_ATTR = 'textContent'; + +/** + * Execute extraction script in page context + */ +async function executeExtraction( + tabId: number, + frameId: number | undefined, + mode: 'selector' | 'js', + params: { + selector?: string; + attr?: string; + code?: string; + world?: BrowserWorld; + }, +): Promise<{ ok: true; value: JsonValue } | { ok: false; error: string }> { + const frameIds = typeof frameId === 'number' ? [frameId] : undefined; + const world = params.world === 'ISOLATED' ? 'ISOLATED' : 'MAIN'; + + try { + if (mode === 'selector') { + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world, + func: (selector: string, attr: string) => { + const el = document.querySelector(selector); + if (!el) { + return { success: false, error: `Element not found: ${selector}` }; + } + + let value: JsonValue; + + // Handle special attribute names + if (attr === 'text' || attr === 'textContent') { + value = el.textContent?.trim() ?? ''; + } else if (attr === 'innerText') { + value = (el as HTMLElement).innerText?.trim() ?? ''; + } else if (attr === 'innerHTML') { + value = el.innerHTML; + } else if (attr === 'outerHTML') { + value = el.outerHTML; + } else if (attr === 'value') { + // For form elements + value = (el as HTMLInputElement).value ?? ''; + } else if (attr === 'checked') { + value = (el as HTMLInputElement).checked ?? false; + } else if (attr === 'href') { + value = (el as HTMLAnchorElement).href ?? el.getAttribute('href') ?? ''; + } else if (attr === 'src') { + value = (el as HTMLImageElement).src ?? el.getAttribute('src') ?? ''; + } else { + // Generic attribute + const attrValue = el.getAttribute(attr); + value = attrValue ?? ''; + } + + return { success: true, value }; + }, + args: [params.selector!, params.attr!], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + if (!result || typeof result !== 'object') { + return { ok: false, error: 'Extraction script returned invalid result' }; + } + + if (!result.success) { + return { ok: false, error: result.error || 'Extraction failed' }; + } + + return { ok: true, value: result.value as JsonValue }; + } + + // JS mode + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world, + func: (code: string) => { + try { + // Create function and execute + const fn = new Function(code); + const result = fn(); + + // Handle promises + if (result instanceof Promise) { + return result.then( + (value: unknown) => ({ success: true, value }), + (error: Error) => ({ success: false, error: error?.message || String(error) }), + ); + } + + return { success: true, value: result }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + args: [params.code!], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + + // Handle async result + if (result instanceof Promise) { + const asyncResult = await result; + if (!asyncResult || typeof asyncResult !== 'object') { + return { ok: false, error: 'Async extraction returned invalid result' }; + } + if (!asyncResult.success) { + return { ok: false, error: asyncResult.error || 'Extraction failed' }; + } + return { ok: true, value: asyncResult.value as JsonValue }; + } + + if (!result || typeof result !== 'object') { + return { ok: false, error: 'Extraction script returned invalid result' }; + } + + const typedResult = result as { success: boolean; value?: unknown; error?: string }; + if (!typedResult.success) { + return { ok: false, error: typedResult.error || 'Extraction failed' }; + } + + return { ok: true, value: typedResult.value as JsonValue }; + } catch (e) { + return { + ok: false, + error: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +/** + * Resolve extraction parameters + */ +function resolveExtractParams( + params: unknown, + vars: VariableStore, +): { ok: true; mode: 'selector' | 'js'; resolved: ResolvedParams } | { ok: false; error: string } { + const p = params as { + mode: 'selector' | 'js'; + selector?: unknown; + attr?: unknown; + code?: string; + world?: BrowserWorld; + saveAs: string; + }; + + if (p.mode === 'selector') { + const selectorResult = tryResolveString(p.selector as string, vars); + if (!selectorResult.ok) return selectorResult; + const selector = selectorResult.value.trim(); + if (!selector) return { ok: false, error: 'Empty selector' }; + + let attr = DEFAULT_EXTRACT_ATTR; + if (p.attr !== undefined && p.attr !== null) { + const attrResult = tryResolveString(p.attr as string, vars); + if (!attrResult.ok) return attrResult; + attr = attrResult.value.trim() || DEFAULT_EXTRACT_ATTR; + } + + return { + ok: true, + mode: 'selector', + resolved: { selector, attr, saveAs: p.saveAs }, + }; + } + + if (p.mode === 'js') { + if (!p.code || typeof p.code !== 'string') { + return { ok: false, error: 'JS mode requires code string' }; + } + return { + ok: true, + mode: 'js', + resolved: { code: p.code, world: p.world, saveAs: p.saveAs }, + }; + } + + return { ok: false, error: `Unknown extract mode: ${String(p.mode)}` }; +} + +type ResolvedParams = + | { selector: string; attr: string; saveAs: string } + | { code: string; world?: BrowserWorld; saveAs: string }; + +export const extractHandler: ActionHandler<'extract'> = { + type: 'extract', + + validate: (action) => { + const params = action.params as { + mode: string; + selector?: unknown; + code?: string; + saveAs?: string; + }; + + if (params.mode !== 'selector' && params.mode !== 'js') { + return invalid(`Invalid extract mode: ${String(params.mode)}`); + } + + if (!params.saveAs || typeof params.saveAs !== 'string' || params.saveAs.trim().length === 0) { + return invalid('Extract action requires a non-empty saveAs variable name'); + } + + if (params.mode === 'selector' && params.selector === undefined) { + return invalid('Selector mode requires a selector'); + } + + if (params.mode === 'js' && (!params.code || typeof params.code !== 'string')) { + return invalid('JS mode requires a code string'); + } + + return ok(); + }, + + describe: (action) => { + const params = action.params as { mode: string; saveAs?: string }; + const varName = params.saveAs || '?'; + return params.mode === 'js' ? `Extract JS → ${varName}` : `Extract → ${varName}`; + }, + + run: async (ctx, action) => { + const tabId = ctx.tabId; + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for extract action'); + } + + const resolved = resolveExtractParams(action.params, ctx.vars); + if (!resolved.ok) { + return failed('VALIDATION_ERROR', resolved.error); + } + + const extractParams = + resolved.mode === 'selector' + ? { + selector: (resolved.resolved as { selector: string }).selector, + attr: (resolved.resolved as { attr: string }).attr, + } + : { + code: (resolved.resolved as { code: string }).code, + world: (resolved.resolved as { world?: BrowserWorld }).world, + }; + + const result = await executeExtraction(tabId, ctx.frameId, resolved.mode, extractParams); + + if (!result.ok) { + return failed('SCRIPT_FAILED', result.error); + } + + // Store in variables + const saveAs = (resolved.resolved as { saveAs: string }).saveAs; + ctx.vars[saveAs] = result.value; + + return { + status: 'success', + output: { value: result.value }, + }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts new file mode 100644 index 0000000..ca0bdb1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts @@ -0,0 +1,190 @@ +/** + * Fill Action Handler + * + * Handles form input actions: + * - Text input + * - File upload + * - Auto-scroll and focus + * - Selector fallback with logging + */ + +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { failed, invalid, ok } from '../registry'; +import type { ActionHandler } from '../types'; +import { + ensureElementVisible, + logSelectorFallback, + resolveString, + selectorLocator, + sendMessageToTab, + toSelectorTarget, +} from './common'; + +export const fillHandler: ActionHandler<'fill'> = { + type: 'fill', + + validate: (action) => { + const target = action.params.target as { ref?: string; candidates?: unknown[] }; + const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0; + const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0; + const hasValue = action.params.value !== undefined; + + if (!hasValue) { + return invalid('Missing value parameter'); + } + if (!hasRef && !hasCandidates) { + return invalid('Missing target selector or ref'); + } + return ok(); + }, + + describe: (action) => { + const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)'; + const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value; + return `Fill "${displayValue}"`; + }, + + run: async (ctx, action) => { + const vars = ctx.vars; + const tabId = ctx.tabId; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found'); + } + + // Ensure page is read before locating element + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + + // Resolve fill value + const valueResolved = resolveString(action.params.value, vars); + if (!valueResolved.ok) { + return failed('VALIDATION_ERROR', valueResolved.error); + } + const value = valueResolved.value; + + // Locate target element + const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget( + action.params.target, + vars, + ); + + const located = await selectorLocator.locate(tabId, selectorTarget, { + frameId: ctx.frameId, + preferRef: false, + }); + + const frameId = located?.frameId ?? ctx.frameId; + const refToUse = located?.ref ?? selectorTarget.ref; + const cssSelector = !located?.ref ? firstCssOrAttr : undefined; + + if (!refToUse && !cssSelector) { + return failed('TARGET_NOT_FOUND', 'Could not locate target element'); + } + + // Verify element visibility if we have a ref + if (located?.ref) { + const isVisible = await ensureElementVisible(tabId, located.ref, frameId); + if (!isVisible) { + return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible'); + } + } + + // Check for file input and handle file upload + // Use firstCssOrAttr to check input type even when ref is available + const selectorForTypeCheck = firstCssOrAttr || cssSelector; + if (selectorForTypeCheck) { + const attrResult = await sendMessageToTab<{ value?: string }>( + tabId, + { action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' }, + frameId, + ); + const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase(); + + if (inputType === 'file') { + const uploadResult = await handleCallTool({ + name: TOOL_NAMES.BROWSER.FILE_UPLOAD, + args: { selector: selectorForTypeCheck, filePath: value, tabId }, + }); + + if ((uploadResult as { isError?: boolean })?.isError) { + const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content; + const errorMsg = errorContent?.[0]?.text || 'File upload failed'; + return failed('UNKNOWN', errorMsg); + } + + // Log fallback if used + const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : ''); + const fallbackUsed = + resolvedBy && + firstCandidateType && + resolvedBy !== 'ref' && + resolvedBy !== firstCandidateType; + if (fallbackUsed) { + logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy)); + } + + return { status: 'success' }; + } + } + + // Scroll element into view (best-effort) + if (cssSelector) { + try { + await handleCallTool({ + name: TOOL_NAMES.BROWSER.INJECT_SCRIPT, + args: { + type: 'MAIN', + jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`, + tabId, + }, + }); + } catch { + // Ignore scroll errors + } + } + + // Focus element (best-effort, ignore errors) + if (located?.ref) { + await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId); + } else if (cssSelector) { + await handleCallTool({ + name: TOOL_NAMES.BROWSER.INJECT_SCRIPT, + args: { + type: 'MAIN', + jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`, + tabId, + }, + }); + } + + // Execute fill + const fillResult = await handleCallTool({ + name: TOOL_NAMES.BROWSER.FILL, + args: { + ref: refToUse, + selector: cssSelector, + value, + frameId, + tabId, + }, + }); + + if ((fillResult as { isError?: boolean })?.isError) { + const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content; + const errorMsg = errorContent?.[0]?.text || 'Fill action failed'; + return failed('UNKNOWN', errorMsg); + } + + // Log fallback if used + const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : ''); + const fallbackUsed = + resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType; + + if (fallbackUsed) { + logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy)); + } + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts new file mode 100644 index 0000000..a4256af --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts @@ -0,0 +1,361 @@ +/** + * HTTP Action Handler + * + * Makes HTTP requests from the extension context. + * Supports: + * - All common HTTP methods (GET, POST, PUT, PATCH, DELETE) + * - JSON and text body types + * - Form data + * - Custom headers + * - Response validation + * - Result capture to variables + */ + +import { failed, invalid, ok, tryResolveString, tryResolveValue } from '../registry'; +import type { + ActionHandler, + Assignments, + HttpBody, + HttpHeaders, + HttpFormData, + HttpMethod, + HttpOkStatus, + HttpResponse, + JsonValue, + Resolvable, + VariableStore, +} from '../types'; + +/** Default timeout for HTTP requests */ +const DEFAULT_HTTP_TIMEOUT_MS = 30000; + +/** Maximum URL length */ +const MAX_URL_LENGTH = 8192; + +/** + * Resolve HTTP headers + */ +async function resolveHeaders( + headers: HttpHeaders | undefined, + vars: VariableStore, +): Promise<{ ok: true; resolved: Record } | { ok: false; error: string }> { + if (!headers) return { ok: true, resolved: {} }; + + const resolved: Record = {}; + for (const [key, resolvable] of Object.entries(headers)) { + const result = tryResolveString(resolvable, vars); + if (!result.ok) { + return { ok: false, error: `Failed to resolve header "${key}": ${result.error}` }; + } + resolved[key] = result.value; + } + + return { ok: true, resolved }; +} + +/** + * Resolve form data + */ +async function resolveFormData( + formData: HttpFormData | undefined, + vars: VariableStore, +): Promise<{ ok: true; resolved: Record } | { ok: false; error: string }> { + if (!formData) return { ok: true, resolved: {} }; + + const resolved: Record = {}; + for (const [key, resolvable] of Object.entries(formData)) { + const result = tryResolveString(resolvable, vars); + if (!result.ok) { + return { ok: false, error: `Failed to resolve form field "${key}": ${result.error}` }; + } + resolved[key] = result.value; + } + + return { ok: true, resolved }; +} + +/** + * Resolve HTTP body + */ +async function resolveBody( + body: HttpBody | undefined, + vars: VariableStore, +): Promise< + | { ok: true; contentType: string | undefined; data: string | undefined } + | { ok: false; error: string } +> { + if (!body || body.kind === 'none') { + return { ok: true, contentType: undefined, data: undefined }; + } + + if (body.kind === 'text') { + const textResult = tryResolveString(body.text, vars); + if (!textResult.ok) { + return { ok: false, error: `Failed to resolve body text: ${textResult.error}` }; + } + + let contentType = 'text/plain'; + if (body.contentType) { + const ctResult = tryResolveString(body.contentType, vars); + if (!ctResult.ok) { + return { ok: false, error: `Failed to resolve content type: ${ctResult.error}` }; + } + contentType = ctResult.value; + } + + return { ok: true, contentType, data: textResult.value }; + } + + if (body.kind === 'json') { + const jsonResult = tryResolveValue(body.json, vars); + if (!jsonResult.ok) { + return { ok: false, error: `Failed to resolve JSON body: ${jsonResult.error}` }; + } + + return { + ok: true, + contentType: 'application/json', + data: JSON.stringify(jsonResult.value), + }; + } + + return { ok: false, error: `Unknown body kind: ${(body as { kind: string }).kind}` }; +} + +/** + * Check if status code is considered successful + */ +function isStatusOk(status: number, okStatus: HttpOkStatus | undefined): boolean { + if (!okStatus) { + // Default: 2xx is OK + return status >= 200 && status < 300; + } + + if (okStatus.kind === 'range') { + return status >= okStatus.min && status <= okStatus.max; + } + + if (okStatus.kind === 'list') { + return okStatus.statuses.includes(status); + } + + return false; +} + +/** + * Get value from result using dot/bracket path notation + */ +function getValueByPath(obj: unknown, path: string): JsonValue | undefined { + if (!path || typeof obj !== 'object' || obj === null) { + return obj as JsonValue; + } + + const segments: Array = []; + const pathRegex = /([^.[\]]+)|\[(\d+)\]/g; + let match: RegExpExecArray | null; + + while ((match = pathRegex.exec(path)) !== null) { + if (match[1]) { + segments.push(match[1]); + } else if (match[2]) { + segments.push(parseInt(match[2], 10)); + } + } + + let current: unknown = obj; + for (const segment of segments) { + if (current === null || current === undefined) return undefined; + if (typeof current !== 'object') return undefined; + current = (current as Record)[segment]; + } + + return current as JsonValue; +} + +/** + * Apply assignments from response to variables + */ +function applyAssignments( + response: HttpResponse, + assignments: Assignments, + vars: VariableStore, +): void { + for (const [varName, path] of Object.entries(assignments)) { + const value = getValueByPath(response, path); + if (value !== undefined) { + vars[varName] = value; + } + } +} + +export const httpHandler: ActionHandler<'http'> = { + type: 'http', + + validate: (action) => { + const params = action.params; + + if (params.url === undefined) { + return invalid('HTTP action requires a URL'); + } + + if (params.method !== undefined) { + const validMethods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + if (!validMethods.includes(params.method)) { + return invalid(`Invalid HTTP method: ${String(params.method)}`); + } + } + + return ok(); + }, + + describe: (action) => { + const method = action.params.method || 'GET'; + const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)'; + const displayUrl = url.length > 40 ? url.slice(0, 40) + '...' : url; + return `${method} ${displayUrl}`; + }, + + run: async (ctx, action) => { + const params = action.params; + const method: HttpMethod = params.method || 'GET'; + + // Resolve URL + const urlResult = tryResolveString(params.url, ctx.vars); + if (!urlResult.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`); + } + + const url = urlResult.value.trim(); + if (!url) { + return failed('VALIDATION_ERROR', 'URL is empty'); + } + + if (url.length > MAX_URL_LENGTH) { + return failed('VALIDATION_ERROR', `URL exceeds maximum length of ${MAX_URL_LENGTH}`); + } + + // Validate URL format + try { + new URL(url); + } catch { + return failed('VALIDATION_ERROR', `Invalid URL format: ${url}`); + } + + // Resolve headers + const headersResult = await resolveHeaders(params.headers, ctx.vars); + if (!headersResult.ok) { + return failed('VALIDATION_ERROR', headersResult.error); + } + + // Resolve body + const bodyResult = await resolveBody(params.body, ctx.vars); + if (!bodyResult.ok) { + return failed('VALIDATION_ERROR', bodyResult.error); + } + + // Resolve form data (alternative to body) + const formDataResult = await resolveFormData(params.formData, ctx.vars); + if (!formDataResult.ok) { + return failed('VALIDATION_ERROR', formDataResult.error); + } + + // Build request + const headers: Record = { ...headersResult.resolved }; + let requestBody: string | FormData | undefined; + + if (Object.keys(formDataResult.resolved).length > 0) { + // Use form data + const formData = new FormData(); + for (const [key, value] of Object.entries(formDataResult.resolved)) { + formData.append(key, value); + } + requestBody = formData as unknown as string; // FormData handled by fetch + } else if (bodyResult.data !== undefined) { + // Use body + requestBody = bodyResult.data; + if (bodyResult.contentType && !headers['Content-Type']) { + headers['Content-Type'] = bodyResult.contentType; + } + } + + // Execute request + const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_HTTP_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const fetchOptions: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (requestBody !== undefined && method !== 'GET' && method !== 'DELETE') { + fetchOptions.body = requestBody; + } + + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + // Parse response + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + let responseBody: JsonValue | string | null = null; + const contentType = response.headers.get('content-type') || ''; + + try { + if (contentType.includes('application/json')) { + responseBody = (await response.json()) as JsonValue; + } else { + responseBody = await response.text(); + } + } catch { + responseBody = null; + } + + const httpResponse: HttpResponse = { + url: response.url, + status: response.status, + headers: responseHeaders, + body: responseBody, + }; + + // Check status + if (!isStatusOk(response.status, params.okStatus)) { + return failed( + 'NETWORK_REQUEST_FAILED', + `HTTP ${response.status}: ${response.statusText || 'Request failed'}`, + ); + } + + // Store response if saveAs specified + if (params.saveAs) { + ctx.vars[params.saveAs] = httpResponse as unknown as JsonValue; + } + + // Apply assignments + if (params.assign) { + applyAssignments(httpResponse, params.assign, ctx.vars); + } + + return { + status: 'success', + output: { response: httpResponse }, + }; + } catch (e) { + clearTimeout(timeoutId); + + if (e instanceof Error && e.name === 'AbortError') { + return failed('TIMEOUT', `HTTP request timed out after ${timeoutMs}ms`); + } + + return failed( + 'NETWORK_REQUEST_FAILED', + `HTTP request failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts new file mode 100644 index 0000000..e14feaa --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts @@ -0,0 +1,164 @@ +/** + * Action Handlers Registry + * + * Central registration point for all action handlers. + * Provides factory function to create a fully-configured ActionRegistry + * with all replay handlers registered. + */ + +import { ActionRegistry, createActionRegistry } from '../registry'; +import { assertHandler } from './assert'; +import { clickHandler, dblclickHandler } from './click'; +import { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow'; +import { delayHandler } from './delay'; +import { setAttributeHandler, triggerEventHandler } from './dom'; +import { dragHandler } from './drag'; +import { extractHandler } from './extract'; +import { fillHandler } from './fill'; +import { httpHandler } from './http'; +import { keyHandler } from './key'; +import { navigateHandler } from './navigate'; +import { screenshotHandler } from './screenshot'; +import { scriptHandler } from './script'; +import { scrollHandler } from './scroll'; +import { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs'; +import { waitHandler } from './wait'; + +// Re-export individual handlers for direct access +export { assertHandler } from './assert'; +export { clickHandler, dblclickHandler } from './click'; +export { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow'; +export { delayHandler } from './delay'; +export { setAttributeHandler, triggerEventHandler } from './dom'; +export { dragHandler } from './drag'; +export { extractHandler } from './extract'; +export { fillHandler } from './fill'; +export { httpHandler } from './http'; +export { keyHandler } from './key'; +export { navigateHandler } from './navigate'; +export { screenshotHandler } from './screenshot'; +export { scriptHandler } from './script'; +export { scrollHandler } from './scroll'; +export { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs'; +export { waitHandler } from './wait'; + +// Re-export common utilities +export * from './common'; + +/** + * All available action handlers for replay + * + * Organized by category: + * - Navigation: navigate + * - Interaction: click, dblclick, fill, key, scroll, drag + * - Timing: wait, delay + * - Validation: assert + * - Data: extract, script, http, screenshot + * - DOM Tools: triggerEvent, setAttribute + * - Tabs: openTab, switchTab, closeTab, handleDownload + * - Control Flow: if, foreach, while, switchFrame + * + * TODO: Add remaining handlers: + * - loopElements, executeFlow (advanced control flow) + */ +const ALL_HANDLERS = [ + // Navigation + navigateHandler, + // Interaction + clickHandler, + dblclickHandler, + fillHandler, + keyHandler, + scrollHandler, + dragHandler, + // Timing + waitHandler, + delayHandler, + // Validation + assertHandler, + // Data + extractHandler, + scriptHandler, + httpHandler, + screenshotHandler, + // DOM Tools + triggerEventHandler, + setAttributeHandler, + // Tabs + openTabHandler, + switchTabHandler, + closeTabHandler, + handleDownloadHandler, + // Control Flow + ifHandler, + foreachHandler, + whileHandler, + switchFrameHandler, +] as const; + +/** + * Register all replay handlers to an ActionRegistry instance + */ +export function registerReplayHandlers(registry: ActionRegistry): void { + // Register each handler individually to satisfy TypeScript's type checker + registry.register(navigateHandler, { override: true }); + registry.register(clickHandler, { override: true }); + registry.register(dblclickHandler, { override: true }); + registry.register(fillHandler, { override: true }); + registry.register(keyHandler, { override: true }); + registry.register(scrollHandler, { override: true }); + registry.register(dragHandler, { override: true }); + registry.register(waitHandler, { override: true }); + registry.register(delayHandler, { override: true }); + registry.register(assertHandler, { override: true }); + registry.register(extractHandler, { override: true }); + registry.register(scriptHandler, { override: true }); + registry.register(httpHandler, { override: true }); + registry.register(screenshotHandler, { override: true }); + registry.register(triggerEventHandler, { override: true }); + registry.register(setAttributeHandler, { override: true }); + registry.register(openTabHandler, { override: true }); + registry.register(switchTabHandler, { override: true }); + registry.register(closeTabHandler, { override: true }); + registry.register(handleDownloadHandler, { override: true }); + registry.register(ifHandler, { override: true }); + registry.register(foreachHandler, { override: true }); + registry.register(whileHandler, { override: true }); + registry.register(switchFrameHandler, { override: true }); +} + +/** + * Create a new ActionRegistry with all replay handlers registered + * + * This is the primary entry point for creating an action execution context. + * + * @example + * ```ts + * const registry = createReplayActionRegistry(); + * + * const result = await registry.execute(ctx, { + * id: 'action-1', + * type: 'click', + * params: { target: { candidates: [...] } }, + * }); + * ``` + */ +export function createReplayActionRegistry(): ActionRegistry { + const registry = createActionRegistry(); + registerReplayHandlers(registry); + return registry; +} + +/** + * Get list of supported action types + */ +export function getSupportedActionTypes(): ReadonlyArray { + return ALL_HANDLERS.map((h) => h.type); +} + +/** + * Check if an action type is supported + */ +export function isActionTypeSupported(type: string): boolean { + return ALL_HANDLERS.some((h) => h.type === type); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts new file mode 100644 index 0000000..7f446d7 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts @@ -0,0 +1,196 @@ +/** + * Key Action Handler + * + * Handles keyboard input: + * - Resolves key sequences via variables/templates + * - Optionally focuses a target element before sending keys + * - Dispatches keyboard events via the keyboard tool + */ + +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { failed, invalid, ok } from '../registry'; +import type { ActionHandler, ElementTarget } from '../types'; +import { + ensureElementVisible, + logSelectorFallback, + resolveString, + selectorLocator, + sendMessageToTab, + toSelectorTarget, +} from './common'; + +/** Extract error text from tool result */ +function extractToolError(result: unknown, fallback: string): string { + const content = (result as { content?: Array<{ text?: string }> })?.content; + return content?.find((c) => typeof c?.text === 'string')?.text || fallback; +} + +/** Check if target has valid selector specification */ +function hasTargetSpec(target: unknown): boolean { + if (!target || typeof target !== 'object') return false; + const t = target as { ref?: unknown; candidates?: unknown }; + const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0; + const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0; + return hasRef || hasCandidates; +} + +/** Strip frame prefix from composite selector */ +function stripCompositeSelector(selector: string): string { + const raw = String(selector || '').trim(); + if (!raw || !raw.includes('|>')) return raw; + const parts = raw + .split('|>') + .map((p) => p.trim()) + .filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : raw; +} + +export const keyHandler: ActionHandler<'key'> = { + type: 'key', + + validate: (action) => { + if (action.params.keys === undefined) { + return invalid('Missing keys parameter'); + } + + if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) { + return invalid('Target must include a non-empty ref or selector candidates'); + } + + return ok(); + }, + + describe: (action) => { + const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)'; + const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys; + return `Keys "${display}"`; + }, + + run: async (ctx, action) => { + const vars = ctx.vars; + const tabId = ctx.tabId; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for key action'); + } + + // Resolve keys string + const keysResolved = resolveString(action.params.keys, vars); + if (!keysResolved.ok) { + return failed('VALIDATION_ERROR', keysResolved.error); + } + + const keys = keysResolved.value.trim(); + if (!keys) { + return failed('VALIDATION_ERROR', 'Keys string is empty'); + } + + let frameId = ctx.frameId; + let selectorForTool: string | undefined; + let firstCandidateType: string | undefined; + let resolvedBy: string | undefined; + + // Handle optional target focusing + const target = action.params.target as ElementTarget | undefined; + if (target) { + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } }); + + const { + selectorTarget, + firstCandidateType: firstType, + firstCssOrAttr, + } = toSelectorTarget(target, vars); + firstCandidateType = firstType; + + const located = await selectorLocator.locate(tabId, selectorTarget, { + frameId: ctx.frameId, + preferRef: false, + }); + + frameId = located?.frameId ?? ctx.frameId; + const refToUse = located?.ref ?? selectorTarget.ref; + + if (!refToUse && !firstCssOrAttr) { + return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action'); + } + + resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : ''); + + // Only verify visibility for freshly located refs (not stale refs from payload) + if (located?.ref) { + const visible = await ensureElementVisible(tabId, located.ref, frameId); + if (!visible) { + return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible'); + } + + const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>( + tabId, + { action: 'focusByRef', ref: located.ref }, + frameId, + ); + + if (!focusResult.ok || focusResult.value?.success !== true) { + const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error; + + if (!firstCssOrAttr) { + return failed( + 'TARGET_NOT_FOUND', + `Failed to focus target element: ${focusErr || 'ref may be stale'}`, + ); + } + + ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn'); + } + + // Try to resolve ref to CSS selector for tool + const resolved = await sendMessageToTab<{ + success?: boolean; + selector?: string; + error?: string; + }>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId); + + if ( + resolved.ok && + resolved.value?.success !== false && + typeof resolved.value?.selector === 'string' + ) { + const sel = resolved.value.selector.trim(); + if (sel) selectorForTool = sel; + } + } + + // Fallback to CSS/attr selector + if (!selectorForTool && firstCssOrAttr) { + const stripped = stripCompositeSelector(firstCssOrAttr); + if (stripped) selectorForTool = stripped; + } + } + + // Execute keyboard input + const keyboardResult = await handleCallTool({ + name: TOOL_NAMES.BROWSER.KEYBOARD, + args: { + keys, + selector: selectorForTool, + selectorType: selectorForTool ? 'css' : undefined, + tabId, + frameId, + }, + }); + + if ((keyboardResult as { isError?: boolean })?.isError) { + return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed')); + } + + // Log fallback after successful execution + const fallbackUsed = + resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType; + if (fallbackUsed) { + logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy)); + } + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts new file mode 100644 index 0000000..0a2c974 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts @@ -0,0 +1,104 @@ +/** + * Navigate Action Handler + * + * Handles page navigation actions: + * - Navigate to URL + * - Page refresh + * - Wait for navigation completion + */ + +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { ENGINE_CONSTANTS } from '../../engine/constants'; +import { ensureReadPageIfWeb, waitForNavigationDone } from '../../engine/policies/wait'; +import { failed, invalid, ok } from '../registry'; +import type { ActionHandler } from '../types'; +import { clampInt, readTabUrl, resolveString } from './common'; + +export const navigateHandler: ActionHandler<'navigate'> = { + type: 'navigate', + + validate: (action) => { + const hasRefresh = action.params.refresh === true; + const hasUrl = action.params.url !== undefined; + return hasRefresh || hasUrl ? ok() : invalid('Missing url or refresh parameter'); + }, + + describe: (action) => { + if (action.params.refresh) return 'Refresh page'; + const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)'; + return `Navigate to ${url}`; + }, + + run: async (ctx, action) => { + const vars = ctx.vars; + const tabId = ctx.tabId; + // Check if StepRunner owns nav-wait (skip internal nav-wait logic) + const skipNavWait = ctx.execution?.skipNavWait === true; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found'); + } + + // Only read beforeUrl and calculate waitMs if we need to do nav-wait + const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId); + const waitMs = skipNavWait + ? 0 + : clampInt( + action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, + 0, + ENGINE_CONSTANTS.MAX_WAIT_MS, + ); + + // Handle page refresh + if (action.params.refresh) { + const result = await handleCallTool({ + name: TOOL_NAMES.BROWSER.NAVIGATE, + args: { refresh: true, tabId }, + }); + + if ((result as { isError?: boolean })?.isError) { + const errorContent = (result as { content?: Array<{ text?: string }> })?.content; + const errorMsg = errorContent?.[0]?.text || 'Page refresh failed'; + return failed('NAVIGATION_FAILED', errorMsg); + } + + // Skip nav-wait if StepRunner handles it + if (!skipNavWait) { + await waitForNavigationDone(beforeUrl, waitMs); + await ensureReadPageIfWeb(); + } + return { status: 'success' }; + } + + // Handle URL navigation + const urlResolved = resolveString(action.params.url, vars); + if (!urlResolved.ok) { + return failed('VALIDATION_ERROR', urlResolved.error); + } + + const url = urlResolved.value.trim(); + if (!url) { + return failed('VALIDATION_ERROR', 'URL is empty'); + } + + const result = await handleCallTool({ + name: TOOL_NAMES.BROWSER.NAVIGATE, + args: { url, tabId }, + }); + + if ((result as { isError?: boolean })?.isError) { + const errorContent = (result as { content?: Array<{ text?: string }> })?.content; + const errorMsg = errorContent?.[0]?.text || `Navigation to ${url} failed`; + return failed('NAVIGATION_FAILED', errorMsg); + } + + // Skip nav-wait if StepRunner handles it + if (!skipNavWait) { + await waitForNavigationDone(beforeUrl, waitMs); + await ensureReadPageIfWeb(); + } + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts new file mode 100644 index 0000000..af2b4b9 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts @@ -0,0 +1,101 @@ +/** + * Screenshot Action Handler + * + * Captures screenshots and optionally stores base64 data in variables. + * Supports full page, selector-based, and viewport screenshots. + */ + +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { failed, invalid, ok } from '../registry'; +import type { ActionHandler } from '../types'; +import { resolveString } from './common'; + +/** Extract text content from tool result */ +function extractToolText(result: unknown): string | undefined { + const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content; + const text = content?.find((c) => c?.type === 'text' && typeof c.text === 'string')?.text; + return typeof text === 'string' && text.trim() ? text : undefined; +} + +export const screenshotHandler: ActionHandler<'screenshot'> = { + type: 'screenshot', + + validate: (action) => { + const saveAs = action.params.saveAs; + if (saveAs !== undefined && (!saveAs || String(saveAs).trim().length === 0)) { + return invalid('saveAs must be a non-empty variable name when provided'); + } + return ok(); + }, + + describe: (action) => { + if (action.params.fullPage) return 'Screenshot (full page)'; + if (typeof action.params.selector === 'string') { + const sel = + action.params.selector.length > 30 + ? action.params.selector.slice(0, 30) + '...' + : action.params.selector; + return `Screenshot: ${sel}`; + } + if (action.params.selector) return 'Screenshot (dynamic selector)'; + return 'Screenshot'; + }, + + run: async (ctx, action) => { + const tabId = ctx.tabId; + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for screenshot action'); + } + + // Resolve optional selector + let selector: string | undefined; + if (action.params.selector !== undefined) { + const resolved = resolveString(action.params.selector, ctx.vars); + if (!resolved.ok) return failed('VALIDATION_ERROR', resolved.error); + const s = resolved.value.trim(); + if (s) selector = s; + } + + // Call screenshot tool + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.SCREENSHOT, + args: { + name: 'workflow', + storeBase64: true, + fullPage: action.params.fullPage === true, + selector, + tabId, + }, + }); + + if ((res as { isError?: boolean })?.isError) { + return failed('UNKNOWN', extractToolText(res) || 'Screenshot failed'); + } + + // Parse response + const text = extractToolText(res); + if (!text) { + return failed('UNKNOWN', 'Screenshot tool returned an empty response'); + } + + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return failed('UNKNOWN', 'Screenshot tool returned invalid JSON'); + } + + const base64Data = (payload as { base64Data?: unknown })?.base64Data; + if (typeof base64Data !== 'string' || base64Data.length === 0) { + return failed('UNKNOWN', 'Screenshot tool returned empty base64Data'); + } + + // Store in variables if saveAs specified + if (action.params.saveAs) { + ctx.vars[action.params.saveAs] = base64Data; + } + + return { status: 'success', output: { base64Data } }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts new file mode 100644 index 0000000..6908e5d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts @@ -0,0 +1,237 @@ +/** + * Script Action Handler + * + * Executes custom JavaScript in the page context. + * Supports: + * - MAIN or ISOLATED world execution + * - Argument passing with variable resolution + * - Result capture to variables + * - Assignment mapping from result paths + */ + +import { failed, invalid, ok, tryResolveValue } from '../registry'; +import type { + ActionHandler, + Assignments, + BrowserWorld, + JsonValue, + Resolvable, + VariableStore, +} from '../types'; + +/** Maximum code length to prevent abuse */ +const MAX_CODE_LENGTH = 100000; + +/** + * Resolve script arguments + */ +function resolveArgs( + args: Record> | undefined, + vars: VariableStore, +): { ok: true; resolved: Record } | { ok: false; error: string } { + if (!args) return { ok: true, resolved: {} }; + + const resolved: Record = {}; + for (const [key, resolvable] of Object.entries(args)) { + const result = tryResolveValue(resolvable, vars); + if (!result.ok) { + return { ok: false, error: `Failed to resolve arg "${key}": ${result.error}` }; + } + resolved[key] = result.value; + } + + return { ok: true, resolved }; +} + +/** + * Get value from result using dot/bracket path notation + */ +function getValueByPath(obj: unknown, path: string): JsonValue | undefined { + if (!path || typeof obj !== 'object' || obj === null) { + return obj as JsonValue; + } + + // Parse path: supports "data.items[0].name" style + const segments: Array = []; + const pathRegex = /([^.[\]]+)|\[(\d+)\]/g; + let match: RegExpExecArray | null; + + while ((match = pathRegex.exec(path)) !== null) { + if (match[1]) { + segments.push(match[1]); + } else if (match[2]) { + segments.push(parseInt(match[2], 10)); + } + } + + let current: unknown = obj; + for (const segment of segments) { + if (current === null || current === undefined) return undefined; + if (typeof current !== 'object') return undefined; + current = (current as Record)[segment]; + } + + return current as JsonValue; +} + +/** + * Apply assignments from result to variables + */ +function applyAssignments(result: JsonValue, assignments: Assignments, vars: VariableStore): void { + for (const [varName, path] of Object.entries(assignments)) { + const value = getValueByPath(result, path); + if (value !== undefined) { + vars[varName] = value; + } + } +} + +/** + * Execute script in page context + */ +async function executeScript( + tabId: number, + frameId: number | undefined, + code: string, + args: Record, + world: BrowserWorld, +): Promise<{ ok: true; result: JsonValue } | { ok: false; error: string }> { + const frameIds = typeof frameId === 'number' ? [frameId] : undefined; + + try { + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world: world === 'ISOLATED' ? 'ISOLATED' : 'MAIN', + func: (scriptCode: string, scriptArgs: Record) => { + try { + // Create function with args available + const argNames = Object.keys(scriptArgs); + const argValues = Object.values(scriptArgs); + + // Wrap code to return result + const wrappedCode = ` + return (function(${argNames.join(', ')}) { + ${scriptCode} + })(${argNames.map((_, i) => `arguments[${i}]`).join(', ')}); + `; + + const fn = new Function(...argNames, wrappedCode); + const result = fn(...argValues); + + // Handle promises + if (result instanceof Promise) { + return result.then( + (value: unknown) => ({ success: true, result: value }), + (error: Error) => ({ success: false, error: error?.message || String(error) }), + ); + } + + return { success: true, result }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + args: [code, args], + }); + + const scriptResult = Array.isArray(injected) ? injected[0]?.result : undefined; + + // Handle async result + if (scriptResult instanceof Promise) { + const asyncResult = await scriptResult; + if (!asyncResult || typeof asyncResult !== 'object') { + return { ok: false, error: 'Async script returned invalid result' }; + } + if (!asyncResult.success) { + return { ok: false, error: asyncResult.error || 'Script failed' }; + } + return { ok: true, result: asyncResult.result as JsonValue }; + } + + if (!scriptResult || typeof scriptResult !== 'object') { + return { ok: false, error: 'Script returned invalid result' }; + } + + const typedResult = scriptResult as { success: boolean; result?: unknown; error?: string }; + if (!typedResult.success) { + return { ok: false, error: typedResult.error || 'Script failed' }; + } + + return { ok: true, result: typedResult.result as JsonValue }; + } catch (e) { + return { + ok: false, + error: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +export const scriptHandler: ActionHandler<'script'> = { + type: 'script', + + validate: (action) => { + const params = action.params; + + if (!params.code || typeof params.code !== 'string') { + return invalid('Script action requires a code string'); + } + + if (params.code.length > MAX_CODE_LENGTH) { + return invalid(`Script code exceeds maximum length of ${MAX_CODE_LENGTH} characters`); + } + + if (params.world !== undefined && params.world !== 'MAIN' && params.world !== 'ISOLATED') { + return invalid(`Invalid world: ${String(params.world)}`); + } + + if (params.when !== undefined && params.when !== 'before' && params.when !== 'after') { + return invalid(`Invalid timing: ${String(params.when)}`); + } + + return ok(); + }, + + describe: (action) => { + const world = action.params.world === 'ISOLATED' ? '[isolated]' : ''; + const timing = action.params.when ? `(${action.params.when})` : ''; + return `Script ${world}${timing}`.trim(); + }, + + run: async (ctx, action) => { + const tabId = ctx.tabId; + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for script action'); + } + + const params = action.params; + const world: BrowserWorld = params.world || 'MAIN'; + + // Resolve arguments + const argsResult = resolveArgs(params.args, ctx.vars); + if (!argsResult.ok) { + return failed('VALIDATION_ERROR', argsResult.error); + } + + // Execute script + const result = await executeScript(tabId, ctx.frameId, params.code, argsResult.resolved, world); + + if (!result.ok) { + return failed('SCRIPT_FAILED', result.error); + } + + // Store result if saveAs specified + if (params.saveAs) { + ctx.vars[params.saveAs] = result.result; + } + + // Apply assignments if specified + if (params.assign) { + applyAssignments(result.result, params.assign, ctx.vars); + } + + return { + status: 'success', + output: { result: result.result }, + }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts new file mode 100644 index 0000000..a1969e1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts @@ -0,0 +1,260 @@ +/** + * Scroll Action Handler + * + * Supports three scroll modes: + * - offset: Scroll the window to absolute coordinates + * - element: Scroll an element into view + * - container: Scroll within a container element + */ + +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { failed, invalid, ok, tryResolveNumber } from '../registry'; +import type { ActionHandler, ElementTarget } from '../types'; +import { logSelectorFallback, selectorLocator, sendMessageToTab, toSelectorTarget } from './common'; + +/** Check if target has valid selector specification */ +function hasTargetSpec(target: unknown): boolean { + if (!target || typeof target !== 'object') return false; + const t = target as { ref?: unknown; candidates?: unknown }; + const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0; + const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0; + return hasRef || hasCandidates; +} + +/** Strip frame prefix from composite selector */ +function stripCompositeSelector(selector: string): string { + const raw = String(selector || '').trim(); + if (!raw || !raw.includes('|>')) return raw; + const parts = raw + .split('|>') + .map((p) => p.trim()) + .filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : raw; +} + +/** Format offset value for description */ +function describeOffset(v: unknown): string { + return typeof v === 'number' && Number.isFinite(v) ? String(v) : '(dynamic)'; +} + +export const scrollHandler: ActionHandler<'scroll'> = { + type: 'scroll', + + validate: (action) => { + const mode = action.params.mode; + if (mode !== 'offset' && mode !== 'element' && mode !== 'container') { + return invalid(`Unsupported scroll mode: ${String(mode)}`); + } + + if ((mode === 'element' || mode === 'container') && !hasTargetSpec(action.params.target)) { + return invalid(`Scroll mode "${mode}" requires a target ref or selector candidates`); + } + + return ok(); + }, + + describe: (action) => { + const mode = action.params.mode; + if (mode === 'offset') { + const x = describeOffset(action.params.offset?.x); + const y = describeOffset(action.params.offset?.y); + return `Scroll window to x=${x}, y=${y}`; + } + if (mode === 'container') return 'Scroll container'; + return 'Scroll to element'; + }, + + run: async (ctx, action) => { + const vars = ctx.vars; + const tabId = ctx.tabId; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found for scroll action'); + } + + const mode = action.params.mode; + + // ---------------------------- + // Offset mode: window scroll + // ---------------------------- + if (mode === 'offset') { + let top: number | undefined; + let left: number | undefined; + + if (action.params.offset?.y !== undefined) { + const yResolved = tryResolveNumber(action.params.offset.y, vars); + if (!yResolved.ok) return failed('VALIDATION_ERROR', yResolved.error); + top = yResolved.value; + } + + if (action.params.offset?.x !== undefined) { + const xResolved = tryResolveNumber(action.params.offset.x, vars); + if (!xResolved.ok) return failed('VALIDATION_ERROR', xResolved.error); + left = xResolved.value; + } + + const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined; + + try { + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world: 'MAIN', + func: (t: number | null, l: number | null) => { + try { + const hasTop = typeof t === 'number' && Number.isFinite(t); + const hasLeft = typeof l === 'number' && Number.isFinite(l); + if (!hasTop && !hasLeft) return true; + + window.scrollTo({ + top: hasTop ? t : window.scrollY, + left: hasLeft ? l : window.scrollX, + behavior: 'auto', + }); + return true; + } catch { + return false; + } + }, + args: [top ?? null, left ?? null], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + if (result !== true) { + return failed('SCRIPT_FAILED', 'Window scroll script returned failure'); + } + } catch (e) { + return failed( + 'SCRIPT_FAILED', + `Failed to scroll window: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + return { status: 'success' }; + } + + // ---------------------------- + // Element/Container mode + // ---------------------------- + const target = action.params.target as ElementTarget | undefined; + if (!target) { + return failed('VALIDATION_ERROR', `Scroll mode "${mode}" requires a target`); + } + + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } }); + + const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars); + const located = await selectorLocator.locate(tabId, selectorTarget, { + frameId: ctx.frameId, + preferRef: false, + }); + + const frameId = located?.frameId ?? ctx.frameId; + const refToUse = located?.ref ?? selectorTarget.ref; + + // Resolve selector from ref or fallback + let selector: string | undefined; + if (refToUse) { + const resolved = await sendMessageToTab<{ success?: boolean; selector?: string }>( + tabId, + { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse }, + frameId, + ); + if ( + resolved.ok && + resolved.value?.success !== false && + typeof resolved.value?.selector === 'string' + ) { + const sel = resolved.value.selector.trim(); + if (sel) selector = sel; + } + } + + if (!selector && firstCssOrAttr) { + const stripped = stripCompositeSelector(firstCssOrAttr); + if (stripped) selector = stripped; + } + + if (!selector) { + return failed('TARGET_NOT_FOUND', 'Could not resolve a CSS selector for the scroll target'); + } + + // Resolve offset for container mode + let scrollTop: number | undefined; + let scrollLeft: number | undefined; + if (mode === 'container') { + if (action.params.offset?.y !== undefined) { + const yResolved = tryResolveNumber(action.params.offset.y, vars); + if (!yResolved.ok) return failed('VALIDATION_ERROR', yResolved.error); + scrollTop = yResolved.value; + } + + if (action.params.offset?.x !== undefined) { + const xResolved = tryResolveNumber(action.params.offset.x, vars); + if (!xResolved.ok) return failed('VALIDATION_ERROR', xResolved.error); + scrollLeft = xResolved.value; + } + } + + // Execute scroll script + try { + const frameIds = typeof frameId === 'number' ? [frameId] : undefined; + const injected = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + world: 'MAIN', + func: ( + sel: string, + scrollMode: 'element' | 'container', + top: number | null, + left: number | null, + ) => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) return false; + + if (scrollMode === 'element') { + el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }); + return true; + } + + // Container scroll + const hasTop = typeof top === 'number' && Number.isFinite(top); + const hasLeft = typeof left === 'number' && Number.isFinite(left); + + if (typeof el.scrollTo === 'function') { + el.scrollTo({ + top: hasTop ? top : el.scrollTop, + left: hasLeft ? left : el.scrollLeft, + behavior: 'instant', + }); + } else { + if (hasTop) el.scrollTop = top; + if (hasLeft) el.scrollLeft = left; + } + return true; + }, + args: [selector, mode, scrollTop ?? null, scrollLeft ?? null], + }); + + const result = Array.isArray(injected) ? injected[0]?.result : undefined; + if (result !== true) { + return failed('TARGET_NOT_FOUND', `Scroll target not found: ${selector}`); + } + } catch (e) { + return failed( + 'SCRIPT_FAILED', + `Failed to execute scroll: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + // Log fallback if used + const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : ''); + const fallbackUsed = + resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType; + if (fallbackUsed) { + logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy)); + } + + return { status: 'success' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts new file mode 100644 index 0000000..1356ae6 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts @@ -0,0 +1,419 @@ +/** + * Tab Management Action Handlers + * + * Handles browser tab operations: + * - openTab: Open a new tab or window + * - switchTab: Switch to a different tab + * - closeTab: Close tab(s) + * - handleDownload: Monitor and capture download information + */ + +import { failed, invalid, ok, tryResolveString } from '../registry'; +import type { ActionHandler, DownloadInfo, DownloadState, VariableStore } from '../types'; + +/** Default timeout for tab operations */ +const DEFAULT_TAB_TIMEOUT_MS = 10000; + +/** Default timeout for download operations */ +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60000; + +// ================================ +// openTab Handler +// ================================ + +export const openTabHandler: ActionHandler<'openTab'> = { + type: 'openTab', + + validate: () => ok(), + + describe: (action) => { + const url = typeof action.params.url === 'string' ? action.params.url : undefined; + const displayUrl = url ? (url.length > 30 ? url.slice(0, 30) + '...' : url) : 'blank'; + return action.params.newWindow ? `Open window: ${displayUrl}` : `Open tab: ${displayUrl}`; + }, + + run: async (ctx, action) => { + const params = action.params; + + // Resolve URL if provided + let url: string | undefined; + if (params.url !== undefined) { + const urlResult = tryResolveString(params.url, ctx.vars); + if (!urlResult.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`); + } + url = urlResult.value.trim() || undefined; + } + + try { + let tabId: number; + + if (params.newWindow) { + // Create new window + const window = await chrome.windows.create({ + url: url || 'about:blank', + focused: true, + }); + + const tab = window?.tabs?.[0]; + if (!tab?.id) { + return failed('TAB_NOT_FOUND', 'Failed to create new window'); + } + tabId = tab.id; + } else { + // Create new tab in current window + const tab = await chrome.tabs.create({ + url: url || 'about:blank', + active: true, + }); + + if (!tab.id) { + return failed('TAB_NOT_FOUND', 'Failed to create new tab'); + } + tabId = tab.id; + } + + // Wait for tab to be ready if URL was specified + if (url) { + await waitForTabComplete(tabId, DEFAULT_TAB_TIMEOUT_MS); + } + + // Return newTabId for ctx.tabId sync + return { status: 'success', newTabId: tabId }; + } catch (e) { + return failed('UNKNOWN', `Failed to open tab: ${e instanceof Error ? e.message : String(e)}`); + } + }, +}; + +// ================================ +// switchTab Handler +// ================================ + +export const switchTabHandler: ActionHandler<'switchTab'> = { + type: 'switchTab', + + validate: (action) => { + const params = action.params; + const hasTabId = params.tabId !== undefined; + const hasUrlContains = params.urlContains !== undefined; + const hasTitleContains = params.titleContains !== undefined; + + if (!hasTabId && !hasUrlContains && !hasTitleContains) { + return invalid('switchTab requires tabId, urlContains, or titleContains'); + } + + return ok(); + }, + + describe: (action) => { + if (action.params.tabId !== undefined) { + return `Switch to tab #${action.params.tabId}`; + } + if (action.params.urlContains !== undefined) { + return `Switch tab (URL contains)`; + } + if (action.params.titleContains !== undefined) { + return `Switch tab (title contains)`; + } + return 'Switch tab'; + }, + + run: async (ctx, action) => { + const params = action.params; + + try { + let targetTabId: number | undefined; + + if (params.tabId !== undefined) { + targetTabId = params.tabId; + } else { + // Find tab by URL or title + const tabs = await chrome.tabs.query({}); + + if (params.urlContains !== undefined) { + const urlResult = tryResolveString(params.urlContains, ctx.vars); + if (!urlResult.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve urlContains: ${urlResult.error}`); + } + const urlPattern = urlResult.value.trim().toLowerCase(); + + // Empty pattern is invalid + if (!urlPattern) { + return failed('VALIDATION_ERROR', 'urlContains pattern cannot be empty'); + } + + const matchingTab = tabs.find( + (tab) => tab.url && tab.url.toLowerCase().includes(urlPattern), + ); + targetTabId = matchingTab?.id; + } else if (params.titleContains !== undefined) { + const titleResult = tryResolveString(params.titleContains, ctx.vars); + if (!titleResult.ok) { + return failed( + 'VALIDATION_ERROR', + `Failed to resolve titleContains: ${titleResult.error}`, + ); + } + const titlePattern = titleResult.value.trim().toLowerCase(); + + // Empty pattern is invalid + if (!titlePattern) { + return failed('VALIDATION_ERROR', 'titleContains pattern cannot be empty'); + } + + const matchingTab = tabs.find( + (tab) => tab.title && tab.title.toLowerCase().includes(titlePattern), + ); + targetTabId = matchingTab?.id; + } + } + + if (targetTabId === undefined) { + return failed('TAB_NOT_FOUND', 'No matching tab found'); + } + + // Activate the tab + await chrome.tabs.update(targetTabId, { active: true }); + + // Focus the window containing the tab + const tab = await chrome.tabs.get(targetTabId); + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }); + } + + // Return newTabId for ctx.tabId sync + return { status: 'success', newTabId: targetTabId }; + } catch (e) { + return failed( + 'UNKNOWN', + `Failed to switch tab: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, +}; + +// ================================ +// closeTab Handler +// ================================ + +export const closeTabHandler: ActionHandler<'closeTab'> = { + type: 'closeTab', + + validate: () => ok(), + + describe: (action) => { + if (action.params.tabIds && action.params.tabIds.length > 0) { + return `Close ${action.params.tabIds.length} tab(s)`; + } + if (action.params.url !== undefined) { + return 'Close tab (by URL)'; + } + return 'Close current tab'; + }, + + run: async (ctx, action) => { + const params = action.params; + + try { + let tabIds: number[] = []; + + if (params.tabIds && params.tabIds.length > 0) { + // Close specific tabs + tabIds = [...params.tabIds]; + } else if (params.url !== undefined) { + // Find and close tabs by URL + const urlResult = tryResolveString(params.url, ctx.vars); + if (!urlResult.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`); + } + const urlPattern = urlResult.value.trim().toLowerCase(); + + // Empty pattern is invalid + if (!urlPattern) { + return failed('VALIDATION_ERROR', 'URL pattern cannot be empty'); + } + + const tabs = await chrome.tabs.query({}); + tabIds = tabs + .filter((tab) => tab.url && tab.url.toLowerCase().includes(urlPattern) && tab.id) + .map((tab) => tab.id!); + } else { + // Close current tab + if (typeof ctx.tabId === 'number') { + tabIds = [ctx.tabId]; + } + } + + if (tabIds.length === 0) { + return failed('TAB_NOT_FOUND', 'No tabs to close'); + } + + await chrome.tabs.remove(tabIds); + return { status: 'success' }; + } catch (e) { + return failed( + 'UNKNOWN', + `Failed to close tab: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, +}; + +// ================================ +// handleDownload Handler +// ================================ + +export const handleDownloadHandler: ActionHandler<'handleDownload'> = { + type: 'handleDownload', + + validate: () => ok(), + + describe: (action) => { + if (action.params.filenameContains !== undefined) { + return 'Handle download (by filename)'; + } + return 'Handle download'; + }, + + run: async (ctx, action) => { + const params = action.params; + const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; + const waitForComplete = params.waitForComplete !== false; + + // Resolve filename pattern if provided + let filenamePattern: string | undefined; + if (params.filenameContains !== undefined) { + const result = tryResolveString(params.filenameContains, ctx.vars); + if (!result.ok) { + return failed('VALIDATION_ERROR', `Failed to resolve filenameContains: ${result.error}`); + } + filenamePattern = result.value.toLowerCase(); + } + + return new Promise((resolve) => { + const startTime = Date.now(); + let downloadId: number | undefined; + let downloadInfo: DownloadInfo | undefined; + let resolved = false; + + const cleanup = () => { + chrome.downloads.onCreated.removeListener(onCreated); + chrome.downloads.onChanged.removeListener(onChanged); + }; + + const finish = (result: Awaited['run']>>) => { + if (!resolved) { + resolved = true; + cleanup(); + resolve(result); + } + }; + + const onCreated = (item: chrome.downloads.DownloadItem) => { + // Check if this download matches our criteria + if (filenamePattern) { + const filename = item.filename.toLowerCase(); + if (!filename.includes(filenamePattern)) return; + } + + downloadId = item.id; + downloadInfo = { + id: String(item.id), + filename: item.filename, + url: item.url, + state: item.state as DownloadState, + size: item.totalBytes > 0 ? item.totalBytes : undefined, + }; + + if (!waitForComplete || item.state === 'complete') { + storeAndFinish(); + } + }; + + const onChanged = (delta: chrome.downloads.DownloadDelta) => { + if (delta.id !== downloadId) return; + + if (delta.state) { + if (downloadInfo) { + downloadInfo.state = delta.state.current as DownloadState; + } + + if (delta.state.current === 'complete') { + storeAndFinish(); + } else if (delta.state.current === 'interrupted') { + finish(failed('DOWNLOAD_FAILED', 'Download was interrupted')); + } + } + + if (delta.filename && downloadInfo) { + downloadInfo.filename = delta.filename.current || downloadInfo.filename; + } + + if (delta.totalBytes && downloadInfo && delta.totalBytes.current) { + downloadInfo.size = delta.totalBytes.current; + } + }; + + const storeAndFinish = () => { + if (params.saveAs && downloadInfo) { + ctx.vars[params.saveAs] = downloadInfo as unknown as VariableStore[string]; + } + finish({ + status: 'success', + output: downloadInfo ? { download: downloadInfo } : undefined, + }); + }; + + // Set up listeners + chrome.downloads.onCreated.addListener(onCreated); + chrome.downloads.onChanged.addListener(onChanged); + + // Set up timeout + const checkTimeout = () => { + if (resolved) return; + if (Date.now() - startTime > timeoutMs) { + finish(failed('TIMEOUT', `Download timeout after ${timeoutMs}ms`)); + } else { + setTimeout(checkTimeout, 500); + } + }; + setTimeout(checkTimeout, 500); + }); + }, +}; + +// ================================ +// Helper Functions +// ================================ + +/** + * Wait for a tab to complete loading + */ +async function waitForTabComplete(tabId: number, timeoutMs: number): Promise { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const checkStatus = async () => { + try { + const tab = await chrome.tabs.get(tabId); + + if (tab.status === 'complete') { + resolve(); + return; + } + + if (Date.now() - startTime > timeoutMs) { + reject(new Error(`Tab load timeout after ${timeoutMs}ms`)); + return; + } + + setTimeout(checkStatus, 100); + } catch (e) { + reject(e); + } + }; + + checkStatus(); + }); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts new file mode 100644 index 0000000..e2ec8d9 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts @@ -0,0 +1,195 @@ +/** + * Wait Action Handler + * + * Handles various wait conditions: + * - Sleep (fixed delay) + * - Network idle + * - Navigation complete + * - Text appears/disappears + * - Selector visible/hidden + */ + +import { ENGINE_CONSTANTS } from '../../engine/constants'; +import { waitForNavigation, waitForNetworkIdle } from '../../rr-utils'; +import { failed, invalid, ok, tryResolveNumber } from '../registry'; +import type { ActionHandler } from '../types'; +import { clampInt, resolveString, sendMessageToTab } from './common'; + +export const waitHandler: ActionHandler<'wait'> = { + type: 'wait', + + validate: (action) => { + const condition = action.params.condition; + if (!condition || typeof condition !== 'object') { + return invalid('Missing condition parameter'); + } + if (!('kind' in condition)) { + return invalid('Condition must have a kind property'); + } + return ok(); + }, + + describe: (action) => { + const condition = action.params.condition; + if (!condition) return 'Wait'; + + switch (condition.kind) { + case 'sleep': { + const ms = typeof condition.sleep === 'number' ? condition.sleep : '(dynamic)'; + return `Wait ${ms}ms`; + } + case 'networkIdle': + return 'Wait for network idle'; + case 'navigation': + return 'Wait for navigation'; + case 'text': { + const appear = condition.appear !== false; + const text = typeof condition.text === 'string' ? condition.text : '(dynamic)'; + const displayText = text.length > 20 ? text.slice(0, 20) + '...' : text; + return `Wait for text "${displayText}" to ${appear ? 'appear' : 'disappear'}`; + } + case 'selector': { + const visible = condition.visible !== false; + return `Wait for selector to be ${visible ? 'visible' : 'hidden'}`; + } + default: + return 'Wait'; + } + }, + + run: async (ctx, action) => { + const vars = ctx.vars; + const tabId = ctx.tabId; + + if (typeof tabId !== 'number') { + return failed('TAB_NOT_FOUND', 'No active tab found'); + } + + const timeoutMs = action.policy?.timeout?.ms; + const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined; + const condition = action.params.condition; + + // Handle sleep condition + if (condition.kind === 'sleep') { + const msResolved = tryResolveNumber(condition.sleep, vars); + if (!msResolved.ok) { + return failed('VALIDATION_ERROR', msResolved.error); + } + const ms = Math.max(0, Number(msResolved.value ?? 0)); + await new Promise((resolve) => setTimeout(resolve, ms)); + return { status: 'success' }; + } + + // Handle network idle condition + if (condition.kind === 'networkIdle') { + const totalMs = clampInt(timeoutMs ?? 5000, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS); + let idleMs: number; + + if (condition.idleMs !== undefined) { + const idleResolved = tryResolveNumber(condition.idleMs, vars); + idleMs = idleResolved.ok + ? clampInt(idleResolved.value, 200, 5000) + : Math.min(1500, Math.max(500, Math.floor(totalMs / 3))); + } else { + idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3))); + } + + await waitForNetworkIdle(totalMs, idleMs); + return { status: 'success' }; + } + + // Handle navigation condition + if (condition.kind === 'navigation') { + const timeout = timeoutMs === undefined ? undefined : Math.max(0, Number(timeoutMs)); + await waitForNavigation(timeout); + return { status: 'success' }; + } + + // Handle text condition + if (condition.kind === 'text') { + const textResolved = resolveString(condition.text, vars); + if (!textResolved.ok) { + return failed('VALIDATION_ERROR', textResolved.error); + } + + const appear = condition.appear !== false; + const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS); + + // Inject wait helper script + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + files: ['inject-scripts/wait-helper.js'], + world: 'ISOLATED', + }); + } catch (e) { + return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`); + } + + // Execute wait for text + const response = await sendMessageToTab<{ success?: boolean }>( + tabId, + { action: 'waitForText', text: textResolved.value, appear, timeout }, + ctx.frameId, + ); + + if (!response.ok) { + return failed('TIMEOUT', `Wait for text failed: ${response.error}`); + } + if (response.value?.success !== true) { + return failed( + 'TIMEOUT', + `Text "${textResolved.value}" did not ${appear ? 'appear' : 'disappear'} within timeout`, + ); + } + + return { status: 'success' }; + } + + // Handle selector condition + if (condition.kind === 'selector') { + const selectorResolved = resolveString(condition.selector, vars); + if (!selectorResolved.ok) { + return failed('VALIDATION_ERROR', selectorResolved.error); + } + + const visible = condition.visible !== false; + const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS); + + // Inject wait helper script + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds } as chrome.scripting.InjectionTarget, + files: ['inject-scripts/wait-helper.js'], + world: 'ISOLATED', + }); + } catch (e) { + return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`); + } + + // Execute wait for selector + const response = await sendMessageToTab<{ success?: boolean }>( + tabId, + { action: 'waitForSelector', selector: selectorResolved.value, visible, timeout }, + ctx.frameId, + ); + + if (!response.ok) { + return failed('TIMEOUT', `Wait for selector failed: ${response.error}`); + } + if (response.value?.success !== true) { + return failed( + 'TIMEOUT', + `Selector "${selectorResolved.value}" did not become ${visible ? 'visible' : 'hidden'} within timeout`, + ); + } + + return { status: 'success' }; + } + + return failed( + 'VALIDATION_ERROR', + `Unsupported wait condition kind: ${(condition as { kind: string }).kind}`, + ); + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/index.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/index.ts new file mode 100644 index 0000000..e8df2b6 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/index.ts @@ -0,0 +1,43 @@ +/** + * Action System - 导出模块 + */ + +// 类型导出 +export * from './types'; + +// 注册表导出 +export { + ActionRegistry, + createActionRegistry, + ok, + invalid, + failed, + tryResolveString, + tryResolveNumber, + tryResolveJson, + tryResolveValue, + type BeforeExecuteArgs, + type BeforeExecuteHook, + type AfterExecuteArgs, + type AfterExecuteHook, + type ActionRegistryHooks, +} from './registry'; + +// 适配器导出 +export { + execCtxToActionCtx, + stepToAction, + actionResultToExecResult, + createStepExecutor, + isActionSupported, + getActionType, + type StepExecutionAttempt, +} from './adapter'; + +// Handler 工厂导出 +export { + createReplayActionRegistry, + registerReplayHandlers, + getSupportedActionTypes, + isActionTypeSupported, +} from './handlers'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts new file mode 100644 index 0000000..89fedc8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts @@ -0,0 +1,640 @@ +/** + * Action Registry - Action 执行器注册表和执行管道 + * + * 特性: + * - 动态注册/注销 handler + * - 中间件/钩子机制 (beforeExecute, afterExecute) + * - 重试和超时策略 + * - 类型安全 + */ + +import type { + Action, + ActionError, + ActionErrorCode, + ActionExecutionContext, + ActionExecutionResult, + ActionHandler, + EdgeLabel, + ElementTarget, + ExecutableAction, + ExecutableActionType, + FrameTarget, + JsonValue, + NonEmptyArray, + Resolvable, + RetryPolicy, + SelectorCandidate, + TimeoutPolicy, + ValidationResult, + VariablePathSegment, + VariablePointer, + VariableStore, +} from './types'; + +// ================================ +// 类型定义 +// ================================ + +type AnyExecutableAction = { + [T in ExecutableActionType]: ExecutableAction; +}[ExecutableActionType]; +type AnyExecutableHandler = { [T in ExecutableActionType]: ActionHandler }[ExecutableActionType]; + +export interface BeforeExecuteArgs { + ctx: ActionExecutionContext; + action: ExecutableAction; + handler: ActionHandler; + attempt: number; +} + +export type BeforeExecuteHook = ( + args: BeforeExecuteArgs, +) => void | ActionExecutionResult | Promise>; + +export interface AfterExecuteArgs { + ctx: ActionExecutionContext; + action: ExecutableAction; + handler: ActionHandler; + result: ActionExecutionResult; + attempt: number; +} + +export type AfterExecuteHook = ( + args: AfterExecuteArgs, +) => void | ActionExecutionResult | Promise>; + +export interface ActionRegistryHooks { + beforeExecute?: BeforeExecuteHook; + afterExecute?: AfterExecuteHook; +} + +// ================================ +// 工具函数 +// ================================ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function toNonEmptyArray(value: string[], fallback: string): NonEmptyArray { + return (value.length > 0 ? value : [fallback]) as NonEmptyArray; +} + +const ACTION_ERROR_CODES: ReadonlyArray = [ + 'VALIDATION_ERROR', + 'TIMEOUT', + 'TAB_NOT_FOUND', + 'FRAME_NOT_FOUND', + 'TARGET_NOT_FOUND', + 'ELEMENT_NOT_VISIBLE', + 'NAVIGATION_FAILED', + 'NETWORK_REQUEST_FAILED', + 'DOWNLOAD_FAILED', + 'ASSERTION_FAILED', + 'SCRIPT_FAILED', + 'UNKNOWN', +] as const; + +function isActionErrorCode(value: unknown): value is ActionErrorCode { + return typeof value === 'string' && (ACTION_ERROR_CODES as ReadonlyArray).includes(value); +} + +function toErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + if (typeof e === 'string') return e; + if (isRecord(e) && typeof e.message === 'string') return e.message; + return 'Unknown error'; +} + +function toActionError(e: unknown, fallbackCode: ActionErrorCode = 'UNKNOWN'): ActionError { + if (isRecord(e) && isActionErrorCode(e.code) && typeof e.message === 'string') { + return { code: e.code, message: e.message, data: undefined }; + } + return { code: fallbackCode, message: toErrorMessage(e) }; +} + +export function ok(): ValidationResult { + return { ok: true }; +} + +export function invalid(...errors: string[]): ValidationResult { + return { ok: false, errors: toNonEmptyArray(errors.filter(Boolean), 'Validation failed') }; +} + +export function failed( + code: ActionErrorCode, + message: string, +): ActionExecutionResult { + return { status: 'failed', error: { code, message } }; +} + +function sleep(ms: number): Promise { + const safe = Math.max(0, Math.floor(ms)); + return new Promise((resolve) => setTimeout(resolve, safe)); +} + +// ================================ +// Resolvable 解析器 +// ================================ + +function isVariablePointer(value: unknown): value is VariablePointer { + if (!isRecord(value)) return false; + if (typeof value.name !== 'string' || value.name.length === 0) return false; + if (value.path === undefined) return true; + if (!Array.isArray(value.path)) return false; + return value.path.every((s) => typeof s === 'string' || typeof s === 'number'); +} + +function isVarValue( + value: unknown, +): value is { kind: 'var'; ref: VariablePointer; default?: unknown } { + if (!isRecord(value)) return false; + if (value.kind !== 'var') return false; + return isVariablePointer(value.ref); +} + +function isExprValue(value: unknown): value is { kind: 'expr'; default?: unknown } { + if (!isRecord(value)) return false; + if (value.kind !== 'expr') return false; + return 'expr' in value; +} + +function isStringTemplate(value: unknown): value is { kind: 'template'; parts: unknown[] } { + if (!isRecord(value)) return false; + if (value.kind !== 'template') return false; + return Array.isArray(value.parts) && value.parts.length > 0; +} + +function readByPath( + value: JsonValue, + path?: ReadonlyArray, +): JsonValue | undefined { + if (!path || path.length === 0) return value; + let cur: JsonValue | undefined = value; + for (const seg of path) { + if (cur === undefined || cur === null) return undefined; + if (typeof seg === 'number') { + if (!Array.isArray(cur)) return undefined; + cur = cur[seg] as JsonValue | undefined; + continue; + } + if (typeof seg === 'string') { + if (!isRecord(cur)) return undefined; + cur = (cur as Record)[seg] as JsonValue | undefined; + continue; + } + return undefined; + } + return cur; +} + +export function tryResolveJson( + value: Resolvable, + vars: VariableStore, +): { ok: true; value: JsonValue } | { ok: false; error: string } { + if (isVarValue(value)) { + const ref = value.ref; + const root = vars[ref.name]; + const resolved = root === undefined ? undefined : readByPath(root, ref.path); + if (resolved !== undefined) return { ok: true, value: resolved }; + if ('default' in value) return { ok: true, value: (value.default ?? null) as JsonValue }; + return { ok: true, value: null }; + } + if (isExprValue(value)) { + if ('default' in value) return { ok: true, value: (value.default ?? null) as JsonValue }; + return { ok: false, error: 'Expression value is not supported by the default resolver' }; + } + return { ok: true, value }; +} + +function formatInserted(value: JsonValue, format?: 'text' | 'json' | 'urlEncoded'): string { + if (format === 'json') return JSON.stringify(value); + const text = value === null ? '' : typeof value === 'string' ? value : String(value); + if (format === 'urlEncoded') return encodeURIComponent(text); + return text; +} + +export function tryResolveString( + value: Resolvable, + vars: VariableStore, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof value === 'string') return { ok: true, value }; + if (isVarValue(value)) { + const ref = value.ref; + const root = vars[ref.name]; + const resolved = root === undefined ? undefined : readByPath(root, ref.path); + if (resolved !== undefined && resolved !== null) return { ok: true, value: String(resolved) }; + if ('default' in value && typeof value.default === 'string') + return { ok: true, value: value.default }; + return { ok: true, value: '' }; + } + if (isStringTemplate(value)) { + const parts = value.parts; + let out = ''; + for (const p of parts) { + if (!isRecord(p) || typeof p.kind !== 'string') + return { ok: false, error: 'Invalid template part' }; + if (p.kind === 'text') { + if (typeof p.value !== 'string') return { ok: false, error: 'Invalid template text part' }; + out += p.value; + continue; + } + if (p.kind === 'insert') { + const resolved = tryResolveJson(p.value as Resolvable, vars); + if (!resolved.ok) return { ok: false, error: resolved.error }; + out += formatInserted( + resolved.value, + (p.format as 'text' | 'json' | 'urlEncoded' | undefined) ?? 'text', + ); + continue; + } + return { + ok: false, + error: `Unknown template part kind: ${String((p as { kind: string }).kind)}`, + }; + } + return { ok: true, value: out }; + } + if (isExprValue(value)) { + if ('default' in value && typeof value.default === 'string') + return { ok: true, value: value.default }; + return { ok: false, error: 'Expression value is not supported by the default resolver' }; + } + return { ok: false, error: 'Unsupported resolvable string value' }; +} + +export function tryResolveNumber( + value: Resolvable, + vars: VariableStore, +): { ok: true; value: number } | { ok: false; error: string } { + if (typeof value === 'number' && Number.isFinite(value)) return { ok: true, value }; + if (isVarValue(value)) { + const ref = value.ref; + const root = vars[ref.name]; + const resolved = root === undefined ? undefined : readByPath(root, ref.path); + if (typeof resolved === 'number' && Number.isFinite(resolved)) + return { ok: true, value: resolved }; + if (typeof resolved === 'string' && resolved.trim() !== '') { + const n = Number(resolved); + if (Number.isFinite(n)) return { ok: true, value: n }; + } + if ('default' in value && typeof value.default === 'number' && Number.isFinite(value.default)) + return { ok: true, value: value.default }; + return { ok: false, error: `Variable "${ref.name}" is not a finite number` }; + } + if (isExprValue(value)) { + if ('default' in value && typeof value.default === 'number' && Number.isFinite(value.default)) + return { ok: true, value: value.default }; + return { ok: false, error: 'Expression value is not supported by the default resolver' }; + } + return { ok: false, error: 'Unsupported resolvable number value' }; +} + +/** + * Resolve a generic JSON value (alias for tryResolveJson) + * Useful for script/http handlers that work with arbitrary JSON + */ +export const tryResolveValue = tryResolveJson; + +// ================================ +// 重试和超时逻辑 +// ================================ + +function shouldRetry(policy: RetryPolicy | undefined, error: ActionError | undefined): boolean { + if (!policy) return false; + if (policy.retries <= 0) return false; + if (!error) return false; + if (error.code === 'VALIDATION_ERROR') return false; + if (policy.retryOn && policy.retryOn.length > 0) return policy.retryOn.includes(error.code); + return true; +} + +function computeRetryDelayMs(policy: RetryPolicy, retryIndex: number): number { + const base = Math.max(0, Math.floor(policy.intervalMs)); + const backoff = policy.backoff ?? 'none'; + + let delay = base; + if (backoff === 'linear') delay = base * (retryIndex + 1); + if (backoff === 'exp') delay = base * Math.pow(2, retryIndex); + + const capped = + policy.maxIntervalMs !== undefined ? Math.min(delay, Math.max(0, policy.maxIntervalMs)) : delay; + if ((policy.jitter ?? 'none') === 'full') return Math.floor(Math.random() * capped); + return capped; +} + +async function runWithTimeout( + run: () => Promise, + timeoutMs: number | undefined, +): Promise<{ ok: true; value: T } | { ok: false; error: ActionError }> { + if (timeoutMs === undefined) { + try { + return { ok: true, value: await run() }; + } catch (e) { + return { ok: false, error: toActionError(e) }; + } + } + + const ms = Math.max(0, Math.floor(timeoutMs)); + if (ms === 0) return { ok: false, error: { code: 'TIMEOUT', message: 'Timeout reached' } }; + + return await new Promise((resolve) => { + const timer: ReturnType = setTimeout(() => { + resolve({ ok: false, error: { code: 'TIMEOUT', message: 'Timeout reached' } }); + }, ms); + + run() + .then((value) => { + clearTimeout(timer); + resolve({ ok: true, value }); + }) + .catch((e) => { + clearTimeout(timer); + resolve({ ok: false, error: toActionError(e) }); + }); + }); +} + +// ================================ +// ActionRegistry 类 +// ================================ + +export class ActionRegistry { + private readonly handlers: { [T in ExecutableActionType]?: ActionHandler } = {}; + private readonly beforeHooks: BeforeExecuteHook[] = []; + private readonly afterHooks: AfterExecuteHook[] = []; + + /** + * 注册 action handler + */ + register( + handler: ActionHandler, + options?: { override?: boolean }, + ): void { + const override = options?.override !== false; + const existing = this.handlers[handler.type]; + if (existing && !override) { + throw new Error(`Handler already registered for type: ${handler.type}`); + } + // Type assertion needed due to TypeScript mapped type limitation + + (this.handlers as Record>)[handler.type] = handler; + } + + /** + * 注销 action handler + */ + unregister(type: T): boolean { + const exists = this.handlers[type] !== undefined; + delete this.handlers[type]; + return exists; + } + + /** + * 获取 handler + */ + get(type: T): ActionHandler | undefined { + return this.handlers[type]; + } + + /** + * 检查是否存在 handler + */ + has(type: ExecutableActionType): boolean { + return this.handlers[type] !== undefined; + } + + /** + * 列出所有已注册的 handler + */ + list(): ReadonlyArray { + const arr = Object.values(this.handlers).filter( + (h): h is AnyExecutableHandler => h !== undefined, + ); + return arr; + } + + /** + * 注册 beforeExecute 钩子 + */ + onBeforeExecute(hook: BeforeExecuteHook): () => void { + this.beforeHooks.push(hook); + return () => { + const idx = this.beforeHooks.indexOf(hook); + if (idx >= 0) this.beforeHooks.splice(idx, 1); + }; + } + + /** + * 注册 afterExecute 钩子 + */ + onAfterExecute(hook: AfterExecuteHook): () => void { + this.afterHooks.push(hook); + return () => { + const idx = this.afterHooks.indexOf(hook); + if (idx >= 0) this.afterHooks.splice(idx, 1); + }; + } + + /** + * 批量注册钩子 + */ + use(hooks: ActionRegistryHooks): () => void { + const disposers: Array<() => void> = []; + if (hooks.beforeExecute) disposers.push(this.onBeforeExecute(hooks.beforeExecute)); + if (hooks.afterExecute) disposers.push(this.onAfterExecute(hooks.afterExecute)); + return () => { + for (const d of disposers) d(); + }; + } + + /** + * 验证 action 配置 + */ + validate(action: ExecutableAction): ValidationResult { + const handler = this.get(action.type); + if (!handler) return invalid(`Unsupported action type: ${String(action.type)}`); + if (!handler.validate) return ok(); + return handler.validate(action); + } + + /** + * 执行 action + */ + async execute( + ctx: ActionExecutionContext, + action: ExecutableAction, + ): Promise> { + const startedAt = Date.now(); + + // 跳过禁用的 action + if (action.disabled) { + return { status: 'skipped', durationMs: Date.now() - startedAt }; + } + + // 获取 handler + const handler = this.get(action.type); + if (!handler) { + return { + status: 'failed', + error: { + code: 'VALIDATION_ERROR', + message: `Unsupported action type: ${String(action.type)}`, + }, + durationMs: Date.now() - startedAt, + }; + } + + // 验证 + const v = this.validate(action); + if (!v.ok) { + let result: ActionExecutionResult = { + status: 'failed', + error: { code: 'VALIDATION_ERROR', message: v.errors.join(', ') }, + }; + + // 调用 afterExecute 钩子 + for (const hook of this.afterHooks) { + try { + const maybe = await hook({ ctx, action, handler, result, attempt: 0 }); + if (maybe) result = maybe; + } catch (e) { + try { + ctx.log(`afterExecute hook failed: ${toErrorMessage(e)}`, 'warn'); + } catch { + // ignore + } + } + } + + result.durationMs = Date.now() - startedAt; + return result; + } + + // 计算重试和超时参数 + const retryPolicy = action.policy?.retry; + const timeoutPolicy = action.policy?.timeout; + const maxAttempts = 1 + Math.max(0, Math.floor(retryPolicy?.retries ?? 0)); + + const actionDeadline = + timeoutPolicy && timeoutPolicy.ms > 0 && (timeoutPolicy.scope ?? 'attempt') === 'action' + ? startedAt + timeoutPolicy.ms + : undefined; + + const remainingActionMs = () => + actionDeadline === undefined ? undefined : Math.max(0, actionDeadline - Date.now()); + + let last: ActionExecutionResult | undefined; + + // 执行循环(支持重试) + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const attemptTimeoutMs: number | undefined = (() => { + if (!timeoutPolicy || timeoutPolicy.ms <= 0) return undefined; + const scope = timeoutPolicy.scope ?? 'attempt'; + if (scope === 'attempt') return timeoutPolicy.ms; + return remainingActionMs(); + })(); + + if (attemptTimeoutMs !== undefined && attemptTimeoutMs <= 0) { + last = failed('TIMEOUT', 'Timeout reached'); + break; + } + + // beforeExecute 钩子(可以短路) + let shortCircuited: ActionExecutionResult | undefined; + for (const hook of this.beforeHooks) { + try { + const maybe = await hook({ ctx, action, handler, attempt }); + if (maybe) { + shortCircuited = maybe; + break; + } + } catch (e) { + try { + ctx.log(`beforeExecute hook failed: ${toErrorMessage(e)}`, 'warn'); + } catch { + // ignore + } + } + } + + // 执行 handler + const runOutcome = + shortCircuited ?? + (await (async () => { + const out = await runWithTimeout(() => handler.run(ctx, action), attemptTimeoutMs); + if (!out.ok) return failed(out.error.code, out.error.message); + + const result = out.value ?? ({} as ActionExecutionResult); + if (result.status === 'failed' && !result.error) { + return { ...result, error: { code: 'UNKNOWN' as const, message: 'Action failed' } }; + } + return result; + })()); + + let result: ActionExecutionResult = runOutcome; + + // afterExecute 钩子(可以替换结果) + for (const hook of this.afterHooks) { + try { + const maybe = await hook({ ctx, action, handler, result, attempt }); + if (maybe) result = maybe; + } catch (e) { + try { + ctx.log(`afterExecute hook failed: ${toErrorMessage(e)}`, 'warn'); + } catch { + // ignore + } + } + } + + last = result; + + // 成功则退出 + if (result.status !== 'failed') break; + + // 判断是否重试 + const canRetry = attempt < maxAttempts - 1 && shouldRetry(retryPolicy, result.error); + if (!canRetry) break; + + const delay = computeRetryDelayMs(retryPolicy!, attempt); + if ( + actionDeadline !== undefined && + remainingActionMs() !== undefined && + (remainingActionMs() as number) < delay + ) { + break; + } + + try { + ctx.log(`Retrying action "${action.type}" (attempt ${attempt + 1}/${maxAttempts})`, 'warn'); + } catch { + // ignore + } + + if (delay > 0) await sleep(delay); + } + + const finalResult: ActionExecutionResult = + last ?? + ({ + status: 'failed', + error: { code: 'UNKNOWN', message: 'Action execution produced no result' }, + } as ActionExecutionResult); + + finalResult.durationMs = Date.now() - startedAt; + return finalResult; + } +} + +// ================================ +// 导出工厂函数 +// ================================ + +/** + * 创建默认的 ActionRegistry 实例 + */ +export function createActionRegistry(): ActionRegistry { + return new ActionRegistry(); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts new file mode 100644 index 0000000..41fa954 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts @@ -0,0 +1,944 @@ +/** + * Action Type System for Record & Replay + * 商业级录制回放的核心类型定义 + * + * 设计原则: + * - 类型安全,无 any + * - 支持所有操作类型 + * - 支持重试、超时、错误处理策略 + * - 支持选择器候选列表和稳定性评分 + * - 支持变量系统 + * - 符合 SOLID 原则(接口可通过声明合并扩展) + */ + +// ================================ +// 基础类型 +// ================================ + +export type Milliseconds = number; +export type ISODateTimeString = string; +export type NonEmptyArray = [T, ...T[]]; + +// JSON 类型 +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; +export interface JsonObject { + [key: string]: JsonValue; +} +export type JsonArray = JsonValue[]; + +// ID 类型 +export type FlowId = string; +export type ActionId = string; +export type SubflowId = string; +export type EdgeId = string; +export type VariableName = string; + +// ================================ +// Edge Labels +// ================================ + +export const EDGE_LABELS = { + DEFAULT: 'default', + TRUE: 'true', + FALSE: 'false', + ON_ERROR: 'onError', +} as const; + +export type BuiltinEdgeLabel = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS]; +export type EdgeLabel = string; + +// ================================ +// 错误处理 +// ================================ + +export type ActionErrorCode = + | 'VALIDATION_ERROR' + | 'TIMEOUT' + | 'TAB_NOT_FOUND' + | 'FRAME_NOT_FOUND' + | 'TARGET_NOT_FOUND' + | 'ELEMENT_NOT_VISIBLE' + | 'NAVIGATION_FAILED' + | 'NETWORK_REQUEST_FAILED' + | 'DOWNLOAD_FAILED' + | 'ASSERTION_FAILED' + | 'SCRIPT_FAILED' + | 'UNKNOWN'; + +export interface ActionError { + code: ActionErrorCode; + message: string; + data?: JsonValue; +} + +// ================================ +// 执行策略 +// ================================ + +export interface TimeoutPolicy { + ms: Milliseconds; + /** 'attempt' = 每次尝试独立计时, 'action' = 整个 action 总计时 */ + scope?: 'attempt' | 'action'; +} + +export type BackoffKind = 'none' | 'exp' | 'linear'; + +export interface RetryPolicy { + /** 重试次数(不含首次尝试) */ + retries: number; + /** 重试间隔 */ + intervalMs: Milliseconds; + /** 退避策略 */ + backoff?: BackoffKind; + /** 最大间隔(用于 exp/linear) */ + maxIntervalMs?: Milliseconds; + /** 抖动策略 */ + jitter?: 'none' | 'full'; + /** 仅在这些错误码时重试 */ + retryOn?: ReadonlyArray; +} + +export type ErrorHandlingStrategy = + | { kind: 'stop' } + | { kind: 'continue'; level?: 'warning' | 'error' } + | { kind: 'goto'; label: EdgeLabel }; + +export interface ArtifactCapturePolicy { + screenshot?: 'never' | 'onFailure' | 'always'; + saveScreenshotAs?: VariableName; + includeConsole?: boolean; + includeNetwork?: boolean; +} + +export interface ActionPolicy { + timeout?: TimeoutPolicy; + retry?: RetryPolicy; + onError?: ErrorHandlingStrategy; + artifacts?: ArtifactCapturePolicy; +} + +// ================================ +// 变量系统 +// ================================ + +export interface VariableDefinitionBase { + name: VariableName; + label?: string; + description?: string; + sensitive?: boolean; + required?: boolean; +} + +export interface VariableStringRules { + pattern?: string; + minLength?: number; + maxLength?: number; +} + +export interface VariableNumberRules { + min?: number; + max?: number; + integer?: boolean; +} + +export type VariableDefinition = + | (VariableDefinitionBase & { + kind: 'string'; + default?: string; + rules?: VariableStringRules; + }) + | (VariableDefinitionBase & { + kind: 'number'; + default?: number; + rules?: VariableNumberRules; + }) + | (VariableDefinitionBase & { + kind: 'boolean'; + default?: boolean; + }) + | (VariableDefinitionBase & { + kind: 'enum'; + options: NonEmptyArray; + default?: string; + }) + | (VariableDefinitionBase & { + kind: 'array'; + item: 'string' | 'number' | 'boolean' | 'json'; + default?: JsonValue[]; + }) + | (VariableDefinitionBase & { + kind: 'json'; + default?: JsonValue; + }); + +export type VariableStore = Record; + +export type VariableScope = 'flow' | 'run' | 'env' | 'secret'; +export type VariablePathSegment = string | number; + +export interface VariablePointer { + scope?: VariableScope; + name: VariableName; + path?: ReadonlyArray; +} + +// ================================ +// 表达式和模板 +// ================================ + +export type ExpressionLanguage = 'js' | 'rr'; + +export interface Expression<_T = JsonValue> { + language: ExpressionLanguage; + code: string; +} + +export interface VariableValue { + kind: 'var'; + ref: VariablePointer; + default?: T; +} + +export interface ExpressionValue { + kind: 'expr'; + expr: Expression; + default?: T; +} + +export type TemplateFormat = 'text' | 'json' | 'urlEncoded'; + +export type TemplatePart = + | { kind: 'text'; value: string } + | { kind: 'insert'; value: Resolvable; format?: TemplateFormat }; + +export interface StringTemplate { + kind: 'template'; + parts: NonEmptyArray; +} + +export type Resolvable = + | T + | VariableValue + | ExpressionValue + | ([T] extends [string] ? StringTemplate : never); + +export type DataPath = string; // dot/bracket path: e.g. "data.items[0].id" +export type Assignments = Record; + +// ================================ +// 条件表达式 +// ================================ + +export type CompareOp = + | 'eq' + | 'eqi' + | 'neq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'contains' + | 'containsI' + | 'notContains' + | 'notContainsI' + | 'startsWith' + | 'endsWith' + | 'regex'; + +export type Condition = + | { kind: 'expr'; expr: Expression } + | { + kind: 'compare'; + left: Resolvable; + op: CompareOp; + right: Resolvable; + } + | { kind: 'truthy'; value: Resolvable } + | { kind: 'falsy'; value: Resolvable } + | { kind: 'not'; condition: Condition } + | { kind: 'and'; conditions: NonEmptyArray } + | { kind: 'or'; conditions: NonEmptyArray }; + +// ================================ +// 选择器系统 +// ================================ + +export type SelectorCandidateSource = 'recorded' | 'user' | 'generated'; + +export interface SelectorStability { + /** 稳定性评分 0-1 */ + score: number; + signals?: { + usesId?: boolean; + usesAria?: boolean; + usesText?: boolean; + usesNthOfType?: boolean; + usesAttributes?: boolean; + usesClass?: boolean; + }; + note?: string; +} + +export interface SelectorCandidateBase { + weight?: number; + stability?: SelectorStability; + source?: SelectorCandidateSource; +} + +export type SelectorCandidate = + | (SelectorCandidateBase & { type: 'css'; selector: Resolvable }) + | (SelectorCandidateBase & { type: 'xpath'; xpath: Resolvable }) + | (SelectorCandidateBase & { type: 'attr'; selector: Resolvable }) + | (SelectorCandidateBase & { + type: 'aria'; + role?: Resolvable; + name?: Resolvable; + }) + | (SelectorCandidateBase & { + type: 'text'; + text: Resolvable; + tagNameHint?: string; + match?: 'exact' | 'contains'; + }); + +export type FrameTarget = + | { kind: 'top' } + | { kind: 'index'; index: Resolvable } + | { kind: 'urlContains'; value: Resolvable }; + +export interface TargetHint { + tagName?: string; + role?: string; + name?: string; + text?: string; +} + +export interface ElementTargetBase { + frame?: FrameTarget; + hint?: TargetHint; +} + +export type ElementTarget = + | (ElementTargetBase & { + /** 临时引用(快速路径) */ + ref: string; + candidates?: ReadonlyArray; + }) + | (ElementTargetBase & { + ref?: string; + candidates: NonEmptyArray; + }); + +// ================================ +// Action 参数定义 +// ================================ + +export type BrowserWorld = 'MAIN' | 'ISOLATED'; + +// --- 页面交互 --- + +export interface ClickParams { + target: ElementTarget; + button?: 'left' | 'middle' | 'right'; + before?: { scrollIntoView?: boolean; waitForSelector?: boolean }; + after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean }; +} + +export interface FillParams { + target: ElementTarget; + value: Resolvable; + clearFirst?: boolean; + mode?: 'replace' | 'append'; +} + +export interface KeyParams { + keys: Resolvable; // e.g. "Backspace Enter" or "cmd+a" + target?: ElementTarget; +} + +export type ScrollMode = 'element' | 'offset' | 'container'; + +export interface ScrollOffset { + x?: Resolvable; + y?: Resolvable; +} + +export interface ScrollParams { + mode: ScrollMode; + target?: ElementTarget; + offset?: ScrollOffset; +} + +export interface Point { + x: number; + y: number; +} + +export interface DragParams { + start: ElementTarget; + end: ElementTarget; + path?: ReadonlyArray; +} + +// --- 导航 --- + +export interface NavigateParams { + url: Resolvable; + refresh?: boolean; +} + +// --- 等待和断言 --- + +export type WaitCondition = + | { kind: 'sleep'; sleep: Resolvable } + | { kind: 'navigation' } + | { kind: 'networkIdle'; idleMs?: Resolvable } + | { kind: 'text'; text: Resolvable; appear?: boolean } + | { kind: 'selector'; selector: Resolvable; visible?: boolean }; + +export interface WaitParams { + condition: WaitCondition; +} + +export type Assertion = + | { kind: 'exists'; selector: Resolvable } + | { kind: 'visible'; selector: Resolvable } + | { kind: 'textPresent'; text: Resolvable } + | { + kind: 'attribute'; + selector: Resolvable; + name: Resolvable; + equals?: Resolvable; + matches?: Resolvable; + }; + +export type AssertFailStrategy = 'stop' | 'warn' | 'retry'; + +export interface AssertParams { + assert: Assertion; + failStrategy?: AssertFailStrategy; +} + +// --- 数据和脚本 --- + +export type ExtractParams = + | { + mode: 'selector'; + selector: Resolvable; + attr?: Resolvable; // "text" | "textContent" | attribute name + saveAs: VariableName; + } + | { + mode: 'js'; + code: string; + world?: BrowserWorld; + saveAs: VariableName; + }; + +export type ScriptTiming = 'before' | 'after'; + +export interface ScriptParams { + world?: BrowserWorld; + code: string; + when?: ScriptTiming; + args?: Record>; + saveAs?: VariableName; + assign?: Assignments; +} + +export interface ScreenshotParams { + selector?: Resolvable; + fullPage?: boolean; + saveAs?: VariableName; +} + +// --- HTTP --- + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +export type HttpHeaders = Record>; +export type HttpFormData = Record>; + +export type HttpBody = + | { kind: 'none' } + | { kind: 'text'; text: Resolvable; contentType?: Resolvable } + | { kind: 'json'; json: Resolvable }; + +export type HttpOkStatus = + | { kind: 'range'; min: number; max: number } + | { kind: 'list'; statuses: NonEmptyArray }; + +export interface HttpParams { + method?: HttpMethod; + url: Resolvable; + headers?: HttpHeaders; + body?: HttpBody; + formData?: HttpFormData; + okStatus?: HttpOkStatus; + saveAs?: VariableName; + assign?: Assignments; +} + +// --- DOM 工具 --- + +export interface TriggerEventParams { + target: ElementTarget; + event: Resolvable; + bubbles?: boolean; + cancelable?: boolean; +} + +export interface SetAttributeParams { + target: ElementTarget; + name: Resolvable; + value?: Resolvable; + remove?: boolean; +} + +export interface SwitchFrameParams { + target: FrameTarget; +} + +export interface LoopElementsParams { + selector: Resolvable; + saveAs?: VariableName; + itemVar?: VariableName; + subflowId: SubflowId; +} + +// --- 标签页管理 --- + +export interface OpenTabParams { + url?: Resolvable; + newWindow?: boolean; +} + +export interface SwitchTabParams { + tabId?: number; + urlContains?: Resolvable; + titleContains?: Resolvable; +} + +export interface CloseTabParams { + tabIds?: ReadonlyArray; + url?: Resolvable; +} + +export interface HandleDownloadParams { + filenameContains?: Resolvable; + waitForComplete?: boolean; + saveAs?: VariableName; +} + +// --- 控制流 --- + +export interface ExecuteFlowParams { + flowId: FlowId; + inline?: boolean; + args?: Record>; +} + +export interface ForeachParams { + listVar: VariableName; + itemVar?: VariableName; + subflowId: SubflowId; + concurrency?: number; +} + +export interface WhileParams { + condition: Condition; + subflowId: SubflowId; + maxIterations?: number; +} + +export interface IfBranch { + id: string; + label: EdgeLabel; + condition: Condition; +} + +export type IfParams = + | { + mode: 'binary'; + condition: Condition; + trueLabel?: EdgeLabel; + falseLabel?: EdgeLabel; + } + | { + mode: 'branches'; + branches: NonEmptyArray; + elseLabel?: EdgeLabel; + }; + +export interface DelayParams { + sleep: Resolvable; +} + +// --- 触发器 --- + +export type TriggerUrlRuleKind = 'url' | 'domain' | 'path'; + +export interface TriggerUrlRule { + kind: TriggerUrlRuleKind; + value: Resolvable; +} + +export interface TriggerUrlConfig { + rules?: ReadonlyArray; +} + +export interface TriggerModeConfig { + manual?: boolean; + url?: boolean; + contextMenu?: boolean; + command?: boolean; + dom?: boolean; + schedule?: boolean; +} + +export interface TriggerContextMenuConfig { + title?: Resolvable; + enabled?: boolean; +} + +export interface TriggerCommandConfig { + commandKey?: Resolvable; + enabled?: boolean; +} + +export interface TriggerDomConfig { + selector?: Resolvable; + appear?: boolean; + once?: boolean; + debounceMs?: Milliseconds; + enabled?: boolean; +} + +export type TriggerScheduleType = 'once' | 'interval' | 'daily'; + +export interface TriggerSchedule { + id: string; + type: TriggerScheduleType; + when: Resolvable; // ISO/cron-like string + enabled?: boolean; +} + +export interface TriggerParams { + enabled?: boolean; + description?: Resolvable; + modes?: TriggerModeConfig; + url?: TriggerUrlConfig; + contextMenu?: TriggerContextMenuConfig; + command?: TriggerCommandConfig; + dom?: TriggerDomConfig; + schedules?: ReadonlyArray; +} + +// ================================ +// Action 核心定义 +// ================================ + +/** + * ActionParamsByType 使用 interface 声明 + * 允许外部模块通过声明合并扩展 Action 类型(符合 OCP 原则) + */ +export interface ActionParamsByType { + // UI/构建时 + trigger: TriggerParams; + delay: DelayParams; + + // 页面交互 + click: ClickParams; + dblclick: ClickParams; + fill: FillParams; + key: KeyParams; + scroll: ScrollParams; + drag: DragParams; + + // 同步和验证 + wait: WaitParams; + assert: AssertParams; + + // 数据和脚本 + extract: ExtractParams; + script: ScriptParams; + http: HttpParams; + screenshot: ScreenshotParams; + + // DOM 工具 + triggerEvent: TriggerEventParams; + setAttribute: SetAttributeParams; + + // 帧和循环 + switchFrame: SwitchFrameParams; + loopElements: LoopElementsParams; + + // 控制流 + if: IfParams; + foreach: ForeachParams; + while: WhileParams; + executeFlow: ExecuteFlowParams; + + // 标签页 + navigate: NavigateParams; + openTab: OpenTabParams; + switchTab: SwitchTabParams; + closeTab: CloseTabParams; + handleDownload: HandleDownloadParams; +} + +export type ActionType = keyof ActionParamsByType; + +export interface ActionBase { + id: ActionId; + type: T; + name?: string; + disabled?: boolean; + tags?: ReadonlyArray; + policy?: ActionPolicy; + ui?: { x: number; y: number }; +} + +export type Action = ActionBase & { + params: ActionParamsByType[T]; +}; + +export type AnyAction = { [T in ActionType]: Action }[ActionType]; + +export type ExecutableActionType = Exclude; +export type ExecutableAction = Action; + +// ================================ +// Action 输出 +// ================================ + +export interface HttpResponse { + url: string; + status: number; + headers?: Record; + body?: JsonValue | string | null; +} + +export type DownloadState = 'in_progress' | 'complete' | 'interrupted' | 'canceled'; + +export interface DownloadInfo { + id: string; + filename: string; + url?: string; + state?: DownloadState; + size?: number; +} + +/** + * Action 输出类型映射(可通过声明合并扩展) + */ +export interface ActionOutputsByType { + screenshot: { base64Data: string }; + extract: { value: JsonValue }; + script: { result: JsonValue }; + http: { response: HttpResponse }; + handleDownload: { download: DownloadInfo }; + loopElements: { elements: string[] }; +} + +export type ActionOutput = T extends keyof ActionOutputsByType + ? ActionOutputsByType[T] + : undefined; + +// ================================ +// 执行接口 +// ================================ + +export type ValidationResult = { ok: true } | { ok: false; errors: NonEmptyArray }; + +/** + * Execution flags for coordinating with orchestrator policies. + * Used to avoid duplicate retry/nav-wait when StepRunner owns these policies. + */ +export interface ExecutionFlags { + /** + * When true, navigation waiting should be handled by StepRunner. + * Action handlers (click, navigate) should skip their internal nav-wait logic. + */ + skipNavWait?: boolean; +} + +export interface ActionExecutionContext { + vars: VariableStore; + tabId: number; + frameId?: number; + runId?: string; + /** 日志记录函数 */ + log: (message: string, level?: 'info' | 'warn' | 'error') => void; + /** 截图函数 */ + captureScreenshot?: () => Promise; + /** + * Optional structured log sink for replay UIs (legacy RunLogger integration). + * Action handlers may emit richer entries (e.g. selector fallback) via this hook. + */ + pushLog?: (entry: unknown) => void; + /** + * Execution flags provided by the orchestrator. + * Handlers should respect these flags to avoid duplicating StepRunner policies. + */ + execution?: ExecutionFlags; +} + +export type ControlDirective = + | { + kind: 'foreach'; + listVar: VariableName; + itemVar: VariableName; + subflowId: SubflowId; + concurrency?: number; + } + | { + kind: 'while'; + condition: Condition; + subflowId: SubflowId; + maxIterations: number; + }; + +export interface ActionExecutionResult { + status: 'success' | 'failed' | 'skipped' | 'paused'; + output?: ActionOutput; + error?: ActionError; + /** 下一个边的 label(用于条件分支) */ + nextLabel?: EdgeLabel; + /** 控制流指令(foreach/while) */ + control?: ControlDirective; + /** 执行耗时 */ + durationMs?: Milliseconds; + /** + * New tab ID after tab operations (openTab/switchTab). + * Used to update execution context for subsequent steps. + */ + newTabId?: number; +} + +/** + * Action 执行器接口 + */ +export interface ActionHandler { + type: T; + /** 验证 action 配置 */ + validate?: (action: Action) => ValidationResult; + /** 执行 action */ + run: (ctx: ActionExecutionContext, action: Action) => Promise>; + /** 生成 action 描述(用于 UI 显示) */ + describe?: (action: Action) => string; +} + +// ================================ +// Flow 图结构 +// ================================ + +export interface ActionEdge { + id: EdgeId; + from: ActionId; + to: ActionId; + label?: EdgeLabel; +} + +export interface FlowBinding { + type: 'domain' | 'path' | 'url'; + value: string; +} + +export interface FlowMeta { + createdAt: ISODateTimeString; + updatedAt: ISODateTimeString; + domain?: string; + tags?: ReadonlyArray; + bindings?: ReadonlyArray; + tool?: { category?: string; description?: string }; + exposedOutputs?: ReadonlyArray<{ nodeId: ActionId; as: VariableName }>; +} + +export interface Flow { + id: FlowId; + name: string; + description?: string; + version: number; + meta: FlowMeta; + variables?: ReadonlyArray; + + /** DAG 节点 */ + nodes: ReadonlyArray; + /** DAG 边 */ + edges: ReadonlyArray; + /** 子流程(用于 foreach/while/loopElements) */ + subflows?: Record< + SubflowId, + { nodes: ReadonlyArray; edges: ReadonlyArray } + >; +} + +// ================================ +// Action 规格(用于 UI) +// ================================ + +export type ActionCategory = 'Flow' | 'Actions' | 'Logic' | 'Tools' | 'Tabs' | 'Page'; + +export interface ActionSpecDisplay { + label: string; + description?: string; + category: ActionCategory; + icon?: string; + docUrl?: string; +} + +export interface ActionSpecPorts { + inputs: number | 'any'; + outputs: Array<{ label?: EdgeLabel }> | 'any'; + maxConnection?: number; + allowedInputs?: boolean; +} + +export interface ActionSpec { + type: T; + version: number; + display: ActionSpecDisplay; + ports: ActionSpecPorts; + defaults?: Partial; + /** 需要进行模板替换的字段路径 */ + refDataKeys?: ReadonlyArray; +} + +// ================================ +// 常量导出 +// ================================ + +export const ACTION_TYPES: ReadonlyArray = [ + 'trigger', + 'delay', + 'click', + 'dblclick', + 'fill', + 'key', + 'scroll', + 'drag', + 'wait', + 'assert', + 'extract', + 'script', + 'http', + 'screenshot', + 'triggerEvent', + 'setAttribute', + 'switchFrame', + 'loopElements', + 'if', + 'foreach', + 'while', + 'executeFlow', + 'navigate', + 'openTab', + 'switchTab', + 'closeTab', + 'handleDownload', +] as const; + +export const EXECUTABLE_ACTION_TYPES: ReadonlyArray = ACTION_TYPES.filter( + (t): t is ExecutableActionType => t !== 'trigger', +); diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts new file mode 100644 index 0000000..c30e462 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts @@ -0,0 +1,31 @@ +// constants.ts — centralized engine constants and labels +import { EDGE_LABELS } from 'chrome-mcp-shared'; + +export const ENGINE_CONSTANTS = { + DEFAULT_WAIT_MS: 5000, + MAX_WAIT_MS: 120000, + NETWORK_IDLE_SAMPLE_MS: 1200, + MAX_ITERATIONS: 1000, + MAX_FOREACH_CONCURRENCY: 16, + EDGE_LABELS: EDGE_LABELS, +} as const; + +export type EdgeLabel = + (typeof ENGINE_CONSTANTS.EDGE_LABELS)[keyof typeof ENGINE_CONSTANTS.EDGE_LABELS]; + +// Centralized stepId values used in run logs for non-step events +export const LOG_STEP_IDS = { + GLOBAL_TIMEOUT: 'global-timeout', + PLUGIN_RUN_START: 'plugin-runStart', + VARIABLE_COLLECT: 'variable-collect', + BINDING_CHECK: 'binding-check', + NETWORK_CAPTURE: 'network-capture', + DAG_REQUIRED: 'dag-required', + DAG_CYCLE: 'dag-cycle', + LOOP_GUARD: 'loop-guard', + PLUGIN_RUN_END: 'plugin-runEnd', + RUNSTATE_UPDATE: 'runState-update', + RUNSTATE_DELETE: 'runState-delete', +} as const; + +export type LogStepId = (typeof LOG_STEP_IDS)[keyof typeof LOG_STEP_IDS]; diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts new file mode 100644 index 0000000..2d7d9ef --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts @@ -0,0 +1,237 @@ +/** + * Execution Mode Configuration + * + * Controls whether step execution uses the legacy node system or the new ActionRegistry. + * Provides a migration path from legacy to actions with hybrid mode for gradual rollout. + * + * Modes: + * - 'legacy': Use the existing executeStep from nodes/index.ts (default, safest) + * - 'actions': Use ActionRegistry exclusively (strict mode, throws on unsupported) + * - 'hybrid': Try ActionRegistry first, fall back to legacy for unsupported types + */ + +import type { Step } from '../types'; + +/** + * Execution mode determines how steps are executed + */ +export type ExecutionMode = 'legacy' | 'actions' | 'hybrid'; + +/** + * Configuration for execution mode + */ +export interface ExecutionModeConfig { + /** + * The execution mode to use + * @default 'legacy' + */ + mode: ExecutionMode; + + /** + * Step types that should always use legacy execution (denylist for actions) + * Only applies in hybrid mode + */ + legacyOnlyTypes?: Set; + + /** + * Step types that should use actions execution (allowlist) + * Only applies in hybrid mode. + * - If undefined: uses MINIMAL_HYBRID_ACTION_TYPES (safest default) + * - If empty Set (size=0): falls back to MIGRATED_ACTION_TYPES policy + * - If non-empty Set: only these types use actions + */ + actionsAllowlist?: Set; + + /** + * Whether to log when falling back from actions to legacy in hybrid mode + * @default true + */ + logFallbacks?: boolean; + + /** + * Skip ActionRegistry's built-in retry policy. + * When true, action.policy.retry is removed before execution. + * @default true - StepRunner already handles retry via withRetry() + * + * Note: ActionRegistry timeout is NOT disabled (provides per-action timeout safety). + */ + skipActionsRetry?: boolean; + + /** + * Skip ActionRegistry's navigation waiting when StepRunner handles it + * @default true - StepRunner already handles navigation waiting + */ + skipActionsNavWait?: boolean; +} + +/** + * Default execution mode configuration + * Starts with legacy mode for maximum safety during migration + */ +export const DEFAULT_EXECUTION_MODE_CONFIG: ExecutionModeConfig = { + mode: 'legacy', + logFallbacks: true, + skipActionsRetry: true, + skipActionsNavWait: true, +}; + +/** + * Minimal allowlist for initial hybrid rollout. + * + * This keeps high-risk step types (navigation/click/tab management) on legacy + * until policy (retry/timeout/nav-wait) and tab cursor semantics are unified. + * + * These types are chosen for their low risk: + * - No navigation side effects + * - No tab management + * - No complex timing requirements + * - Simple input/output semantics + */ +export const MINIMAL_HYBRID_ACTION_TYPES = new Set([ + 'fill', // Form input - no navigation + 'key', // Keyboard input - no navigation + 'scroll', // Viewport manipulation - no navigation + 'drag', // Drag and drop - local operation + 'wait', // Condition waiting - no side effects + 'delay', // Simple delay - no side effects + 'screenshot', // Capture only - no side effects + 'assert', // Validation only - no side effects +]); + +/** + * Step types that are fully migrated and tested with ActionRegistry + * These are safe to run in actions mode + * + * NOTE: Start conservative and expand gradually as testing confirms equivalence. + * Types NOT included here will fall back to legacy in hybrid mode. + * + * Criteria for inclusion: + * 1. Handler implementation matches legacy behavior exactly + * 2. Step data structure is compatible (no complex transformation needed) + * 3. No timing-sensitive dependencies (like script when:'after' defer) + */ +export const MIGRATED_ACTION_TYPES = new Set([ + // Navigation - well tested, simple mapping + 'navigate', + // Interaction - well tested, core functionality + 'click', + 'dblclick', + 'fill', + 'key', + 'scroll', + 'drag', + // Timing - simple logic, no complex state + 'wait', + 'delay', + // Screenshot - simple, no side effects + 'screenshot', + // Assert - validation only, no state changes + 'assert', +]); + +/** + * Step types that need more validation before migration + * These are supported by ActionRegistry but may have behavior differences + */ +export const NEEDS_VALIDATION_TYPES = new Set([ + // Data extraction - need to verify selector/js mode equivalence + 'extract', + // HTTP - body type handling may differ + 'http', + // Script - when:'after' defer semantics differ from legacy + 'script', + // Tabs - tabId tracking needs careful integration + 'openTab', + 'switchTab', + 'closeTab', + 'handleDownload', + // Control flow - condition evaluation may differ + 'if', + 'foreach', + 'while', + 'switchFrame', +]); + +/** + * Step types that must use legacy execution + * These have complex integration requirements not yet supported by ActionRegistry + */ +export const LEGACY_ONLY_TYPES = new Set([ + // Complex legacy types not yet migrated + 'triggerEvent', + 'setAttribute', + 'loopElements', + 'executeFlow', +]); + +/** + * Determine whether a step should use actions execution based on config + */ +export function shouldUseActions(step: Step, config: ExecutionModeConfig): boolean { + if (config.mode === 'legacy') { + return false; + } + + if (config.mode === 'actions') { + return true; + } + + // Hybrid mode: check allowlist/denylist + const stepType = step.type; + + // Denylist takes precedence + if (config.legacyOnlyTypes?.has(stepType)) { + return false; + } + + // If allowlist is specified and non-empty, step must be in it + if (config.actionsAllowlist && config.actionsAllowlist.size > 0) { + return config.actionsAllowlist.has(stepType); + } + + // Default to using actions for supported types + return MIGRATED_ACTION_TYPES.has(stepType); +} + +/** + * Create a hybrid execution mode config for gradual migration. + * + * By default uses MINIMAL_HYBRID_ACTION_TYPES as allowlist, which excludes + * high-risk types (navigate/click/tab management) from actions execution. + * + * @param overrides - Optional overrides for the config + * @param overrides.actionsAllowlist - Set of step types to execute via actions. + * If provided with size > 0, only these types use actions. + * If empty Set, falls back to MIGRATED_ACTION_TYPES. + * If undefined, uses MINIMAL_HYBRID_ACTION_TYPES (safest default). + */ +export function createHybridConfig(overrides?: Partial): ExecutionModeConfig { + return { + ...DEFAULT_EXECUTION_MODE_CONFIG, + mode: 'hybrid', + legacyOnlyTypes: new Set(LEGACY_ONLY_TYPES), + actionsAllowlist: new Set(MINIMAL_HYBRID_ACTION_TYPES), + ...overrides, + }; +} + +/** + * Create a strict actions mode config for testing. + * All steps must be handled by ActionRegistry or throw. + * + * Note: Even in actions mode, StepRunner remains the policy authority for + * retry/nav-wait. This ensures consistent behavior across all execution modes + * and avoids double-strategy issues. + */ +export function createActionsOnlyConfig( + overrides?: Partial, +): ExecutionModeConfig { + return { + ...DEFAULT_EXECUTION_MODE_CONFIG, + mode: 'actions', + // Keep StepRunner as policy authority - skip ActionRegistry's internal policies + skipActionsRetry: true, + skipActionsNavWait: true, + ...overrides, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/logging/run-logger.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/logging/run-logger.ts new file mode 100644 index 0000000..419799b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/logging/run-logger.ts @@ -0,0 +1,69 @@ +// engine/logging/run-logger.ts — run logs, overlay and persistence +import type { RunLogEntry, RunRecord, Flow } from '../../types'; +import { appendRun } from '../../flow-store'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; + +export class RunLogger { + private logs: RunLogEntry[] = []; + constructor(private runId: string) {} + + push(e: RunLogEntry) { + this.logs.push(e); + } + + getLogs() { + return this.logs; + } + + async overlayInit() { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]?.id) + await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'init' } as any); + } catch {} + } + + async overlayAppend(text: string) { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]?.id) + await chrome.tabs.sendMessage(tabs[0].id, { + action: 'rr_overlay', + cmd: 'append', + text, + } as any); + } catch {} + } + + async overlayDone() { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]?.id) + await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'done' } as any); + } catch {} + } + + async screenshotOnFailure() { + try { + const shot = await handleCallTool({ + name: TOOL_NAMES.BROWSER.COMPUTER, + args: { action: 'screenshot' }, + }); + const img = (shot?.content?.find((c: any) => c.type === 'image') as any)?.data as string; + if (img) this.logs[this.logs.length - 1].screenshotBase64 = img; + } catch {} + } + + async persist(flow: Flow, startedAt: number, success: boolean) { + const record: RunRecord = { + id: this.runId, + flowId: flow.id, + startedAt: new Date(startedAt).toISOString(), + finishedAt: new Date().toISOString(), + success, + entries: this.logs, + }; + await appendRun(record); + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/breakpoint.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/breakpoint.ts new file mode 100644 index 0000000..0d668a1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/breakpoint.ts @@ -0,0 +1,19 @@ +import type { RunPlugin, StepContext } from './types'; +import { runState } from '../state-manager'; + +export function breakpointPlugin(): RunPlugin { + return { + name: 'breakpoint', + async onBeforeStep(ctx: StepContext) { + try { + const step: any = ctx.step as any; + const hasBreakpoint = step?.$breakpoint === true || step?.breakpoint === true; + if (!hasBreakpoint) return; + // mark run paused for external UI to resume + await runState.update(ctx.runId, { status: 'stopped', updatedAt: Date.now() } as any); + return { pause: true }; + } catch {} + return; + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/manager.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/manager.ts new file mode 100644 index 0000000..509a463 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/manager.ts @@ -0,0 +1,74 @@ +import type { + RunPlugin, + HookControl, + RunContext, + StepContext, + StepAfterContext, + StepErrorContext, + StepRetryContext, + RunEndContext, + SubflowContext, +} from './types'; + +export class PluginManager { + constructor(private plugins: RunPlugin[]) {} + + async runStart(ctx: RunContext) { + for (const p of this.plugins) await safeCall(p, 'onRunStart', ctx); + } + + async beforeStep(ctx: StepContext): Promise { + for (const p of this.plugins) { + const out = await safeCall(p, 'onBeforeStep', ctx); + if (out && (out.pause || out.nextLabel)) return out; + } + return undefined; + } + + async afterStep(ctx: StepAfterContext) { + for (const p of this.plugins) await safeCall(p, 'onAfterStep', ctx); + } + + async onError(ctx: StepErrorContext): Promise { + for (const p of this.plugins) { + const out = await safeCall(p, 'onStepError', ctx); + if (out && (out.pause || out.nextLabel)) return out; + } + return undefined; + } + + async onRetry(ctx: StepRetryContext) { + for (const p of this.plugins) await safeCall(p, 'onRetry', ctx); + } + + async onChooseNextLabel(ctx: StepContext & { suggested?: string }): Promise { + for (const p of this.plugins) { + const out = await safeCall(p, 'onChooseNextLabel', ctx); + if (out && out.nextLabel) return String(out.nextLabel); + } + return undefined; + } + + async subflowStart(ctx: SubflowContext) { + for (const p of this.plugins) await safeCall(p, 'onSubflowStart', ctx); + } + + async subflowEnd(ctx: SubflowContext) { + for (const p of this.plugins) await safeCall(p, 'onSubflowEnd', ctx); + } + + async runEnd(ctx: RunEndContext) { + for (const p of this.plugins) await safeCall(p, 'onRunEnd', ctx); + } +} + +async function safeCall(plugin: RunPlugin, key: T, arg: any) { + try { + const fn = plugin[key] as any; + if (typeof fn === 'function') return await fn.call(plugin, arg); + } catch (e) { + // swallow plugin errors to keep core stable + // console.warn(`[plugin:${plugin.name}] ${String(key)} error:`, e); + } + return undefined; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/types.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/types.ts new file mode 100644 index 0000000..b27cac4 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/plugins/types.ts @@ -0,0 +1,56 @@ +// Plugin system for record-replay engine +// Inspired by webpack-like lifecycle hooks, to avoid touching core for extensibility + +import type { Flow, Step } from '../../types'; +import type { ExecResult } from '../../nodes'; + +export interface RunContext { + runId: string; + flow: Flow; + vars: Record; +} + +export interface StepContext extends RunContext { + step: Step; +} + +export interface StepErrorContext extends StepContext { + error: any; +} + +export interface StepRetryContext extends StepErrorContext { + attempt: number; +} + +export interface StepAfterContext extends StepContext { + result?: ExecResult; +} + +export interface SubflowContext extends RunContext { + subflowId: string; +} + +export interface RunEndContext extends RunContext { + success: boolean; + failed: number; +} + +export interface HookControl { + pause?: boolean; // request scheduler to pause run (e.g., breakpoint) + nextLabel?: string; // override next edge label +} + +export interface RunPlugin { + name: string; + onRunStart?(ctx: RunContext): Promise | void; + onBeforeStep?(ctx: StepContext): Promise | HookControl | void; + onAfterStep?(ctx: StepAfterContext): Promise | void; + onStepError?(ctx: StepErrorContext): Promise | HookControl | void; + onRetry?(ctx: StepRetryContext): Promise | void; + onChooseNextLabel?( + ctx: StepContext & { suggested?: string }, + ): Promise | HookControl | void; + onSubflowStart?(ctx: SubflowContext): Promise | void; + onSubflowEnd?(ctx: SubflowContext): Promise | void; + onRunEnd?(ctx: RunEndContext): Promise | void; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/policies/retry.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/policies/retry.ts new file mode 100644 index 0000000..3b0cb4a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/policies/retry.ts @@ -0,0 +1,31 @@ +// engine/policies/retry.ts — unified retry/backoff policy + +export type BackoffKind = 'none' | 'exp'; + +export interface RetryOptions { + count?: number; // max attempts beyond the first run + intervalMs?: number; + backoff?: BackoffKind; +} + +export async function withRetry( + run: () => Promise, + onRetry?: (attempt: number, err: any) => Promise | void, + opts?: RetryOptions, +): Promise { + const max = Math.max(0, Number(opts?.count ?? 0)); + const base = Math.max(0, Number(opts?.intervalMs ?? 0)); + const backoff = (opts?.backoff || 'none') as BackoffKind; + let attempt = 0; + while (true) { + try { + return await run(); + } catch (e) { + if (attempt >= max) throw e; + if (onRetry) await onRetry(attempt, e); + const delay = base > 0 ? (backoff === 'exp' ? base * Math.pow(2, attempt) : base) : 0; + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + attempt += 1; + } + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts new file mode 100644 index 0000000..67f26fe --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts @@ -0,0 +1,94 @@ +// engine/policies/wait.ts — wrappers around rr-utils navigation/network waits +// Keep logic centralized to avoid duplication in schedulers and nodes + +import { handleCallTool } from '@/entrypoints/background/tools'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { waitForNavigation as rrWaitForNavigation, waitForNetworkIdle } from '../../rr-utils'; + +export async function waitForNavigationDone(prevUrl: string, timeoutMs?: number) { + await rrWaitForNavigation(timeoutMs, prevUrl); +} + +export async function ensureReadPageIfWeb() { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = tabs?.[0]?.url || ''; + if (/^(https?:|file:)/i.test(url)) { + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + } + } catch {} +} + +export async function maybeQuickWaitForNav(prevUrl: string, timeoutMs?: number) { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') return; + const sniffMs = 350; + const startedAt = Date.now(); + let seen = false; + await new Promise((resolve) => { + let timer: any = null; + const cleanup = () => { + try { + chrome.webNavigation.onCommitted.removeListener(onCommitted); + } catch {} + try { + chrome.webNavigation.onCompleted.removeListener(onCompleted); + } catch {} + try { + (chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.( + onHistoryStateUpdated, + ); + } catch {} + try { + chrome.tabs.onUpdated.removeListener(onUpdated); + } catch {} + if (timer) { + try { + clearTimeout(timer); + } catch {} + } + }; + const finish = async () => { + cleanup(); + if (seen) { + try { + await rrWaitForNavigation( + prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined, + prevUrl, + ); + } catch {} + } + resolve(); + }; + const mark = () => { + seen = true; + }; + const onCommitted = (d: any) => { + if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark(); + }; + const onCompleted = (d: any) => { + if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark(); + }; + const onHistoryStateUpdated = (d: any) => { + if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark(); + }; + const onUpdated = (updatedId: number, change: chrome.tabs.TabChangeInfo) => { + if (updatedId !== tabId) return; + if (change.status === 'loading') mark(); + if (typeof change.url === 'string' && (!prevUrl || change.url !== prevUrl)) mark(); + }; + + chrome.webNavigation.onCommitted.addListener(onCommitted); + chrome.webNavigation.onCompleted.addListener(onCompleted); + try { + (chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated); + } catch {} + chrome.tabs.onUpdated.addListener(onUpdated); + timer = setTimeout(finish, sniffMs); + }); + } catch {} +} + +export { waitForNetworkIdle }; diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts new file mode 100644 index 0000000..f7f247b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts @@ -0,0 +1,88 @@ +// after-script-queue.ts — queue + executor for deferred after-scripts +// Notes: +// - Executes user-provided code in the specified world (ISOLATED by default) +// - Clears queue before execution to avoid leaks; re-queues remainder on failure +// - Logs warnings instead of throwing to keep the main engine resilient + +import type { StepScript } from '../../types'; +import type { ExecCtx } from '../../nodes'; +import { RunLogger } from '../logging/run-logger'; +import { applyAssign } from '../../rr-utils'; + +export class AfterScriptQueue { + private queue: StepScript[] = []; + + constructor(private logger: RunLogger) {} + + enqueue(script: StepScript) { + this.queue.push(script); + } + + size() { + return this.queue.length; + } + + async flush(ctx: ExecCtx, vars: Record) { + if (this.queue.length === 0) return; + const scriptsToFlush = this.queue.splice(0, this.queue.length); + for (let i = 0; i < scriptsToFlush.length; i++) { + const s = scriptsToFlush[i]!; + const tScript = Date.now(); + const world = (s as any).world || 'ISOLATED'; + const code = String((s as any).code || ''); + if (!code.trim()) { + this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript }); + continue; + } + try { + // Warn on obviously dangerous constructs; not a sandbox, just visibility. + const dangerous = + /[;{}]|\b(function|=>|while|for|class|globalThis|window|self|this|constructor|__proto__|prototype|eval|Function|import|require|XMLHttpRequest|fetch|chrome)\b/; + if (dangerous.test(code)) { + this.logger.push({ + stepId: s.id, + status: 'warning', + message: 'Script contains potentially unsafe tokens; executed in isolated world', + }); + } + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: (userCode: string) => { + try { + return (0, eval)(userCode); + } catch (e) { + return { __error: true, message: String(e) } as any; + } + }, + args: [code], + world: world as any, + } as any); + if ((result as any)?.__error) { + this.logger.push({ + stepId: s.id, + status: 'warning', + message: `After-script error: ${(result as any).message || 'unknown'}`, + }); + } + const value = (result as any)?.__error ? null : result; + if ((s as any).saveAs) (vars as any)[(s as any).saveAs] = value; + if ((s as any).assign && typeof (s as any).assign === 'object') + applyAssign(vars, value, (s as any).assign); + } catch (e: any) { + // Re-queue remaining and stop flush cycle for now + const remaining = scriptsToFlush.slice(i + 1); + if (remaining.length) this.queue.unshift(...remaining); + this.logger.push({ + stepId: s.id, + status: 'warning', + message: `After-script execution failed: ${e?.message || String(e)}`, + }); + break; + } + this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript }); + } + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/control-flow-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/control-flow-runner.ts new file mode 100644 index 0000000..3d798cb --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/control-flow-runner.ts @@ -0,0 +1,59 @@ +// control-flow-runner.ts — foreach / while orchestration + +import type { ExecCtx } from '../../nodes'; +import { RunLogger } from '../logging/run-logger'; + +export interface ControlFlowEnv { + vars: Record; + logger: RunLogger; + evalCondition: (cond: any) => boolean; + runSubflowById: (subflowId: string, ctx: ExecCtx) => Promise; + isPaused: () => boolean; +} + +export class ControlFlowRunner { + constructor(private env: ControlFlowEnv) {} + + async run(control: any, ctx: ExecCtx): Promise<'ok' | 'paused'> { + if (control?.kind === 'foreach') { + const list = Array.isArray(this.env.vars[control.listVar]) + ? (this.env.vars[control.listVar] as any[]) + : []; + const concurrency = Math.max(1, Math.min(16, Number(control.concurrency ?? 1))); + if (concurrency <= 1) { + for (const it of list) { + this.env.vars[control.itemVar] = it; + await this.env.runSubflowById(control.subflowId, ctx); + if (this.env.isPaused()) return 'paused'; + } + return this.env.isPaused() ? 'paused' : 'ok'; + } + // Parallel with shallow-cloned vars per task (no automatic merge) + let idx = 0; + const runOne = async () => { + while (idx < list.length) { + const cur = idx++; + const it = list[cur]; + const childCtx: ExecCtx = { ...ctx, vars: { ...this.env.vars } }; + childCtx.vars[control.itemVar] = it; + await this.env.runSubflowById(control.subflowId, childCtx); + if (this.env.isPaused()) return; + } + }; + const workers = Array.from({ length: Math.min(concurrency, list.length) }, () => runOne()); + await Promise.all(workers); + return this.env.isPaused() ? 'paused' : 'ok'; + } + if (control?.kind === 'while') { + let i = 0; + while (i < control.maxIterations && this.env.evalCondition(control.condition)) { + await this.env.runSubflowById(control.subflowId, ctx); + if (this.env.isPaused()) return 'paused'; + i++; + } + return this.env.isPaused() ? 'paused' : 'ok'; + } + // Unknown control type → no-op + return 'ok'; + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts new file mode 100644 index 0000000..49e727e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts @@ -0,0 +1,256 @@ +/** + * Step Executor Interface + * + * Provides a unified interface for step execution that supports multiple execution modes. + * This abstraction allows seamless switching between legacy and actions execution. + * + * Architecture: + * - StepExecutorInterface: Base interface for all executors + * - LegacyStepExecutor: Uses the existing executeStep from nodes/ + * - ActionsStepExecutor: Uses ActionRegistry from actions/ + * - HybridStepExecutor: Tries actions first, falls back to legacy + */ + +import type { Step } from '../../types'; +import type { ExecCtx, ExecResult } from '../../nodes/types'; +import { executeStep as legacyExecuteStep } from '../../nodes'; +import type { ActionRegistry } from '../../actions/registry'; +import { + createStepExecutor, + isActionSupported, + type StepExecutionAttempt, +} from '../../actions/adapter'; +import type { ExecutionModeConfig } from '../execution-mode'; +import { shouldUseActions } from '../execution-mode'; + +/** + * Step execution result with additional metadata + */ +export interface StepExecutionResult { + /** The execution result from the step */ + result: ExecResult; + /** Which executor was used */ + executor: 'legacy' | 'actions'; + /** Whether fallback was used (only in hybrid mode) */ + fallback?: boolean; + /** Reason for fallback (only when fallback=true) */ + fallbackReason?: string; +} + +/** + * Options for step execution + */ +export interface StepExecutionOptions { + /** Current tab ID */ + tabId: number; + /** Run ID for logging/tracing */ + runId?: string; + /** Logger for recording fallback information */ + pushLog?: (entry: unknown) => void; + /** Remaining time budget from global deadline */ + remainingBudgetMs?: number; +} + +/** + * Base interface for step executors + */ +export interface StepExecutorInterface { + /** + * Execute a single step + */ + execute(ctx: ExecCtx, step: Step, options: StepExecutionOptions): Promise; + + /** + * Check if executor supports a step type + */ + supports(stepType: string): boolean; +} + +/** + * Legacy step executor using nodes/executeStep + * + * This executor delegates to the existing node execution system. + * The options parameter is accepted but not used - retry/timeout/navigation + * waiting are handled by StepRunner to maintain existing behavior. + */ +export class LegacyStepExecutor implements StepExecutorInterface { + async execute( + ctx: ExecCtx, + step: Step, + _options: StepExecutionOptions, + ): Promise { + // Note: tabId from options is not used here because legacy executeStep + // queries the active tab internally. In hybrid/actions mode, tabId is + // passed through to ActionRegistry handlers. + const result = await legacyExecuteStep(ctx, step); + return { + result: result || {}, + executor: 'legacy', + }; + } + + supports(_stepType: string): boolean { + // Legacy executor supports all step types via its own registry + return true; + } +} + +/** + * Actions step executor using ActionRegistry + * + * In strict mode, any unsupported step type throws an error. + * This executor does NOT fall back to legacy - use HybridStepExecutor for fallback behavior. + * + * Respects ExecutionModeConfig for: + * - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry) + * - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait) + */ +export class ActionsStepExecutor implements StepExecutorInterface { + private executor: ReturnType; + + constructor( + private registry: ActionRegistry, + private config: ExecutionModeConfig, + ) { + this.executor = createStepExecutor(registry); + } + + async execute( + ctx: ExecCtx, + step: Step, + options: StepExecutionOptions, + ): Promise { + // Use strict=true: throws on unsupported types instead of returning { supported: false } + // This ensures all steps must be handled by ActionRegistry in actions-only mode + const attempt = (await this.executor(ctx, step, options.tabId, { + runId: options.runId, + pushLog: options.pushLog, + strict: true, + // Pass policy skip flags from config (default to true = skip) + skipRetry: this.config.skipActionsRetry !== false, + skipNavWait: this.config.skipActionsNavWait !== false, + })) as StepExecutionAttempt; + + // With strict=true, we should never get { supported: false } - it would throw instead + // This check exists for type safety and defensive programming + if (!attempt.supported) { + throw new Error(attempt.reason); + } + + return { + result: attempt.result, + executor: 'actions', + }; + } + + supports(stepType: string): boolean { + // Use adapter's type guard to check if step type is supported + return isActionSupported(stepType); + } +} + +/** + * Hybrid step executor that tries actions first, falls back to legacy + * + * Respects ExecutionModeConfig for: + * - actionsAllowlist/legacyOnlyTypes: Controls which steps use actions vs legacy + * - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry) + * - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait) + * - logFallbacks: Whether to log when falling back to legacy + */ +export class HybridStepExecutor implements StepExecutorInterface { + private actionsExecutor: ReturnType; + + constructor( + private registry: ActionRegistry, + private config: ExecutionModeConfig, + ) { + this.actionsExecutor = createStepExecutor(registry); + } + + async execute( + ctx: ExecCtx, + step: Step, + options: StepExecutionOptions, + ): Promise { + // Check if step should use actions based on config + if (!shouldUseActions(step, this.config)) { + // Use legacy directly + const result = await legacyExecuteStep(ctx, step); + return { + result: result || {}, + executor: 'legacy', + }; + } + + // Try actions first + const attempt = (await this.actionsExecutor(ctx, step, options.tabId, { + runId: options.runId, + pushLog: options.pushLog, + strict: false, // Don't throw on unsupported, return { supported: false } + // Pass policy skip flags from config (default to true = skip) + skipRetry: this.config.skipActionsRetry !== false, + skipNavWait: this.config.skipActionsNavWait !== false, + })) as StepExecutionAttempt; + + if (attempt.supported) { + return { + result: attempt.result, + executor: 'actions', + }; + } + + // Fall back to legacy + if (this.config.logFallbacks) { + options.pushLog?.({ + stepId: step.id, + status: 'warning', + message: `Falling back to legacy execution: ${attempt.reason}`, + }); + } + + const legacyResult = await legacyExecuteStep(ctx, step); + return { + result: legacyResult || {}, + executor: 'legacy', + fallback: true, + fallbackReason: attempt.reason, + }; + } + + supports(stepType: string): boolean { + // Hybrid executor supports all types (via fallback) + return true; + } +} + +/** + * Factory function to create the appropriate executor based on config + */ +export function createExecutor( + config: ExecutionModeConfig, + registry?: ActionRegistry, +): StepExecutorInterface { + switch (config.mode) { + case 'legacy': + return new LegacyStepExecutor(); + + case 'actions': + if (!registry) { + throw new Error('ActionRegistry required for actions execution mode'); + } + return new ActionsStepExecutor(registry, config); + + case 'hybrid': + if (!registry) { + throw new Error('ActionRegistry required for hybrid execution mode'); + } + return new HybridStepExecutor(registry, config); + + default: { + // TypeScript exhaustiveness check + const _exhaustive: never = config.mode; + throw new Error(`Unknown execution mode: ${_exhaustive}`); + } + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts new file mode 100644 index 0000000..6981091 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts @@ -0,0 +1,238 @@ +/** + * step-runner.ts + * + * Encapsulates execution of a single step with policies (retry, navigation wait) and plugins. + * Uses dependency-injected StepExecutorInterface for actual step execution, enabling + * seamless switching between legacy and ActionRegistry execution modes. + */ + +import type { Flow, Step, StepClick } from '../../types'; +import { STEP_TYPES } from 'chrome-mcp-shared'; +import type { ExecCtx, ExecResult } from '../../nodes'; +import { RunLogger } from '../logging/run-logger'; +import { withRetry } from '../policies/retry'; +import { + waitForNavigationDone, + maybeQuickWaitForNav, + ensureReadPageIfWeb, + waitForNetworkIdle, +} from '../policies/wait'; +import { ENGINE_CONSTANTS } from '../constants'; +import { AfterScriptQueue } from './after-script-queue'; +import { PluginManager } from '../plugins/manager'; +import type { HookControl } from '../plugins/types'; +import type { StepExecutorInterface } from './step-executor'; + +// Narrow error-like value used for overlay reporting +interface ErrorLike { + message?: string; +} + +function errorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + if (e && typeof e === 'object' && 'message' in e) return String((e as any).message); + return String(e); +} + +/** + * Environment dependencies for StepRunner. + * Injected by Scheduler to allow flexible configuration and testing. + */ +export interface StepRunEnv { + /** Unique identifier for this run */ + runId: string; + /** The flow being executed */ + flow: Flow; + /** Runtime variables */ + vars: Record; + /** Run logger for recording execution events */ + logger: RunLogger; + /** Plugin manager for hooks (beforeStep, afterStep, onRetry, onError) */ + pluginManager: PluginManager; + /** Queue for deferred after-scripts */ + afterScripts: AfterScriptQueue; + /** Returns remaining time budget from global deadline (ms), Infinity if no deadline */ + getRemainingBudgetMs: () => number; + /** + * Step executor for actual step execution. + * Defaults to LegacyStepExecutor if not provided (for backwards compatibility). + * In future, Scheduler will inject ActionsStepExecutor or HybridStepExecutor. + */ + stepExecutor: StepExecutorInterface; +} + +export class StepRunner { + constructor(private env: StepRunEnv) {} + + private async getActiveTabInfo(): Promise<{ url: string; status: string | '' }> { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + return { url: tab?.url || '', status: (tab?.status as string) || '' }; + } + + async run( + ctx: ExecCtx, + step: Step, + appendOverlayOk: (s: Step) => Promise | void, + appendOverlayFail: (s: Step, e: ErrorLike) => Promise | void, + ): Promise<{ + status: 'success' | 'failed' | 'paused'; + nextLabel?: string; + control?: ExecResult['control']; + }> { + const t0 = Date.now(); + let stepNextLabel: string | undefined; + let controlOut: ExecResult['control'] | undefined = undefined; + let ctrlStart: HookControl | undefined; + try { + ctrlStart = await this.env.pluginManager.beforeStep({ + runId: this.env.runId, + flow: this.env.flow, + vars: this.env.vars, + step, + }); + } catch (e: unknown) { + this.env.logger.push({ + stepId: step.id, + status: 'warning', + message: `plugin.beforeStep error: ${errorMessage(e)}`, + }); + } + if (ctrlStart?.pause) return { status: 'paused' }; + + const beforeInfo = await this.getActiveTabInfo(); + try { + await withRetry( + async () => { + // Execute step via injected executor (legacy, actions, or hybrid) + // tabId is expected to be set by Scheduler in ctx; fallback to active tab if missing + let tabId = ctx.tabId; + if (typeof tabId !== 'number') { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + tabId = tabs?.[0]?.id; + } + if (typeof tabId !== 'number') { + throw new Error('No active tab found for step execution'); + } + + const execResult = await this.env.stepExecutor.execute(ctx, step, { + tabId, + runId: this.env.runId, + pushLog: (entry) => this.env.logger.push(entry as any), + remainingBudgetMs: this.env.getRemainingBudgetMs(), + }); + const result = execResult.result; + const remainingBudget = this.env.getRemainingBudgetMs(); + if (step.type === STEP_TYPES.CLICK || step.type === STEP_TYPES.DBLCLICK) { + const after = step.after ?? ({} as NonNullable); + if (after.waitForNavigation) + await waitForNavigationDone( + beforeInfo.url, + Math.min(step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, remainingBudget), + ); + else if (after.waitForNetworkIdle) { + const totalMs = Math.min( + step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, + remainingBudget, + ); + const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3))); + await waitForNetworkIdle(totalMs, idleMs); + } else + await maybeQuickWaitForNav( + beforeInfo.url, + Math.min(step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, remainingBudget), + ); + } + if (step.type === STEP_TYPES.NAVIGATE || step.type === STEP_TYPES.OPEN_TAB) { + await waitForNavigationDone( + beforeInfo.url, + Math.min( + step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, + this.env.getRemainingBudgetMs(), + ), + ); + await ensureReadPageIfWeb(); + } else if (step.type === STEP_TYPES.SWITCH_TAB) { + await ensureReadPageIfWeb(); + } + if (!result?.alreadyLogged) + this.env.logger.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 }); + try { + await this.env.pluginManager.afterStep({ + runId: this.env.runId, + flow: this.env.flow, + vars: this.env.vars, + step, + result, + }); + } catch (e: unknown) { + this.env.logger.push({ + stepId: step.id, + status: 'warning', + message: `plugin.afterStep error: ${errorMessage(e)}`, + }); + } + await appendOverlayOk(step); + if (result?.nextLabel) stepNextLabel = String(result.nextLabel); + if (result?.control) controlOut = result.control; + if (result?.deferAfterScript) this.env.afterScripts.enqueue(result.deferAfterScript); + await this.env.afterScripts.flush(ctx, this.env.vars); + }, + async (attempt, e) => { + this.env.logger.push({ + stepId: step.id, + status: 'retrying', + message: errorMessage(e), + }); + try { + await this.env.pluginManager.onRetry({ + runId: this.env.runId, + flow: this.env.flow, + vars: this.env.vars, + step, + error: e, + attempt, + }); + } catch (pe: unknown) { + this.env.logger.push({ + stepId: step.id, + status: 'warning', + message: `plugin.onRetry error: ${errorMessage(pe)}`, + }); + } + }, + { + count: Math.max(0, step.retry?.count ?? 0), + intervalMs: Math.max(0, step.retry?.intervalMs ?? 0), + backoff: step.retry?.backoff || 'none', + }, + ); + } catch (e: unknown) { + this.env.logger.push({ + stepId: step.id, + status: 'failed', + message: errorMessage(e), + tookMs: Date.now() - t0, + }); + await appendOverlayFail(step, e as ErrorLike); + try { + const hook = await this.env.pluginManager.onError({ + runId: this.env.runId, + flow: this.env.flow, + vars: this.env.vars, + step, + error: e, + }); + if (hook?.pause) return { status: 'paused' }; + } catch (pe: unknown) { + this.env.logger.push({ + stepId: step.id, + status: 'warning', + message: `plugin.onError error: ${errorMessage(pe)}`, + }); + } + return { status: 'failed' }; + } + return { status: 'success', nextLabel: stepNextLabel, control: controlOut }; + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts new file mode 100644 index 0000000..f91826d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts @@ -0,0 +1,169 @@ +// subflow-runner.ts — execute a subflow (nodes/edges) using DAG traversal with branch support + +import { STEP_TYPES } from 'chrome-mcp-shared'; +import type { ExecCtx } from '../../nodes'; +import { RunLogger } from '../logging/run-logger'; +import { PluginManager } from '../plugins/manager'; +import { mapDagNodeToStep } from '../../rr-utils'; +import type { Edge, NodeBase, Step } from '../../types'; +import { StepRunner } from './step-runner'; +import { ENGINE_CONSTANTS } from '../constants'; + +export interface SubflowEnv { + runId: string; + flow: any; + vars: Record; + logger: RunLogger; + pluginManager: PluginManager; + stepRunner: StepRunner; +} + +export class SubflowRunner { + constructor(private env: SubflowEnv) {} + + async runSubflowById(subflowId: string, ctx: ExecCtx, pausedRef: () => boolean): Promise { + const sub = (this.env.flow.subflows || {})[subflowId]; + if (!sub || !Array.isArray(sub.nodes) || sub.nodes.length === 0) return; + + try { + await this.env.pluginManager.subflowStart({ + runId: this.env.runId, + flow: this.env.flow, + vars: this.env.vars, + subflowId, + }); + } catch (e: any) { + this.env.logger.push({ + stepId: `subflow:${subflowId}`, + status: 'warning', + message: `plugin.subflowStart error: ${e?.message || String(e)}`, + }); + } + + const sNodes: NodeBase[] = sub.nodes; + const sEdges: Edge[] = sub.edges || []; + + // Build lookup maps + const id2node = new Map(sNodes.map((n) => [n.id, n] as const)); + const outEdges = new Map(); + for (const e of sEdges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from)!.push(e); + } + + // Calculate in-degrees to find root nodes + const indeg = new Map(sNodes.map((n) => [n.id, 0] as const)); + for (const e of sEdges) { + indeg.set(e.to, (indeg.get(e.to) || 0) + 1); + } + + // Find start node: prefer non-trigger nodes with indeg=0 + const findFirstExecutableRoot = (): string | undefined => { + const executableRoot = sNodes.find( + (n) => (indeg.get(n.id) || 0) === 0 && n.type !== STEP_TYPES.TRIGGER, + ); + if (executableRoot) return executableRoot.id; + + // If all roots are triggers, follow default edge to first executable + const triggerRoot = sNodes.find((n) => (indeg.get(n.id) || 0) === 0); + if (triggerRoot) { + const defaultEdge = (outEdges.get(triggerRoot.id) || []).find( + (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT, + ); + if (defaultEdge) return defaultEdge.to; + } + + return sNodes[0]?.id; + }; + + let currentId: string | undefined = findFirstExecutableRoot(); + let guard = 0; + const maxIterations = ENGINE_CONSTANTS.MAX_ITERATIONS; + + const ok = (s: Step) => this.env.logger.overlayAppend(`✔ ${s.type} (${s.id})`); + const fail = (s: Step, e: any) => + this.env.logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${e?.message || String(e)}`); + + while (currentId) { + if (pausedRef()) break; + if (guard++ >= maxIterations) { + this.env.logger.push({ + stepId: `subflow:${subflowId}`, + status: 'warning', + message: `Subflow exceeded ${maxIterations} iterations - possible cycle`, + }); + break; + } + + const node = id2node.get(currentId); + if (!node) break; + + // Skip trigger nodes + if (node.type === STEP_TYPES.TRIGGER) { + const defaultEdge = (outEdges.get(currentId) || []).find( + (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT, + ); + if (defaultEdge) { + currentId = defaultEdge.to; + continue; + } + break; + } + + const step: Step = mapDagNodeToStep(node); + const r = await this.env.stepRunner.run(ctx, step, ok, fail); + + if (r.status === 'paused' || pausedRef()) break; + + if (r.status === 'failed') { + // Try to find on_error edge + const errEdge = (outEdges.get(currentId) || []).find( + (e) => e.label === ENGINE_CONSTANTS.EDGE_LABELS.ON_ERROR, + ); + if (errEdge) { + currentId = errEdge.to; + continue; + } + break; + } + + // Determine next edge by label + const suggestedLabel = r.nextLabel + ? String(r.nextLabel) + : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT; + const oes = outEdges.get(currentId) || []; + const nextEdge = + oes.find((e) => (e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT) === suggestedLabel) || + oes.find((e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT); + + if (!nextEdge) { + // Log warning if we expected a labeled edge but couldn't find it + if (r.nextLabel && oes.length > 0) { + const availableLabels = oes.map((e) => e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT); + this.env.logger.push({ + stepId: step.id, + status: 'warning', + message: `No edge for label '${suggestedLabel}'. Available: [${availableLabels.join(', ')}]`, + }); + } + break; + } + currentId = nextEdge.to; + } + + try { + await this.env.pluginManager.subflowEnd({ + runId: this.env.runId, + flow: this.env.flow, + vars: this.env.vars, + subflowId, + }); + } catch (e: any) { + this.env.logger.push({ + stepId: `subflow:${subflowId}`, + status: 'warning', + message: `plugin.subflowEnd error: ${e?.message || String(e)}`, + }); + } + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts new file mode 100644 index 0000000..9bf5e89 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts @@ -0,0 +1,846 @@ +import { STEP_TYPES, TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { Edge, Flow, NodeBase, RunLogEntry, RunResult, Step } from '../types'; +import { + mapDagNodeToStep, + topoOrder, + ensureTab, + expandTemplatesDeep, + defaultEdgesOnly, +} from '../rr-utils'; +import type { ExecCtx } from '../nodes'; +import { RunLogger } from './logging/run-logger'; +import { PluginManager } from './plugins/manager'; +import type { RunPlugin } from './plugins/types'; +import { breakpointPlugin } from './plugins/breakpoint'; +import { evalExpression } from './utils/expression'; +import { runState } from './state-manager'; +import { AfterScriptQueue } from './runners/after-script-queue'; +import { StepRunner } from './runners/step-runner'; +import { ControlFlowRunner } from './runners/control-flow-runner'; +import { SubflowRunner } from './runners/subflow-runner'; +import { ENGINE_CONSTANTS, LOG_STEP_IDS } from './constants'; +import { + DEFAULT_EXECUTION_MODE_CONFIG, + createActionsOnlyConfig, + createHybridConfig, + type ExecutionMode, + type ExecutionModeConfig, +} from './execution-mode'; +import { createExecutor, type StepExecutorInterface } from './runners/step-executor'; +import { createReplayActionRegistry } from '../actions/handlers'; + +export interface RunOptions { + tabTarget?: 'current' | 'new'; + refresh?: boolean; + captureNetwork?: boolean; + returnLogs?: boolean; + timeoutMs?: number; + startUrl?: string; + args?: Record; + startNodeId?: string; + plugins?: RunPlugin[]; + + /** + * Step execution mode switch. + * - 'legacy': Use existing nodes/executeStep (default, safest) + * - 'hybrid': Try ActionRegistry first, fall back to legacy + * - 'actions': Use ActionRegistry exclusively (strict mode) + */ + executionMode?: ExecutionMode; + + /** + * Hybrid mode only: allowlist of step types executed via ActionRegistry. + * - undefined: use MINIMAL_HYBRID_ACTION_TYPES (safest default) + * - []: disable allowlist, fall back to MIGRATED_ACTION_TYPES policy + * - ['fill', 'key', ...]: only these types use actions + */ + actionsAllowlist?: string[]; + + /** + * Hybrid mode only: denylist of step types forced to legacy. + * When omitted, createHybridConfig defaults to LEGACY_ONLY_TYPES. + */ + legacyOnlyTypes?: string[]; +} + +/** + * Type guard for ExecutionMode + */ +function isExecutionMode(value: unknown): value is ExecutionMode { + return value === 'legacy' || value === 'hybrid' || value === 'actions'; +} + +/** + * Convert array to Set, filtering invalid values + */ +function toStringSet(value: unknown): Set { + const result = new Set(); + if (!Array.isArray(value)) return result; + for (const item of value) { + if (typeof item === 'string') { + const trimmed = item.trim(); + if (trimmed) result.add(trimmed); + } + } + return result; +} + +/** + * Build ExecutionModeConfig from RunOptions. + * Defaults to legacy mode if executionMode is not specified. + * + * Note: Only array inputs for actionsAllowlist/legacyOnlyTypes are accepted. + * Non-array values are ignored to prevent accidental misconfiguration + * (e.g., passing a string instead of array would unexpectedly widen the allowlist). + */ +function buildExecutionModeConfig(options: RunOptions): ExecutionModeConfig { + const mode: ExecutionMode = isExecutionMode(options.executionMode) + ? options.executionMode + : DEFAULT_EXECUTION_MODE_CONFIG.mode; + + if (mode === 'hybrid') { + const overrides: Partial = {}; + // Only apply override if it's a valid array + // This prevents misconfiguration from widening the actions scope + if (Array.isArray(options.actionsAllowlist)) { + overrides.actionsAllowlist = toStringSet(options.actionsAllowlist); + } + if (Array.isArray(options.legacyOnlyTypes)) { + overrides.legacyOnlyTypes = toStringSet(options.legacyOnlyTypes); + } + return createHybridConfig(overrides); + } + + if (mode === 'actions') { + return createActionsOnlyConfig(); + } + + // Default: legacy mode + return { ...DEFAULT_EXECUTION_MODE_CONFIG }; +} + +/** + * ExecutionOrchestrator manages the lifecycle of a flow execution. + * + * Architecture: + * - Creates StepExecutor based on ExecutionModeConfig (legacy by default) + * - Injects StepExecutor into StepRunner for step execution + * - Manages tabId and passes it through ExecCtx + * - Handles DAG traversal, control flow, and cleanup + */ +class ExecutionOrchestrator { + private readonly runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + private readonly startAt = Date.now(); + private readonly logger = new RunLogger(this.runId); + private readonly pluginManager: PluginManager; + private readonly afterScripts = new AfterScriptQueue(this.logger); + + // Execution mode configuration (defaults to legacy for safety) + private readonly executionModeConfig: ExecutionModeConfig; + private readonly stepExecutor: StepExecutorInterface; + + // Runtime state + private vars: Record = Object.create(null); + private tabId: number | null = null; + private deadline = 0; + private networkCaptureStarted = false; + private paused = false; + private failed = 0; + private executed = 0; + private steps: Step[] = []; + private prepareError: RunResult | null = null; + + // Runners + private stepRunner: StepRunner; + private controlFlowRunner!: ControlFlowRunner; + private subflowRunner!: SubflowRunner; + + constructor( + private flow: Flow, + private options: RunOptions = {}, + ) { + // Initialize variables from flow defaults and args + for (const v of flow.variables || []) { + if (v.default !== undefined) this.vars[v.key] = v.default; + } + if (options.args) Object.assign(this.vars, options.args); + + // Set up global deadline + const globalTimeout = Math.max(0, Number(options.timeoutMs || 0)); + this.deadline = globalTimeout > 0 ? this.startAt + globalTimeout : 0; + + // Initialize plugin manager + this.pluginManager = new PluginManager( + options.plugins && options.plugins.length ? options.plugins : [breakpointPlugin()], + ); + + // Create step executor based on execution mode configuration + // Default to legacy mode for maximum safety during migration + this.executionModeConfig = buildExecutionModeConfig(options); + + // Only create ActionRegistry when needed (hybrid or actions mode) + // This avoids unnecessary initialization overhead in legacy mode + const registry = + this.executionModeConfig.mode === 'legacy' ? undefined : createReplayActionRegistry(); + this.stepExecutor = createExecutor(this.executionModeConfig, registry); + + // Initialize step runner with injected executor + this.stepRunner = new StepRunner({ + runId: this.runId, + flow: this.flow, + vars: this.vars, + logger: this.logger, + pluginManager: this.pluginManager, + afterScripts: this.afterScripts, + getRemainingBudgetMs: () => + this.deadline > 0 ? Math.max(0, this.deadline - Date.now()) : Number.POSITIVE_INFINITY, + stepExecutor: this.stepExecutor, + }); + } + + private ensureWithinDeadline() { + if (this.deadline > 0 && Date.now() > this.deadline) { + const err = new Error('Global timeout reached'); + this.logger.push({ + stepId: LOG_STEP_IDS.GLOBAL_TIMEOUT, + status: 'failed', + message: 'Global timeout reached', + }); + throw err; + } + } + + async run(): Promise { + try { + await this.prepareExecution(); + if (this.prepareError) return this.prepareError; + return await this.traverseDag(); + } finally { + await this.cleanup(); + } + } + + private async prepareExecution() { + // Derive default startUrl + let derivedStartUrl: string | undefined; + try { + const hasDag0 = Array.isArray(this.flow.nodes) && (this.flow.nodes?.length || 0) > 0; + const nodes0: NodeBase[] = hasDag0 ? this.flow.nodes || [] : []; + const edges0: Edge[] = hasDag0 ? this.flow.edges || [] : []; + const defaultEdges0 = hasDag0 ? defaultEdgesOnly(edges0) : []; + const order0 = hasDag0 ? topoOrder(nodes0, defaultEdges0) : []; + const steps0: Step[] = hasDag0 ? order0.map((n) => mapDagNodeToStep(n)) : []; + const nav = steps0.find((s) => s.type === STEP_TYPES.NAVIGATE); + if (nav && nav.type === STEP_TYPES.NAVIGATE) + derivedStartUrl = expandTemplatesDeep(nav.url, this.vars); + } catch { + // ignore: best-effort derive startUrl + } + + const ensured = await ensureTab({ + tabTarget: this.options.tabTarget, + startUrl: this.options.startUrl || derivedStartUrl, + refresh: this.options.refresh, + }); + // Capture tabId for use in ExecCtx + this.tabId = ensured?.tabId ?? null; + + // register run state + await runState.restore(); + await runState.add(this.runId, { + id: this.runId, + flowId: this.flow.id, + name: this.flow.name, + status: 'running', + startedAt: this.startAt, + updatedAt: this.startAt, + }); + + try { + await this.pluginManager.runStart({ runId: this.runId, flow: this.flow, vars: this.vars }); + } catch (e: any) { + this.logger.push({ + stepId: LOG_STEP_IDS.PLUGIN_RUN_START, + status: 'warning', + message: e?.message || String(e), + }); + } + + // pre-load read_page when on web + try { + const u = ensured?.url || ''; + if (/^(https?:|file:)/i.test(u)) + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + } catch { + // ignore: preloading read_page is best-effort + } + + // overlay variable collection + try { + const needed = (this.flow.variables || []).filter( + (v) => + (this.options.args?.[v.key] == null || this.options.args?.[v.key] === '') && + (v.rules?.required || (v.default ?? '') === ''), + ); + if (needed.length) { + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT, + args: { + eventName: TOOL_MESSAGE_TYPES.COLLECT_VARIABLES, + payload: JSON.stringify({ variables: needed, useOverlay: true }), + }, + }); + let values: Record | null = null; + try { + const t = (res?.content || []).find((c: any) => c.type === 'text')?.text; + const j = t ? JSON.parse(t) : null; + if (j && j.success && j.values) values = j.values; + } catch { + // ignore: parse result from tool response + } + if (!values) { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId === 'number') { + const res2 = await chrome.tabs.sendMessage(tabId, { + action: TOOL_MESSAGE_TYPES.COLLECT_VARIABLES, + variables: needed, + useOverlay: true, + }); + if (res2 && res2.success && res2.values) values = res2.values; + } + } + if (values) Object.assign(this.vars, values); + else + this.logger.push({ + stepId: LOG_STEP_IDS.VARIABLE_COLLECT, + status: 'warning', + message: 'Variable collection failed; using provided args/defaults', + }); + } + } catch { + // ignore: variable collection is optional + } + + await this.logger.overlayInit(); + + // binding enforcement + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const currentUrl = tabs?.[0]?.url || ''; + const bindings = this.flow.meta?.bindings || []; + if (!this.options.startUrl && bindings.length > 0) { + const ok = bindings.some((b) => { + try { + if (b.type === 'domain') return new URL(currentUrl).hostname.includes(b.value); + if (b.type === 'path') return new URL(currentUrl).pathname.startsWith(b.value); + if (b.type === 'url') return currentUrl.startsWith(b.value); + } catch { + // ignore: URL parsing for binding check + } + return false; + }); + if (!ok) { + this.prepareError = { + runId: this.runId, + success: false, + summary: { total: 0, success: 0, failed: 0, tookMs: 0 }, + url: currentUrl, + outputs: null, + logs: [ + { + stepId: LOG_STEP_IDS.BINDING_CHECK, + status: 'failed', + message: + 'Flow binding mismatch. Provide startUrl or open a page matching flow.meta.bindings.', + }, + ], + screenshots: { onFailure: null }, + paused: false, + }; + return; + } + } + } catch { + // ignore: binding enforcement failures fall back to default behavior + } + + // network capture start + if (this.options.captureNetwork) { + try { + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START, + args: { includeStatic: false, maxCaptureTime: 3 * 60_000, inactivityTimeout: 0 }, + }); + let started = false; + try { + const t = res?.content?.find?.((c: any) => c.type === 'text')?.text; + if (t) { + const j = JSON.parse(t); + started = !!j?.success; + } + } catch { + // ignore: parse network debugger start response + } + this.networkCaptureStarted = started; + if (!started) { + this.logger.push({ + stepId: LOG_STEP_IDS.NETWORK_CAPTURE, + status: 'warning', + message: 'Failed to confirm network capture start', + }); + } + } catch (e: any) { + this.logger.push({ + stepId: LOG_STEP_IDS.NETWORK_CAPTURE, + status: 'warning', + message: e?.message || 'Network capture start errored', + }); + } + } + + // build DAG steps + const hasDag = Array.isArray(this.flow.nodes) && (this.flow.nodes?.length || 0) > 0; + if (!hasDag) { + this.prepareError = { + runId: this.runId, + success: false, + summary: { total: 0, success: 0, failed: 0, tookMs: 0 }, + url: null, + outputs: null, + logs: [ + { + stepId: LOG_STEP_IDS.DAG_REQUIRED, + status: 'failed', + message: + 'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.', + }, + ], + screenshots: { onFailure: null }, + paused: false, + }; + return; + } + const nodes: NodeBase[] = (this.flow.nodes || []) as NodeBase[]; + const edges: Edge[] = (this.flow.edges || []) as Edge[]; + // Validate DAG for potential cycles on full edge set + try { + if (this.hasCycle(nodes, edges)) { + this.prepareError = { + runId: this.runId, + success: false, + summary: { total: 0, success: 0, failed: 0, tookMs: 0 }, + url: null, + outputs: null, + logs: [ + { + stepId: LOG_STEP_IDS.DAG_CYCLE, + status: 'failed', + message: + 'Flow DAG contains a cycle. Please break the cycle or add explicit labels/branches to avoid infinite loops.', + }, + ], + screenshots: { onFailure: null }, + paused: false, + }; + return; + } + } catch { + // ignore: cycle detection guard + } + const defaultEdges = defaultEdgesOnly(edges); + const order = topoOrder(nodes, defaultEdges); + // Filter out trigger nodes - they are configuration nodes, not executable steps + this.steps = order.filter((n) => n.type !== STEP_TYPES.TRIGGER).map((n) => mapDagNodeToStep(n)); + // initialize runners + this.subflowRunner = new SubflowRunner({ + runId: this.runId, + flow: this.flow, + vars: this.vars, + logger: this.logger, + pluginManager: this.pluginManager, + stepRunner: this.stepRunner, + }); + this.controlFlowRunner = new ControlFlowRunner({ + vars: this.vars, + logger: this.logger, + evalCondition: (c) => this.evalCondition(c), + runSubflowById: (id, ctx) => this.subflowRunner.runSubflowById(id, ctx, () => this.paused), + isPaused: () => this.paused, + }); + } + + // Basic cycle detection using DFS coloring on the full edge set + private hasCycle( + nodes: Array<{ id: string }>, + edges: Array<{ from: string; to: string }>, + ): boolean { + const adj = new Map(); + for (const n of nodes) adj.set(n.id, []); + for (const e of edges) { + if (!adj.has(e.from)) adj.set(e.from, []); + adj.get(e.from)!.push(e.to); + } + const color = new Map(); // 0=unvisited,1=visiting,2=done + const visit = (u: string): boolean => { + const c = color.get(u) || 0; + if (c === 1) return true; // back-edge + if (c === 2) return false; + color.set(u, 1); + for (const v of adj.get(u) || []) if (visit(v)) return true; + color.set(u, 2); + return false; + }; + for (const n of nodes) if ((color.get(n.id) || 0) === 0 && visit(n.id)) return true; + return false; + } + + private async traverseDag(): Promise { + if (!this.steps.length) { + await this.logger.overlayDone(); + const tookMs0 = Date.now() - this.startAt; + return ( + this.prepareError || { + runId: this.runId, + success: false, + summary: { total: 0, success: 0, failed: 0, tookMs: tookMs0 }, + url: null, + outputs: null, + logs: this.options.returnLogs ? this.logger.getLogs() : undefined, + screenshots: { onFailure: null }, + paused: false, + } + ); + } + const nodes: NodeBase[] = this.flow.nodes || []; + const edges: Edge[] = this.flow.edges || []; + const id2node = new Map(nodes.map((n) => [n.id, n] as const)); + const outEdges = new Map>(); + for (const e of edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from)!.push(e); + } + const indeg = new Map(nodes.map((n) => [n.id, 0] as const)); + for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1); + // Find start node: prefer non-trigger nodes with indeg=0 + // Trigger nodes are configuration nodes and should be skipped + const findFirstExecutableRoot = (): string | undefined => { + // First try to find a non-trigger root node + const executableRoot = nodes.find( + (n) => (indeg.get(n.id) || 0) === 0 && n.type !== STEP_TYPES.TRIGGER, + ); + if (executableRoot) return executableRoot.id; + + // If all roots are triggers, find one and follow default edge to first executable + const triggerRoot = nodes.find((n) => (indeg.get(n.id) || 0) === 0); + if (triggerRoot) { + const defaultEdge = (outEdges.get(triggerRoot.id) || []).find( + (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT, + ); + if (defaultEdge) return defaultEdge.to; + } + + // Fallback to first node + return nodes[0]?.id; + }; + + let currentId: string | undefined = + this.options.startNodeId && id2node.has(this.options.startNodeId) + ? this.options.startNodeId + : findFirstExecutableRoot(); + let guard = 0; + + // Create execution context with tabId from ensureTab + // tabId is managed by Scheduler and may be updated by openTab/switchTab actions + const ctx: ExecCtx = { + vars: this.vars, + tabId: this.tabId ?? undefined, + logger: (e: RunLogEntry) => this.logger.push(e), + }; + if (currentId) { + try { + await this.logger.overlayAppend( + `▶ start at ${id2node.get(currentId)?.type || ''} (${currentId})`, + ); + } catch { + // ignore: eval condition failure treated as false + } + } + while (currentId) { + this.ensureWithinDeadline(); + if (guard++ >= ENGINE_CONSTANTS.MAX_ITERATIONS) { + this.logger.push({ + stepId: LOG_STEP_IDS.LOOP_GUARD, + status: 'failed', + message: `Exceeded ${ENGINE_CONSTANTS.MAX_ITERATIONS} iterations - possible cycle in DAG`, + }); + this.failed++; + break; + } + const node = id2node.get(currentId); + if (!node) break; + + // Skip trigger nodes - they are configuration nodes, not executable steps + // Follow default edge to the next executable node + if (node.type === STEP_TYPES.TRIGGER) { + try { + await this.logger.overlayAppend(`⏭ skip trigger (${node.id})`); + } catch {} + const defaultEdge = (outEdges.get(currentId) || []).find( + (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT, + ); + if (defaultEdge) { + currentId = defaultEdge.to; + continue; + } + // No successor after trigger - end execution + this.logger.push({ + stepId: node.id, + status: 'warning', + message: 'Trigger node has no successor - nothing to execute', + }); + break; + } + + const step: Step = mapDagNodeToStep(node); + // lightweight trace to aid debugging edge traversal + try { + await this.logger.overlayAppend(`→ ${step.type} (${step.id})`); + } catch { + // ignore: stopping network capture is best-effort + } + // Count this step as executed (regardless of success/failure) + this.executed++; + + const r = await this.stepRunner.run( + ctx, + step, + (s) => this.logger.overlayAppend(`✔ ${s.type} (${s.id})`), + (s, e) => this.logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${e?.message || String(e)}`), + ); + if (r.status === 'paused') { + this.paused = true; + break; + } + if (r.status === 'failed') { + this.failed++; + const oes = (outEdges.get(currentId) || []) as Edge[]; + const errEdge = oes.find((edg) => edg.label === ENGINE_CONSTANTS.EDGE_LABELS.ON_ERROR); + if (errEdge) { + currentId = errEdge.to; + continue; + } else { + break; + } + } + if (r.control) { + const control = r.control; + const st = await this.controlFlowRunner.run(control, ctx); + if (st === 'paused') { + this.paused = true; + break; + } + const suggested = r.nextLabel ? String(r.nextLabel) : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT; + const next = await this.advanceToNext(currentId, step, suggested, id2node, outEdges); + if (!next) break; + currentId = next; + continue; + } + // choose next by label + { + const suggested = r.nextLabel ? String(r.nextLabel) : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT; + const next = await this.advanceToNext(currentId, step, suggested, id2node, outEdges); + if (!next) break; + currentId = next; + } + } + const tookMs = Date.now() - this.startAt; + const sensitiveKeys = new Set( + (this.flow.variables || []).filter((v) => v.sensitive).map((v) => v.key), + ); + const outputs: Record = {}; + for (const [k, v] of Object.entries(this.vars)) if (!sensitiveKeys.has(k)) outputs[k] = v; + return { + runId: this.runId, + success: !this.paused && this.failed === 0, + summary: { + total: this.executed, + success: this.executed - this.failed, + failed: this.failed, + tookMs, + }, + url: null, + outputs, + logs: this.options.returnLogs ? this.logger.getLogs() : undefined, + screenshots: { + onFailure: this.logger.getLogs().find((l) => l.status === 'failed')?.screenshotBase64, + }, + paused: this.paused, + }; + } + + // Advance to next node by suggested label, with overlay/logging and fallback to default edge. + private async advanceToNext( + currentId: string, + step: Step, + suggested: string, + id2node: Map, + outEdges: Map>, + ): Promise { + const nextLabel = await this.chooseNextLabel(step, suggested); + const nextId = this.findNextNodeId(currentId, outEdges, nextLabel); + if (nextId) { + try { + await this.logger.overlayAppend( + `↪ next(${nextLabel}) → ${id2node.get(nextId)?.type || ''} (${nextId})`, + ); + } catch {} + return nextId; + } + const labels = (outEdges.get(currentId) || []).map((e) => + String(e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT), + ); + this.logger.push({ + stepId: step.id, + status: 'warning', + message: `No next edge for label '${nextLabel}'. Outgoing labels: [${labels.join(', ')}]`, + }); + return undefined; + } + + // Decide next label, allowing plugins to override; logs plugin errors as warnings + private async chooseNextLabel(step: Step, suggested: string): Promise { + try { + const override = await this.pluginManager.onChooseNextLabel({ + runId: this.runId, + flow: this.flow, + vars: this.vars, + step, + suggested, + }); + return override ? String(override) : suggested; + } catch (e: any) { + this.logger.push({ + stepId: step.id, + status: 'warning', + message: `plugin.onChooseNextLabel error: ${e?.message || String(e)}`, + }); + return suggested; + } + } + + // From current node and label, pick next nodeId using outEdges; prefers labeled edge then default + private findNextNodeId( + currentId: string, + outEdges: Map>, + nextLabel: string, + ): string | undefined { + const oes = (outEdges.get(currentId) || []) as Edge[]; + const edge = + oes.find((e) => String(e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT) === nextLabel) || + oes.find((e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT); + return edge ? edge.to : undefined; + } + + private evalCondition(cond: any): boolean { + try { + if (cond && typeof cond.expression === 'string' && cond.expression.trim()) { + return !!evalExpression(String(cond.expression), { vars: this.vars }); + } + if (cond && typeof cond.var === 'string') { + const v = this.vars[cond.var]; + if ('equals' in cond) return String(v) === String(cond.equals); + return !!v; + } + } catch { + // ignore: cleanup guard + } + return false; + } + + private async cleanup() { + if (this.networkCaptureStarted) { + try { + const stopRes = await handleCallTool({ + name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP, + args: {}, + }); + const text = (stopRes?.content || []).find((c: any) => c.type === 'text')?.text; + if (text) { + try { + const data = JSON.parse(text); + const requests: any[] = Array.isArray(data?.requests) ? data.requests : []; + const snippets = requests + .filter((r) => ['XHR', 'Fetch'].includes(String(r.type))) + .slice(0, 10) + .map((r) => ({ + method: String(r.method || 'GET'), + url: String(r.url || ''), + status: r.statusCode || r.status, + ms: Math.max(0, (r.responseTime || 0) - (r.requestTime || 0)), + })); + this.logger.push({ + stepId: LOG_STEP_IDS.NETWORK_CAPTURE, + status: 'success', + message: `Captured ${Number(data?.requestCount || 0)} requests`, + networkSnippets: snippets, + }); + } catch (e: any) { + this.logger.push({ + stepId: LOG_STEP_IDS.NETWORK_CAPTURE, + status: 'warning', + message: `Failed parsing network capture result: ${e?.message || String(e)}`, + }); + } + } + } catch {} + } + await this.logger.overlayDone(); + try { + try { + await this.pluginManager.runEnd({ + runId: this.runId, + flow: this.flow, + vars: this.vars, + success: this.failed === 0 && !this.paused, + failed: this.failed, + }); + } catch (e: any) { + this.logger.push({ + stepId: LOG_STEP_IDS.PLUGIN_RUN_END, + status: 'warning', + message: e?.message || String(e), + }); + } + if (!this.paused) await this.logger.persist(this.flow, this.startAt, this.failed === 0); + try { + await runState.update(this.runId, { + status: this.paused ? 'stopped' : this.failed === 0 ? 'completed' : 'failed', + updatedAt: Date.now(), + }); + } catch (e: any) { + this.logger.push({ + stepId: LOG_STEP_IDS.RUNSTATE_UPDATE, + status: 'warning', + message: e?.message || String(e), + }); + } + try { + if (!this.paused) await runState.delete(this.runId); + } catch (e: any) { + this.logger.push({ + stepId: LOG_STEP_IDS.RUNSTATE_DELETE, + status: 'warning', + message: e?.message || String(e), + }); + } + } catch {} + } +} + +export async function runFlow(flow: Flow, options: RunOptions = {}): Promise { + const orchestrator = new ExecutionOrchestrator(flow, options); + return await orchestrator.run(); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/state-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/state-manager.ts new file mode 100644 index 0000000..036e47d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/state-manager.ts @@ -0,0 +1,87 @@ +// engine/state-manager.ts — lightweight run state store with events and persistence + +type Listener = (payload: T) => void; + +export interface RunState { + id: string; + flowId: string; + name?: string; + status: 'running' | 'completed' | 'failed' | 'stopped'; + startedAt: number; + updatedAt: number; +} + +export class StateManager { + private key: string; + private states = new Map(); + private listeners: Record[]> = Object.create(null); + + constructor(storageKey: string) { + this.key = storageKey; + } + + on(name: string, listener: Listener) { + (this.listeners[name] = this.listeners[name] || []).push(listener); + } + + off(name: string, listener: Listener) { + const arr = this.listeners[name]; + if (!arr) return; + const i = arr.indexOf(listener as any); + if (i >= 0) arr.splice(i, 1); + } + + private emit(name: string, payload: E) { + const arr = this.listeners[name] || []; + for (const fn of arr) + try { + fn(payload); + } catch {} + } + + getAll(): Map { + return this.states; + } + + get(id: string): T | undefined { + return this.states.get(id); + } + + async add(id: string, data: T): Promise { + this.states.set(id, data); + this.emit('add', { id, data }); + await this.persist(); + } + + async update(id: string, patch: Partial): Promise { + const cur = this.states.get(id); + if (!cur) return; + const next = Object.assign({}, cur, patch); + this.states.set(id, next); + this.emit('update', { id, data: next }); + await this.persist(); + } + + async delete(id: string): Promise { + this.states.delete(id); + this.emit('delete', { id }); + await this.persist(); + } + + private async persist(): Promise { + try { + const obj = Object.fromEntries(this.states.entries()); + await chrome.storage.local.set({ [this.key]: obj }); + } catch {} + } + + async restore(): Promise { + try { + const res = await chrome.storage.local.get(this.key); + const obj = (res && res[this.key]) || {}; + this.states = new Map(Object.entries(obj) as any); + } catch {} + } +} + +export const runState = new StateManager('rr_run_states'); diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts new file mode 100644 index 0000000..4435221 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts @@ -0,0 +1,227 @@ +// expression.ts — minimal safe boolean expression evaluator (no access to global scope) +// Supported: +// - Literals: numbers (123, 1.23), strings ('x' or "x"), booleans (true/false) +// - Variables: vars.x, vars.a.b (only reads from provided vars object) +// - Operators: !, &&, ||, ==, !=, >, >=, <, <=, +, -, *, / +// - Parentheses: ( ... ) + +type Token = { type: string; value?: any }; + +function tokenize(input: string): Token[] { + const s = input.trim(); + const out: Token[] = []; + let i = 0; + const isAlpha = (c: string) => /[a-zA-Z_]/.test(c); + const isNum = (c: string) => /[0-9]/.test(c); + const isIdChar = (c: string) => /[a-zA-Z0-9_]/.test(c); + while (i < s.length) { + const c = s[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { + i++; + continue; + } + // operators + if ( + s.startsWith('&&', i) || + s.startsWith('||', i) || + s.startsWith('==', i) || + s.startsWith('!=', i) || + s.startsWith('>=', i) || + s.startsWith('<=', i) + ) { + out.push({ type: 'op', value: s.slice(i, i + 2) }); + i += 2; + continue; + } + if ('!+-*/()<>'.includes(c)) { + out.push({ type: 'op', value: c }); + i++; + continue; + } + // number + if (isNum(c) || (c === '.' && isNum(s[i + 1] || ''))) { + let j = i + 1; + while (j < s.length && (isNum(s[j]) || s[j] === '.')) j++; + out.push({ type: 'num', value: parseFloat(s.slice(i, j)) }); + i = j; + continue; + } + // string + if (c === '"' || c === "'") { + const quote = c; + let j = i + 1; + let str = ''; + while (j < s.length) { + if (s[j] === '\\' && j + 1 < s.length) { + str += s[j + 1]; + j += 2; + } else if (s[j] === quote) { + j++; + break; + } else { + str += s[j++]; + } + } + out.push({ type: 'str', value: str }); + i = j; + continue; + } + // identifier (vars or true/false) + if (isAlpha(c)) { + let j = i + 1; + while (j < s.length && isIdChar(s[j])) j++; + let id = s.slice(i, j); + // dotted path + while (s[j] === '.' && isAlpha(s[j + 1] || '')) { + let k = j + 1; + while (k < s.length && isIdChar(s[k])) k++; + id += s.slice(j, k); + j = k; + } + out.push({ type: 'id', value: id }); + i = j; + continue; + } + // unknown token, skip to avoid crash + i++; + } + return out; +} + +// Recursive descent parser +export function evalExpression(expr: string, scope: { vars: Record }): any { + const tokens = tokenize(expr); + let i = 0; + const peek = () => tokens[i]; + const consume = () => tokens[i++]; + + function parsePrimary(): any { + const t = peek(); + if (!t) return undefined; + if (t.type === 'num') { + consume(); + return t.value; + } + if (t.type === 'str') { + consume(); + return t.value; + } + if (t.type === 'id') { + consume(); + const id = String(t.value); + if (id === 'true') return true; + if (id === 'false') return false; + // Only allow vars.* lookups + if (!id.startsWith('vars')) return undefined; + try { + const parts = id.split('.').slice(1); + let cur: any = scope.vars; + for (const p of parts) { + if (cur == null) return undefined; + cur = cur[p]; + } + return cur; + } catch { + return undefined; + } + } + if (t.type === 'op' && t.value === '(') { + consume(); + const v = parseOr(); + if (peek()?.type === 'op' && peek()?.value === ')') consume(); + return v; + } + return undefined; + } + + function parseUnary(): any { + const t = peek(); + if (t && t.type === 'op' && (t.value === '!' || t.value === '-')) { + consume(); + const v = parseUnary(); + return t.value === '!' ? !truthy(v) : -Number(v || 0); + } + return parsePrimary(); + } + + function parseMulDiv(): any { + let v = parseUnary(); + while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) { + const op = consume().value; + const r = parseUnary(); + v = op === '*' ? Number(v || 0) * Number(r || 0) : Number(v || 0) / Number(r || 0); + } + return v; + } + + function parseAddSub(): any { + let v = parseMulDiv(); + while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) { + const op = consume().value; + const r = parseMulDiv(); + v = op === '+' ? Number(v || 0) + Number(r || 0) : Number(v || 0) - Number(r || 0); + } + return v; + } + + function parseRel(): any { + let v = parseAddSub(); + while (peek() && peek().type === 'op' && ['>', '>=', '<', '<='].includes(peek().value)) { + const op = consume().value as string; + const r = parseAddSub(); + const a = toComparable(v); + const b = toComparable(r); + if (op === '>') v = (a as any) > (b as any); + else if (op === '>=') v = (a as any) >= (b as any); + else if (op === '<') v = (a as any) < (b as any); + else v = (a as any) <= (b as any); + } + return v; + } + + function parseEq(): any { + let v = parseRel(); + while (peek() && peek().type === 'op' && (peek().value === '==' || peek().value === '!=')) { + const op = consume().value as string; + const r = parseRel(); + const a = toComparable(v); + const b = toComparable(r); + v = op === '==' ? a === b : a !== b; + } + return v; + } + + function parseAnd(): any { + let v = parseEq(); + while (peek() && peek().type === 'op' && peek().value === '&&') { + consume(); + const r = parseEq(); + v = truthy(v) && truthy(r); + } + return v; + } + + function parseOr(): any { + let v = parseAnd(); + while (peek() && peek().type === 'op' && peek().value === '||') { + consume(); + const r = parseAnd(); + v = truthy(v) || truthy(r); + } + return v; + } + + function truthy(v: any) { + return !!v; + } + function toComparable(v: any) { + return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v); + } + + try { + const res = parseOr(); + return res; + } catch { + return false; + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/flow-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/flow-runner.ts new file mode 100644 index 0000000..2682e3a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/flow-runner.ts @@ -0,0 +1,3 @@ +// thin re-export for backward compatibility +export { runFlow } from './engine/scheduler'; +export type { RunOptions } from './engine/scheduler'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts b/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts new file mode 100644 index 0000000..5e96fa2 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts @@ -0,0 +1,422 @@ +import type { Flow, RunRecord, NodeBase, Edge } from './types'; +import { stepsToDAG, type RRNode, type RREdge } from 'chrome-mcp-shared'; +import { NODE_TYPES } from '@/common/node-types'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager'; + +// Design note: IndexedDB-backed store for flows and run records. +// Includes lazy migration from chrome.storage.local for backwards compatibility. + +// Validate if a type string is a valid NodeType +const VALID_NODE_TYPES = new Set(Object.values(NODE_TYPES)); +function isValidNodeType(type: string): boolean { + return VALID_NODE_TYPES.has(type); +} + +// Convert RRNode to NodeBase (ui coordinates are optional, not added here) +function toNodeBase(node: RRNode): NodeBase { + return { + id: node.id, + type: isValidNodeType(node.type) ? (node.type as NodeBase['type']) : NODE_TYPES.SCRIPT, + config: node.config, + }; +} + +// Convert RREdge to Edge +function toEdge(edge: RREdge): Edge { + return { + id: edge.id, + from: edge.from, + to: edge.to, + label: edge.label, + }; +} + +/** + * Filter edges to only keep those whose from/to both exist in nodeIds. + * Prevents topoOrder crash when edges reference non-existent nodes. + */ +function filterValidEdges(edges: Edge[], nodeIds: Set): Edge[] { + return edges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to)); +} + +// ============================================================================= +// UI Notification +// ============================================================================= + +/** + * Timer handle for coalescing flow change notifications. + * Prevents multiple rapid changes (e.g., during import) from flooding UI. + */ +let flowsChangedTimer: ReturnType | undefined; + +/** + * Notify UI that flows have changed. + * Uses a short debounce (50ms) to coalesce rapid changes. + */ +function notifyFlowsChanged(): void { + // If timer is already scheduled, skip (will be handled by pending timer) + if (flowsChangedTimer !== undefined) return; + + flowsChangedTimer = setTimeout(() => { + flowsChangedTimer = undefined; + try { + // Send message to all extension contexts (popup, sidepanel, etc.) + // Use void cast to avoid unhandled promise rejection + void chrome.runtime + .sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.RR_FLOWS_CHANGED, + }) + .catch(() => { + // Ignore errors - no listeners is expected when UI is closed + }); + } catch { + // Ignore errors (e.g., if chrome.runtime is not available) + } + }, 50); +} + +/** + * Strip deprecated steps field before persisting to IndexedDB. + * This ensures new saves only contain the DAG model (nodes/edges). + * + * @param flow - Flow with or without steps + * @returns Flow without steps field (omit entirely, not set to empty array) + */ +function stripStepsForSave(flow: Flow): Flow { + if (!('steps' in flow)) { + return flow; + } + + const { steps: _steps, ...rest } = flow; + return rest as Flow; +} + +/** + * Normalize flow before saving: ensure nodes/edges exist for scheduler compatibility. + * Only generates DAG from steps if nodes are missing or empty. + * Preserves existing nodes/edges to avoid overwriting user edits. + * + * Also validates edges: removes edges referencing non-existent nodes to prevent + * runtime errors in scheduler's topoOrder calculation. + */ +function normalizeFlowForSave(flow: Flow): Flow { + const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0; + if (hasNodes) { + // Validate edges even when nodes exist (e.g., imported flows may have invalid edges) + const nodeIds = new Set(flow.nodes!.map((n) => n.id)); + if (Array.isArray(flow.edges) && flow.edges.length > 0) { + const validEdges = filterValidEdges(flow.edges, nodeIds); + if (validEdges.length !== flow.edges.length) { + // Some edges were invalid, return cleaned flow + return { ...flow, edges: validEdges }; + } + } + return flow; + } + + // No nodes - generate from steps + if (!Array.isArray(flow.steps) || flow.steps.length === 0) { + return flow; + } + + const dag = stepsToDAG(flow.steps); + if (dag.nodes.length === 0) { + return flow; + } + + const nodes: NodeBase[] = dag.nodes.map(toNodeBase); + const nodeIds = new Set(nodes.map((n) => n.id)); + + // Validate existing edges: only keep if from/to both exist in new nodes + // Otherwise fall back to generated chain edges + let edges: Edge[]; + if (Array.isArray(flow.edges) && flow.edges.length > 0) { + const validEdges = filterValidEdges(flow.edges, nodeIds); + edges = validEdges.length > 0 ? validEdges : dag.edges.map(toEdge); + } else { + edges = dag.edges.map(toEdge); + } + + return { + ...flow, + nodes, + edges, + }; +} + +export interface PublishedFlowInfo { + id: string; + slug: string; // for tool name `flow.` + version: number; + name: string; + description?: string; +} + +/** + * Check if a flow needs normalization (missing nodes when steps exist). + */ +function needsNormalization(flow: Flow): boolean { + const hasSteps = Array.isArray(flow.steps) && flow.steps.length > 0; + const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0; + return hasSteps && !hasNodes; +} + +/** + * Lazy normalize a flow if needed, and persist the normalized version. + * This handles legacy flows that only have steps but no nodes. + * After normalization, steps field is stripped before persist AND return. + */ +async function lazyNormalize(flow: Flow): Promise { + if (!needsNormalization(flow)) { + return stripStepsForSave(flow); + } + // Normalize and save back to storage (strip steps before persist) + const normalized = normalizeFlowForSave(flow); + const cleanFlow = stripStepsForSave(normalized); + try { + await IndexedDbStorage.flows.save(cleanFlow); + } catch (e) { + console.warn('lazyNormalize: failed to save normalized flow', e); + } + // Return DAG-only flow (do not leak deprecated steps to callers) + return cleanFlow; +} + +export async function listFlows(): Promise { + await ensureMigratedFromLocal(); + const flows = await IndexedDbStorage.flows.list(); + // Check if any flows need normalization + const needsNorm = flows.some(needsNormalization); + if (!needsNorm) { + // Strip steps from all flows before returning + return flows.map(stripStepsForSave); + } + // Normalize flows that need it (in parallel) + // lazyNormalize already returns DAG-only flow + const normalized = await Promise.all( + flows.map(async (flow) => { + if (needsNormalization(flow)) { + return lazyNormalize(flow); + } + return stripStepsForSave(flow); + }), + ); + return normalized; +} + +export async function getFlow(flowId: string): Promise { + await ensureMigratedFromLocal(); + const flow = await IndexedDbStorage.flows.get(flowId); + if (!flow) return undefined; + // Lazy normalize if needed (lazyNormalize returns DAG-only) + if (needsNormalization(flow)) { + return lazyNormalize(flow); + } + // Strip steps before returning + return stripStepsForSave(flow); +} + +export async function saveFlow(flow: Flow, options?: { notify?: boolean }): Promise { + await ensureMigratedFromLocal(); + // 1. Normalize: generate nodes/edges from steps if missing + // 2. Strip: remove deprecated steps field before persist + const normalizedFlow = normalizeFlowForSave(flow); + const cleanFlow = stripStepsForSave(normalizedFlow); + await IndexedDbStorage.flows.save(cleanFlow); + // Notify UI by default, can be disabled for batch operations + if (options?.notify !== false) { + notifyFlowsChanged(); + } +} + +export async function deleteFlow(flowId: string): Promise { + await ensureMigratedFromLocal(); + await IndexedDbStorage.flows.delete(flowId); + notifyFlowsChanged(); +} + +export async function listRuns(): Promise { + await ensureMigratedFromLocal(); + return await IndexedDbStorage.runs.list(); +} + +export async function appendRun(record: RunRecord): Promise { + await ensureMigratedFromLocal(); + const runs = await IndexedDbStorage.runs.list(); + runs.push(record); + // Trim to keep last 10 runs per flowId to avoid unbounded growth + try { + const byFlow = new Map(); + for (const r of runs) { + const list = byFlow.get(r.flowId) || []; + list.push(r); + byFlow.set(r.flowId, list); + } + const merged: RunRecord[] = []; + for (const [, arr] of byFlow.entries()) { + arr.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()); + const last = arr.slice(Math.max(0, arr.length - 10)); + merged.push(...last); + } + await IndexedDbStorage.runs.replaceAll(merged); + } catch (e) { + console.warn('appendRun: trim failed, saving all', e); + await IndexedDbStorage.runs.replaceAll(runs); + } +} + +export async function listPublished(): Promise { + await ensureMigratedFromLocal(); + return await IndexedDbStorage.published.list(); +} + +export async function publishFlow(flow: Flow, slug?: string): Promise { + await ensureMigratedFromLocal(); + const info: PublishedFlowInfo = { + id: flow.id, + slug: slug || toSlug(flow.name) || flow.id, + version: flow.version, + name: flow.name, + description: flow.description, + }; + await IndexedDbStorage.published.save(info); + return info; +} + +export async function unpublishFlow(flowId: string): Promise { + await ensureMigratedFromLocal(); + await IndexedDbStorage.published.delete(flowId); +} + +export function toSlug(name: string): string { + return (name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') + .slice(0, 64); +} + +export async function exportFlow(flowId: string): Promise { + const flow = await getFlow(flowId); + if (!flow) throw new Error('flow not found'); + return JSON.stringify(flow, null, 2); +} + +export async function exportAllFlows(): Promise { + const flows = await listFlows(); + return JSON.stringify({ flows }, null, 2); +} + +/** + * Import flows from JSON string. + * + * Supported formats: + * 1. Array of flows: [...flows] + * 2. Object with flows array: { flows: [...] } + * 3. Single flow with steps: { id, steps: [...] } + * 4. Single flow with nodes (new format): { id, nodes: [...], edges?: [...] } + * + * Flows are normalized on save (steps → nodes if needed). + */ +export async function importFlowFromJson(json: string): Promise { + await ensureMigratedFromLocal(); + const parsed = JSON.parse(json); + + // Detect candidates from various formats + const candidates: unknown[] = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.flows) + ? parsed.flows + : parsed?.id && (Array.isArray(parsed?.steps) || Array.isArray(parsed?.nodes)) + ? [parsed] + : []; + + if (!candidates.length) { + throw new Error('invalid flow json: no flows found'); + } + + const nowIso = new Date().toISOString(); + const flowsToImport: Flow[] = []; + + for (const raw of candidates) { + if (!raw || typeof raw !== 'object') { + throw new Error('invalid flow json: flow must be an object'); + } + + const f = raw as Record; + const id = String(f.id || '').trim(); + if (!id) { + throw new Error('invalid flow json: missing id'); + } + + // Normalize fields with sensible defaults + const name = typeof f.name === 'string' && f.name.trim() ? f.name : id; + const version = Number.isFinite(Number(f.version)) ? Number(f.version) : 1; + + // Handle meta with proper timestamps + const existingMeta = + f.meta && typeof f.meta === 'object' ? (f.meta as Record) : {}; + const createdAt = typeof existingMeta.createdAt === 'string' ? existingMeta.createdAt : nowIso; + + // Build flow object - preserve steps only if present (for normalize) + // saveFlow() will normalize (steps→nodes) then strip steps before persist + const flow: Flow = { + ...(f as object), + id, + name, + version, + meta: { + ...existingMeta, + createdAt, + updatedAt: nowIso, + }, + } as Flow; + + // Preserve steps for normalization if present in import data + if (Array.isArray(f.steps) && f.steps.length > 0) { + flow.steps = f.steps as Flow['steps']; + } + + flowsToImport.push(flow); + } + + // Save all flows (normalize on save) + // Disable individual notifications to avoid flooding UI during batch import + for (const f of flowsToImport) { + await saveFlow(f, { notify: false }); + } + + // Send single notification after all flows are imported + notifyFlowsChanged(); + + return flowsToImport; +} + +// Scheduling support +export type ScheduleType = 'once' | 'interval' | 'daily'; +export interface FlowSchedule { + id: string; // schedule id + flowId: string; + type: ScheduleType; + enabled: boolean; + // when: ISO string for 'once'; HH:mm for 'daily'; minutes for 'interval' + when: string; + // optional variables to pass when running + args?: Record; +} + +export async function listSchedules(): Promise { + await ensureMigratedFromLocal(); + return await IndexedDbStorage.schedules.list(); +} + +export async function saveSchedule(s: FlowSchedule): Promise { + await ensureMigratedFromLocal(); + await IndexedDbStorage.schedules.save(s); +} + +export async function removeSchedule(scheduleId: string): Promise { + await ensureMigratedFromLocal(); + await IndexedDbStorage.schedules.delete(scheduleId); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/index.ts b/app/chrome-extension/entrypoints/background/record-replay/index.ts new file mode 100644 index 0000000..1f60351 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/index.ts @@ -0,0 +1,504 @@ +import { BACKGROUND_MESSAGE_TYPES, CONTENT_MESSAGE_TYPES } from '@/common/message-types'; +import { Flow } from './types'; +import { + listFlows, + saveFlow, + getFlow, + deleteFlow, + publishFlow, + unpublishFlow, + exportFlow, + exportAllFlows, + importFlowFromJson, + listSchedules, + saveSchedule, + removeSchedule, + type FlowSchedule, +} from './flow-store'; +import { listRuns } from './flow-store'; +import { STORAGE_KEYS } from '@/common/constants'; +import { listTriggers, saveTrigger, deleteTrigger, type FlowTrigger } from './trigger-store'; +import { runFlow } from './flow-runner'; +import { RecorderManager } from './recording/recorder-manager'; +import { recordingSession } from './recording/session-manager'; +// Browser/content listeners are initialized via RecorderManager.init + +// design note: background listener for record & replay; delegates recording to dedicated modules + +// Alarm helpers for schedules +async function rescheduleAlarms() { + const schedules = await listSchedules(); + // Clear existing rr_schedule_* alarms + const alarms = await chrome.alarms.getAll(); + await Promise.all( + alarms + .filter((a) => a.name && a.name.startsWith('rr_schedule_')) + .map((a) => chrome.alarms.clear(a.name)), + ); + for (const s of schedules) { + if (!s.enabled) continue; + const name = `rr_schedule_${s.id}`; + if (s.type === 'interval') { + const minutes = Math.max(1, Math.floor(Number(s.when) || 0)); + await chrome.alarms.create(name, { periodInMinutes: minutes }); + } else if (s.type === 'once') { + const whenMs = Date.parse(s.when); + if (Number.isFinite(whenMs)) await chrome.alarms.create(name, { when: whenMs }); + } else if (s.type === 'daily') { + // daily HH:mm local time + const [hh, mm] = String(s.when || '00:00') + .split(':') + .map((x) => Number(x)); + const now = new Date(); + const next = new Date(); + next.setHours(hh || 0, mm || 0, 0, 0); + if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1); + await chrome.alarms.create(name, { when: next.getTime(), periodInMinutes: 24 * 60 }); + } + } +} + +// legacy injection helpers removed — use recording/content-injection when needed + +async function startRecording(meta?: Partial): Promise<{ success: boolean; error?: string }> { + return await RecorderManager.start(meta); +} + +async function stopRecording(): Promise<{ success: boolean; flow?: Flow; error?: string }> { + return await RecorderManager.stop(); +} + +export function initRecordReplayListeners() { + // Storage state sync is handled within session manager and recorder manager + // On startup, re-schedule alarms + rescheduleAlarms().catch(() => {}); + // Initialize trigger engine (contextMenus/commands/url/dom) + initTriggerEngine().catch(() => {}); + // Initialize recorder manager (wires browser and content listeners) + RecorderManager.init().catch(() => {}); + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + try { + // rr_recorder_event 交由 ContentMessageHandler 处理 + switch (message?.type) { + case BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING: { + startRecording(message.meta) + .then(sendResponse) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING: { + stopRecording() + .then(sendResponse) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_PAUSE_RECORDING: { + RecorderManager.pause() + .then(sendResponse) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_RESUME_RECORDING: { + RecorderManager.resume() + .then(sendResponse) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_GET_RECORDING_STATUS: { + const status = recordingSession.getStatus(); + const session = recordingSession.getSession(); + sendResponse({ + success: true, + status, + sessionId: session.sessionId, + originTabId: session.originTabId, + }); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS: { + listFlows() + .then((flows) => sendResponse({ success: true, flows })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_GET_FLOW: { + getFlow(message.flowId) + .then((flow) => sendResponse({ success: !!flow, flow })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_DELETE_FLOW: { + deleteFlow(message.flowId) + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_PUBLISH_FLOW: { + getFlow(message.flowId) + .then(async (flow) => { + if (!flow) return sendResponse({ success: false, error: 'flow not found' }); + await publishFlow(flow, message.slug); + sendResponse({ success: true }); + }) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_UNPUBLISH_FLOW: { + unpublishFlow(message.flowId) + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW: { + getFlow(message.flowId) + .then(async (flow) => { + if (!flow) return sendResponse({ success: false, error: 'flow not found' }); + const result = await runFlow(flow, message.options || {}); + sendResponse({ success: true, result }); + }) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW: { + const flow = message.flow as Flow; + if (!flow || !flow.id) { + sendResponse({ success: false, error: 'invalid flow' }); + return true; + } + saveFlow(flow) + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_EXPORT_FLOW: { + exportFlow(message.flowId) + .then((json) => sendResponse({ success: true, json })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_EXPORT_ALL: { + exportAllFlows() + .then((json) => sendResponse({ success: true, json })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_IMPORT_FLOW: { + importFlowFromJson(message.json) + .then((flows) => sendResponse({ success: true, imported: flows.length, flows })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_LIST_RUNS: { + listRuns() + .then((runs) => sendResponse({ success: true, runs })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_LIST_TRIGGERS: { + listTriggers() + .then((triggers) => sendResponse({ success: true, triggers })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER: { + const t = message.trigger as FlowTrigger; + if (!t || !t.id || !t.type || !t.flowId) { + sendResponse({ success: false, error: 'invalid trigger' }); + return true; + } + saveTrigger(t) + .then(async () => { + await refreshTriggers(); + sendResponse({ success: true }); + }) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: { + const id = String(message.id || ''); + if (!id) { + sendResponse({ success: false, error: 'invalid id' }); + return true; + } + deleteTrigger(id) + .then(async () => { + await refreshTriggers(); + sendResponse({ success: true }); + }) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS: { + refreshTriggers() + .then(() => sendResponse({ success: true })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES: { + listSchedules() + .then((s) => sendResponse({ success: true, schedules: s })) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW: { + const s = message.schedule as FlowSchedule; + if (!s || !s.id || !s.flowId) { + sendResponse({ success: false, error: 'invalid schedule' }); + return true; + } + saveSchedule(s) + .then(async () => { + await rescheduleAlarms(); + sendResponse({ success: true }); + }) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + case BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW: { + const scheduleId = String(message.scheduleId || ''); + if (!scheduleId) { + sendResponse({ success: false, error: 'invalid scheduleId' }); + return true; + } + removeSchedule(scheduleId) + .then(async () => { + await rescheduleAlarms(); + sendResponse({ success: true }); + }) + .catch((e) => sendResponse({ success: false, error: e?.message || String(e) })); + return true; + } + } + } catch (err) { + sendResponse({ success: false, error: (err as any)?.message || String(err) }); + } + return false; + }); + + // Trigger engine: contextMenus/commands/url/dom + if ((chrome as any).contextMenus?.onClicked?.addListener) { + chrome.contextMenus.onClicked.addListener(async (info) => { + try { + const triggers = await listTriggers(); + const t = triggers.find( + (x) => x.type === 'contextMenu' && (x as any).menuId === info.menuItemId, + ); + if (!t || t.enabled === false) return; + const flow = await getFlow(t.flowId); + if (!flow) return; + await runFlow(flow, { args: t.args || {}, returnLogs: false }); + } catch {} + }); + } + chrome.commands.onCommand.addListener(async (command) => { + try { + const triggers = await listTriggers(); + const t = triggers.find((x) => x.type === 'command' && (x as any).commandKey === command); + if (!t || t.enabled === false) return; + const flow = await getFlow(t.flowId); + if (!flow) return; + await runFlow(flow, { args: t.args || {}, returnLogs: false }); + } catch {} + }); + chrome.webNavigation.onCommitted.addListener(async (details) => { + try { + if (details.frameId !== 0) return; + const url = details.url || ''; + // Ensure core content scripts are injected for this tab (pre-heat for replay) + await ensureCoreInjected(details.tabId); + // Ensure DOM observer is active on this tab (if triggers exist) + try { + const { [STORAGE_KEYS.RR_TRIGGERS]: stored } = + (await chrome.storage.local.get(STORAGE_KEYS.RR_TRIGGERS)) || {}; + const triggers: any[] = Array.isArray(stored) ? stored : []; + const domTriggers = triggers + .filter((x) => x.type === 'dom' && x.enabled !== false) + .map((x: any) => ({ + id: x.id, + selector: x.selector, + appear: x.appear !== false, + once: x.once !== false, + debounceMs: x.debounceMs ?? 800, + })); + if (typeof details.tabId === 'number') { + try { + await chrome.scripting.executeScript({ + target: { tabId: details.tabId, allFrames: true }, + files: ['inject-scripts/dom-observer.js'], + world: 'ISOLATED', + } as any); + await chrome.tabs.sendMessage(details.tabId, { + action: 'set_dom_triggers', + triggers: domTriggers, + } as any); + } catch {} + } + } catch {} + const triggers = await listTriggers(); + const list = triggers.filter((x) => x.type === 'url' && x.enabled !== false) as any[]; + for (const t of list) { + if (matchUrl(url, (t as any).match || [])) { + const flow = await getFlow(t.flowId); + if (!flow) continue; + await runFlow(flow, { args: t.args || {}, returnLogs: false }); + } + } + } catch {} + }); + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + try { + if (message && message.action === 'dom_trigger_fired') { + const id = message.triggerId; + listTriggers().then(async (arr) => { + const t = arr.find((x) => x.id === id && x.type === 'dom'); + if (!t || t.enabled === false) return; + const flow = await getFlow(t.flowId); + if (!flow) return; + await runFlow(flow, { args: t.args || {}, returnLogs: false }); + }); + sendResponse({ ok: true }); + return true; + } + } catch {} + return false; + }); +} + +function matchUrl( + u: string, + rules: Array<{ kind: 'url' | 'domain' | 'path'; value: string }>, +): boolean { + try { + const url = new URL(u); + for (const r of rules || []) { + const v = String(r.value || ''); + if (r.kind === 'url' && u.startsWith(v)) return true; + if (r.kind === 'domain' && url.hostname.includes(v)) return true; + if (r.kind === 'path' && url.pathname.startsWith(v)) return true; + } + } catch {} + return false; +} + +// Track context menu IDs created by record-replay to avoid removing other menus +const rrContextMenuIds = new Set(); + +async function refreshContextMenus(triggers: FlowTrigger[]) { + if (!(chrome as any).contextMenus?.create) return; + + // Remove only our own menu items + await removeRecordReplayMenus(); + + // Create menus for enabled context menu triggers + for (const t of triggers) { + if (t.type !== 'contextMenu' || t.enabled === false) continue; + const id = `rr_menu_${t.id}`; + (t as any).menuId = id; + + try { + await chrome.contextMenus.create({ + id, + title: (t as any).title || '运行工作流', + contexts: (t as any).contexts || ['all'], + }); + rrContextMenuIds.add(id); + } catch (err) { + console.warn('[RecordReplay] Failed to create context menu:', err); + } + } +} + +async function removeRecordReplayMenus() { + if (!(chrome as any).contextMenus?.remove) { + rrContextMenuIds.clear(); + return; + } + + const pending = Array.from(rrContextMenuIds.values()).map((id) => + chrome.contextMenus.remove(id).catch(() => {}), + ); + + if (pending.length) await Promise.all(pending); + rrContextMenuIds.clear(); +} + +async function refreshTriggers() { + try { + const triggers = await listTriggers(); + await refreshContextMenus(triggers); + await chrome.storage.local.set({ [STORAGE_KEYS.RR_TRIGGERS]: triggers }); + const domTriggers = triggers + .filter((x) => x.type === 'dom' && x.enabled !== false) + .map((x: any) => ({ + id: x.id, + selector: x.selector, + appear: x.appear !== false, + once: x.once !== false, + debounceMs: x.debounceMs ?? 800, + })); + const tabs = await chrome.tabs.query({}); + for (const t of tabs) { + if (!t.id) continue; + try { + await chrome.scripting.executeScript({ + target: { tabId: t.id, allFrames: true }, + files: ['inject-scripts/dom-observer.js'], + world: 'ISOLATED', + } as any); + await chrome.tabs.sendMessage(t.id, { + action: 'set_dom_triggers', + triggers: domTriggers, + } as any); + } catch {} + } + } catch {} +} + +// Backward-compatible init function; initialize all trigger-related hooks/state +async function initTriggerEngine() { + await refreshTriggers(); +} + +// Ensure core content scripts are present for a tab after navigation +async function ensureCoreInjected(tabId?: number) { + try { + if (typeof tabId !== 'number') return; + // Ping accessibility helper + const ok = await pingTab(tabId, CONTENT_MESSAGE_TYPES.ACCESSIBILITY_TREE_HELPER_PING); + if (!ok) { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['inject-scripts/inject-bridge.js', 'inject-scripts/accessibility-tree-helper.js'], + world: 'ISOLATED', + } as any); + } + } catch {} +} + +async function pingTab(tabId: number, action: string): Promise { + try { + const resp: any = await chrome.tabs.sendMessage(tabId, { action } as any); + if (!resp) return false; + // Helpers generally respond { status: 'pong' } or { ok: true } + return resp.status === 'pong' || resp.ok === true; + } catch { + return false; + } +} + +// Alarm listener executes scheduled flows +chrome.alarms.onAlarm.addListener(async (alarm) => { + try { + if (!alarm?.name || !alarm.name.startsWith('rr_schedule_')) return; + const id = alarm.name.slice('rr_schedule_'.length); + const schedules = await listSchedules(); + const s = schedules.find((x) => x.id === id && x.enabled); + if (!s) return; + const flow = await getFlow(s.flowId); + if (!flow) return; + await runFlow(flow, { args: s.args || {}, returnLogs: false }); + } catch (e) { + // swallow to not spam logs + } +}); diff --git a/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts b/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts new file mode 100644 index 0000000..2586abb --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts @@ -0,0 +1,252 @@ +/** + * Legacy Step Types for Record & Replay + * + * This file contains the legacy Step type system that is being phased out + * in favor of the DAG-based execution model (nodes/edges). + * + * These types are kept for: + * 1. Backward compatibility with existing flows that use steps array + * 2. Recording pipeline that still produces Step[] output + * 3. Legacy node handlers in nodes/ directory + * + * New code should use the Action type system from ./actions/types.ts instead. + * + * Migration status: P4 phase 1 - types extracted, re-exported from types.ts + */ + +import { STEP_TYPES } from '@/common/step-types'; + +// ============================================================================= +// Legacy Selector Types +// ============================================================================= + +export type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text'; + +export interface SelectorCandidate { + type: SelectorType; + value: string; // literal selector or text/aria expression + weight?: number; // user-adjustable priority; higher first +} + +export interface TargetLocator { + ref?: string; // ephemeral ref from read_page + candidates: SelectorCandidate[]; // ordered by priority +} + +// ============================================================================= +// Legacy Step Types +// ============================================================================= + +export type StepType = (typeof STEP_TYPES)[keyof typeof STEP_TYPES]; + +export interface StepBase { + id: string; + type: StepType; + timeoutMs?: number; // default 10000 + retry?: { count: number; intervalMs: number; backoff?: 'none' | 'exp' }; + screenshotOnFail?: boolean; // default true +} + +export interface StepClick extends StepBase { + type: 'click' | 'dblclick'; + target: TargetLocator; + before?: { scrollIntoView?: boolean; waitForSelector?: boolean }; + after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean }; +} + +export interface StepFill extends StepBase { + type: 'fill'; + target: TargetLocator; + value: string; // may contain {var} +} + +export interface StepTriggerEvent extends StepBase { + type: 'triggerEvent'; + target: TargetLocator; + event: string; // e.g. 'input', 'change', 'mouseover' + bubbles?: boolean; + cancelable?: boolean; +} + +export interface StepSetAttribute extends StepBase { + type: 'setAttribute'; + target: TargetLocator; + name: string; + value?: string; // when omitted and remove=true, remove attribute + remove?: boolean; +} + +export interface StepScreenshot extends StepBase { + type: 'screenshot'; + selector?: string; + fullPage?: boolean; + saveAs?: string; // variable name to store base64 +} + +export interface StepSwitchFrame extends StepBase { + type: 'switchFrame'; + frame?: { index?: number; urlContains?: string }; +} + +export interface StepLoopElements extends StepBase { + type: 'loopElements'; + selector: string; + saveAs?: string; // list var name + itemVar?: string; // default 'item' + subflowId: string; +} + +export interface StepKey extends StepBase { + type: 'key'; + keys: string; // e.g. "Backspace Enter" or "cmd+a" + target?: TargetLocator; // optional focus target +} + +export interface StepScroll extends StepBase { + type: 'scroll'; + mode: 'element' | 'offset' | 'container'; + target?: TargetLocator; // when mode = element / container + offset?: { x?: number; y?: number }; +} + +export interface StepDrag extends StepBase { + type: 'drag'; + start: TargetLocator; + end: TargetLocator; + path?: Array<{ x: number; y: number }>; // sampled trajectory +} + +export interface StepWait extends StepBase { + type: 'wait'; + condition: + | { selector: string; visible?: boolean } + | { text: string; appear?: boolean } + | { navigation: true } + | { networkIdle: true } + | { sleep: number }; +} + +export interface StepAssert extends StepBase { + type: 'assert'; + assert: + | { exists: string } + | { visible: string } + | { textPresent: string } + | { attribute: { selector: string; name: string; equals?: string; matches?: string } }; + // 失败策略:stop=失败即停(默认)、warn=仅告警并继续、retry=触发重试机制 + failStrategy?: 'stop' | 'warn' | 'retry'; +} + +export interface StepScript extends StepBase { + type: 'script'; + world?: 'MAIN' | 'ISOLATED'; + code: string; // user script string + when?: 'before' | 'after'; +} + +export interface StepIf extends StepBase { + type: 'if'; + // condition supports: { var: string; equals?: any } | { expression: string } + condition: any; +} + +export interface StepForeach extends StepBase { + type: 'foreach'; + listVar: string; + itemVar?: string; + subflowId: string; +} + +export interface StepWhile extends StepBase { + type: 'while'; + condition: any; + subflowId: string; + maxIterations?: number; +} + +export interface StepHttp extends StepBase { + type: 'http'; + method?: string; + url: string; + headers?: Record; + body?: any; + formData?: any; + saveAs?: string; + assign?: Record; +} + +export interface StepExtract extends StepBase { + type: 'extract'; + selector?: string; + attr?: string; // 'text'|'textContent' to read text + js?: string; // custom JS that returns value + saveAs: string; +} + +export interface StepOpenTab extends StepBase { + type: 'openTab'; + url?: string; + newWindow?: boolean; +} + +export interface StepSwitchTab extends StepBase { + type: 'switchTab'; + tabId?: number; + urlContains?: string; + titleContains?: string; +} + +export interface StepCloseTab extends StepBase { + type: 'closeTab'; + tabIds?: number[]; + url?: string; +} + +export interface StepNavigate extends StepBase { + type: 'navigate'; + url: string; +} + +export interface StepHandleDownload extends StepBase { + type: 'handleDownload'; + filenameContains?: string; + saveAs?: string; + waitForComplete?: boolean; +} + +export interface StepExecuteFlow extends StepBase { + type: 'executeFlow'; + flowId: string; + inline?: boolean; + args?: Record; +} + +// ============================================================================= +// Step Union Type +// ============================================================================= + +export type Step = + | StepClick + | StepFill + | StepTriggerEvent + | StepSetAttribute + | StepScreenshot + | StepSwitchFrame + | StepLoopElements + | StepKey + | StepScroll + | StepDrag + | StepWait + | StepAssert + | StepScript + | StepIf + | StepForeach + | StepWhile + | StepNavigate + | StepHttp + | StepExtract + | StepOpenTab + | StepSwitchTab + | StepCloseTab + | StepHandleDownload + | StepExecuteFlow; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts new file mode 100644 index 0000000..3a23bbd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts @@ -0,0 +1,91 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepAssert } from '../types'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const assertNode: NodeRuntime = { + validate: (step) => { + const s = step as any; + const ok = !!s.assert; + if (ok && s.assert && 'attribute' in s.assert) { + const a = s.assert.attribute || {}; + if (!a.selector || !a.name) + return { ok: false, errors: ['assert.attribute: 需提供 selector 与 name'] }; + } + return ok ? { ok } : { ok, errors: ['缺少断言条件'] }; + }, + run: async (ctx: ExecCtx, step: StepAssert) => { + const s = expandTemplatesDeep(step as StepAssert, ctx.vars) as any; + const failStrategy = (s as any).failStrategy || 'stop'; + const fail = (msg: string) => { + if (failStrategy === 'warn') { + ctx.logger({ stepId: (step as any).id, status: 'warning', message: msg }); + return { alreadyLogged: true } as any; + } + throw new Error(msg); + }; + if ('textPresent' in s.assert) { + const text = (s.assert as any).textPresent; + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.COMPUTER, + args: { action: 'wait', text, appear: true, timeout: (step as any).timeoutMs || 5000 }, + }); + if ((res as any).isError) return fail('assert text failed'); + } else if ('exists' in s.assert || 'visible' in s.assert) { + const selector = (s.assert as any).exists || (s.assert as any).visible; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const firstTab = tabs && tabs[0]; + const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined; + if (!tabId) return fail('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const ensured: any = (await chrome.tabs.sendMessage( + tabId, + { + action: 'ensureRefForSelector', + selector, + } as any, + { frameId: ctx.frameId } as any, + )) as any; + if (!ensured || !ensured.success) return fail('assert selector not found'); + if ('visible' in s.assert) { + const rect = ensured && ensured.center ? ensured.center : null; + if (!rect) return fail('assert visible failed'); + } + } else if ('attribute' in s.assert) { + const { selector, name, equals, matches } = (s.assert as any).attribute || {}; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const firstTab = tabs && tabs[0]; + const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined; + if (!tabId) return fail('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const resp: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'getAttributeForSelector', selector, name } as any, + { frameId: ctx.frameId } as any, + )) as any; + if (!resp || !resp.success) return fail('assert attribute: element not found'); + const actual: string | null = resp.value ?? null; + if (equals !== undefined && equals !== null) { + const expected = String(equals); + if (String(actual) !== String(expected)) + return fail( + `assert attribute equals failed: ${name} actual=${String(actual)} expected=${String(expected)}`, + ); + } else if (matches !== undefined && matches !== null) { + try { + const re = new RegExp(String(matches)); + if (!re.test(String(actual))) + return fail( + `assert attribute matches failed: ${name} actual=${String(actual)} regex=${String(matches)}`, + ); + } catch { + return fail(`invalid regex for attribute matches: ${String(matches)}`); + } + } else { + if (actual == null) return fail(`assert attribute failed: ${name} missing`); + } + } + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/click.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/click.ts new file mode 100644 index 0000000..6a4757d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/click.ts @@ -0,0 +1,108 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { Step } from '../types'; +import { locateElement } from '../selector-engine'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const clickNode: NodeRuntime = { + validate: (step) => { + const ok = !!(step as any).target?.candidates?.length; + return ok ? { ok } : { ok, errors: ['缺少目标选择器候选'] }; + }, + run: async (ctx: ExecCtx, step: Step) => { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const firstTab = tabs && tabs[0]; + const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined; + if (!tabId) throw new Error('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const located = await locateElement(tabId, s.target, ctx.frameId); + const frameId = (located as any)?.frameId ?? ctx.frameId; + const first = s.target?.candidates?.[0]?.type; + const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : ''); + const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first; + if ((located as any)?.ref) { + const resolved: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'resolveRef', ref: (located as any).ref } as any, + { frameId } as any, + )) as any; + const rect = resolved?.rect; + if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible'); + } + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.CLICK, + args: { + ref: (located as any)?.ref || (step as any).target?.ref, + selector: !(located as any)?.ref + ? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value + : undefined, + waitForNavigation: false, + timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)), + frameId, + }, + }); + if ((res as any).isError) throw new Error('click failed'); + if (fallbackUsed) + ctx.logger({ + stepId: step.id, + status: 'success', + message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`, + fallbackUsed: true, + fallbackFrom: String(first), + fallbackTo: String(resolvedBy), + } as any); + return {} as ExecResult; + }, +}; + +export const dblclickNode: NodeRuntime = { + validate: clickNode.validate, + run: async (ctx: ExecCtx, step: Step) => { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const firstTab = tabs && tabs[0]; + const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined; + if (!tabId) throw new Error('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const located = await locateElement(tabId, s.target, ctx.frameId); + const frameId = (located as any)?.frameId ?? ctx.frameId; + const first = s.target?.candidates?.[0]?.type; + const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : ''); + const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first; + if ((located as any)?.ref) { + const resolved: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'resolveRef', ref: (located as any).ref } as any, + { frameId } as any, + )) as any; + const rect = resolved?.rect; + if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible'); + } + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.CLICK, + args: { + ref: (located as any)?.ref || (step as any).target?.ref, + selector: !(located as any)?.ref + ? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value + : undefined, + waitForNavigation: false, + timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)), + frameId, + double: true, + }, + }); + if ((res as any).isError) throw new Error('dblclick failed'); + if (fallbackUsed) + ctx.logger({ + stepId: step.id, + status: 'success', + message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`, + fallbackUsed: true, + fallbackFrom: String(first), + fallbackTo: String(resolvedBy), + } as any); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/conditional.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/conditional.ts new file mode 100644 index 0000000..7d08013 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/conditional.ts @@ -0,0 +1,55 @@ +import type { Step } from '../types'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const ifNode: NodeRuntime = { + validate: (step) => { + const s = step as any; + const hasBranches = Array.isArray(s.branches) && s.branches.length > 0; + const ok = hasBranches || !!s.condition; + return ok ? { ok } : { ok, errors: ['缺少条件或分支'] }; + }, + run: async (ctx: ExecCtx, step: Step) => { + const s: any = step; + if (Array.isArray(s.branches) && s.branches.length > 0) { + const evalExpr = (expr: string): boolean => { + const code = String(expr || '').trim(); + if (!code) return false; + try { + const fn = new Function( + 'vars', + 'workflow', + `try { return !!(${code}); } catch (e) { return false; }`, + ); + return !!fn(ctx.vars, ctx.vars); + } catch { + return false; + } + }; + for (const br of s.branches) { + if (br?.expr && evalExpr(String(br.expr))) + return { nextLabel: String(br.label || `case:${br.id || 'match'}`) } as ExecResult; + } + if ('else' in s) return { nextLabel: String(s.else || 'default') } as ExecResult; + return { nextLabel: 'default' } as ExecResult; + } + // legacy condition: { var/equals | expression } + try { + let result = false; + const cond = s.condition; + if (cond && typeof cond.expression === 'string' && cond.expression.trim()) { + const fn = new Function( + 'vars', + `try { return !!(${cond.expression}); } catch (e) { return false; }`, + ); + result = !!fn(ctx.vars); + } else if (cond && typeof cond.var === 'string') { + const v = ctx.vars[cond.var]; + if ('equals' in cond) result = String(v) === String(cond.equals); + else result = !!v; + } + return { nextLabel: result ? 'true' : 'false' } as ExecResult; + } catch { + return { nextLabel: 'false' } as ExecResult; + } + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/download-screenshot-attr-event-frame-loop.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/download-screenshot-attr-event-frame-loop.ts new file mode 100644 index 0000000..66dd37d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/download-screenshot-attr-event-frame-loop.ts @@ -0,0 +1,252 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { Step } from '../types'; +import { locateElement } from '../selector-engine'; + +export const handleDownloadNode: NodeRuntime = { + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const args: any = { + filenameContains: s.filenameContains || undefined, + timeoutMs: Math.max(1000, Math.min(Number(s.timeoutMs ?? 60000), 300000)), + waitForComplete: s.waitForComplete !== false, + }; + const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD, args }); + const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text; + try { + const payload = text ? JSON.parse(text) : null; + if (s.saveAs && payload && payload.download) ctx.vars[s.saveAs] = payload.download; + } catch {} + return {} as ExecResult; + }, +}; + +export const screenshotNode: NodeRuntime = { + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const args: any = { name: 'workflow', storeBase64: true }; + if (s.fullPage) args.fullPage = true; + if (s.selector && typeof s.selector === 'string' && s.selector.trim()) + args.selector = s.selector; + const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.SCREENSHOT, args }); + const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text; + try { + const payload = text ? JSON.parse(text) : null; + if (s.saveAs && payload && payload.base64Data) ctx.vars[s.saveAs] = payload.base64Data; + } catch {} + return {} as ExecResult; + }, +}; + +export const triggerEventNode: NodeRuntime = { + validate: (step) => { + const s: any = step; + const ok = !!s?.target?.candidates?.length && typeof s?.event === 'string' && s.event; + return ok ? { ok } : { ok, errors: ['缺少目标选择器或事件类型'] }; + }, + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const located = await locateElement(tabId, s.target, ctx.frameId); + const cssSelector = !(located as any)?.ref + ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value + : undefined; + let sel = cssSelector as string | undefined; + if (!sel && (located as any)?.ref) { + try { + const resolved: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'resolveRef', ref: (located as any).ref } as any, + { frameId: ctx.frameId } as any, + )) as any; + sel = resolved?.selector; + } catch {} + } + if (!sel) throw new Error('triggerEvent: selector not resolved'); + const world: any = 'MAIN'; + const ev = String(s.event || '').trim(); + const bubbles = s.bubbles !== false; + const cancelable = s.cancelable === true; + await chrome.scripting.executeScript({ + target: { + tabId, + frameIds: typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined, + } as any, + world, + func: (selector: string, type: string, bubbles: boolean, cancelable: boolean) => { + try { + const el = document.querySelector(selector); + if (!el) return false; + const e = new Event(type, { bubbles, cancelable }); + (el as any).dispatchEvent(e); + return true; + } catch (e) { + return false; + } + }, + args: [sel, ev, !!bubbles, !!cancelable], + } as any); + return {} as ExecResult; + }, +}; + +export const setAttributeNode: NodeRuntime = { + validate: (step) => { + const s: any = step; + const ok = !!s?.target?.candidates?.length && typeof s?.name === 'string' && s.name; + return ok ? { ok } : { ok, errors: ['需提供目标选择器与属性名'] }; + }, + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const located = await locateElement(tabId, s.target, ctx.frameId); + const frameId = (located as any)?.frameId ?? ctx.frameId; + const cssSelector = !(located as any)?.ref + ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value + : undefined; + let sel = cssSelector as string | undefined; + if (!sel && (located as any)?.ref) { + try { + const resolved: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'resolveRef', ref: (located as any).ref } as any, + { frameId } as any, + )) as any; + sel = resolved?.selector; + } catch {} + } + if (!sel) throw new Error('setAttribute: selector not resolved'); + const world: any = 'MAIN'; + const name = String(s.name || ''); + const value = s.value; + const remove = s.remove === true; + await chrome.scripting.executeScript({ + target: { tabId, frameIds: typeof frameId === 'number' ? [frameId] : undefined } as any, + world, + func: (selector: string, name: string, value: any, remove: boolean) => { + try { + const el = document.querySelector(selector) as any; + if (!el) return false; + if (remove) el.removeAttribute(name); + else el.setAttribute(name, String(value ?? '')); + return true; + } catch { + return false; + } + }, + args: [sel, name, value, remove], + } as any); + return {} as ExecResult; + }, +}; + +export const switchFrameNode: NodeRuntime = { + run: async (ctx, step) => { + const s: any = step; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const frames = await chrome.webNavigation.getAllFrames({ tabId }); + if (!Array.isArray(frames) || frames.length === 0) { + ctx.frameId = undefined; + return {} as ExecResult; + } + let target: any | undefined; + const idx = Number(s?.frame?.index ?? NaN); + if (Number.isFinite(idx)) { + const list = frames.filter((f) => f.frameId !== 0); + target = list[Math.max(0, Math.min(list.length - 1, idx))]; + } + const urlContains = String(s?.frame?.urlContains || '').trim(); + if (!target && urlContains) + target = frames.find((f) => typeof f.url === 'string' && f.url.includes(urlContains)); + if (!target) ctx.frameId = undefined; + else ctx.frameId = target.frameId; + try { + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + } catch {} + ctx.logger({ + stepId: (step as any).id, + status: 'success', + message: `frameId=${String(ctx.frameId ?? 'top')}`, + } as any); + return {} as ExecResult; + }, +}; + +export const loopElementsNode: NodeRuntime = { + validate: (step) => { + const s: any = step; + const ok = + typeof s?.selector === 'string' && + s.selector && + typeof s?.subflowId === 'string' && + s.subflowId; + return ok ? { ok } : { ok, errors: ['需提供 selector 与 subflowId'] }; + }, + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const world: any = 'MAIN'; + const selector = String(s.selector || ''); + const res = await chrome.scripting.executeScript({ + target: { + tabId, + frameIds: typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined, + } as any, + world, + func: (sel: string) => { + try { + const list = Array.from(document.querySelectorAll(sel)); + const toCss = (node: Element) => { + try { + if ((node as HTMLElement).id) { + const idSel = `#${CSS.escape((node as HTMLElement).id)}`; + if (document.querySelectorAll(idSel).length === 1) return idSel; + } + } catch {} + let path = ''; + let current: Element | null = node; + while (current && current.tagName !== 'BODY') { + let part = current.tagName.toLowerCase(); + const parentEl: Element | null = current.parentElement; + if (parentEl) { + const siblings = Array.from(parentEl.children).filter( + (c) => (c as any).tagName === current!.tagName, + ); + if (siblings.length > 1) { + const idx = siblings.indexOf(current) + 1; + part += `:nth-of-type(${idx})`; + } + } + path = path ? `${part} > ${path}` : part; + current = parentEl; + } + return path ? `body > ${path}` : 'body'; + }; + return list.map(toCss); + } catch (e) { + return []; + } + }, + args: [selector], + } as any); + const arr: string[] = (res && Array.isArray(res[0]?.result) ? res[0].result : []) as any; + const listVar = String(s.saveAs || 'elements'); + const itemVar = String(s.itemVar || 'item'); + ctx.vars[listVar] = arr; + return { + control: { kind: 'foreach', listVar, itemVar, subflowId: String(s.subflowId) }, + } as any; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/drag.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/drag.ts new file mode 100644 index 0000000..6d35c50 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/drag.ts @@ -0,0 +1,42 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepDrag } from '../types'; +import { locateElement } from '../selector-engine'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const dragNode: NodeRuntime = { + run: async (_ctx, step: StepDrag) => { + const s = step as StepDrag; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + let startRef: string | undefined; + let endRef: string | undefined; + try { + if (typeof tabId === 'number') { + const locatedStart = await locateElement(tabId, (s as any).start); + const locatedEnd = await locateElement(tabId, (s as any).end); + startRef = (locatedStart as any)?.ref || (s as any).start.ref; + endRef = (locatedEnd as any)?.ref || (s as any).end.ref; + } + } catch {} + let startCoordinates: { x: number; y: number } | undefined; + let endCoordinates: { x: number; y: number } | undefined; + if ((!startRef || !endRef) && Array.isArray((s as any).path) && (s as any).path.length >= 2) { + startCoordinates = { x: Number((s as any).path[0].x), y: Number((s as any).path[0].y) }; + const last = (s as any).path[(s as any).path.length - 1]; + endCoordinates = { x: Number(last.x), y: Number(last.y) }; + } + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.COMPUTER, + args: { + action: 'left_click_drag', + startRef, + ref: endRef, + startCoordinates, + coordinates: endCoordinates, + }, + }); + if ((res as any).isError) throw new Error('drag failed'); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts new file mode 100644 index 0000000..65510f8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts @@ -0,0 +1,92 @@ +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const executeFlowNode: NodeRuntime = { + validate: (step) => { + const s: any = step; + const ok = typeof s.flowId === 'string' && !!s.flowId; + return ok ? { ok } : { ok, errors: ['需提供 flowId'] }; + }, + run: async (ctx: ExecCtx, step) => { + const s: any = step; + const { getFlow } = await import('../flow-store'); + const flow = await getFlow(String(s.flowId)); + if (!flow) throw new Error('referenced flow not found'); + const inline = s.inline !== false; // default inline + if (!inline) { + const { runFlow } = await import('../flow-runner'); + await runFlow(flow, { args: s.args || {}, returnLogs: false }); + return {} as ExecResult; + } + const { defaultEdgesOnly, topoOrder, mapDagNodeToStep, waitForNetworkIdle, waitForNavigation } = + await import('../rr-utils'); + const vars = ctx.vars; + if (s.args && typeof s.args === 'object') Object.assign(vars, s.args); + + // DAG is required - flow-store guarantees nodes/edges via normalization + const nodes = ((flow as any).nodes || []) as any[]; + const edges = ((flow as any).edges || []) as any[]; + if (nodes.length === 0) { + throw new Error( + 'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.', + ); + } + const defaultEdges = defaultEdgesOnly(edges as any); + const order = topoOrder(nodes as any, defaultEdges as any); + const stepsToRun: any[] = order.map((n) => mapDagNodeToStep(n as any)); + for (const st of stepsToRun) { + const t0 = Date.now(); + const maxRetries = Math.max(0, (st as any).retry?.count ?? 0); + const baseInterval = Math.max(0, (st as any).retry?.intervalMs ?? 0); + let attempt = 0; + const doDelay = async (i: number) => { + const delay = + baseInterval > 0 + ? (st as any).retry?.backoff === 'exp' + ? baseInterval * Math.pow(2, i) + : baseInterval + : 0; + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + }; + while (true) { + try { + const beforeInfo = await (async () => { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + return { url: tab?.url || '', status: (tab as any)?.status || '' }; + })(); + const { executeStep } = await import('../nodes'); + const result = await executeStep(ctx as any, st as any); + if ((st.type === 'click' || st.type === 'dblclick') && (st as any).after) { + const after = (st as any).after as any; + if (after.waitForNavigation) + await waitForNavigation((st as any).timeoutMs, beforeInfo.url); + else if (after.waitForNetworkIdle) + await waitForNetworkIdle(Math.min((st as any).timeoutMs || 5000, 120000), 1200); + } + if (!result?.alreadyLogged) + ctx.logger({ stepId: st.id, status: 'success', tookMs: Date.now() - t0 } as any); + break; + } catch (e: any) { + if (attempt < maxRetries) { + ctx.logger({ + stepId: st.id, + status: 'retrying', + message: e?.message || String(e), + } as any); + await doDelay(attempt); + attempt += 1; + continue; + } + ctx.logger({ + stepId: st.id, + status: 'failed', + message: e?.message || String(e), + tookMs: Date.now() - t0, + } as any); + throw e; + } + } + } + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/extract.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/extract.ts new file mode 100644 index 0000000..b277542 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/extract.ts @@ -0,0 +1,47 @@ +import type { StepExtract } from '../types'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const extractNode: NodeRuntime = { + run: async (ctx: ExecCtx, step: StepExtract) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + let value: any = null; + if (s.js && String(s.js).trim()) { + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: (code: string) => { + try { + return (0, eval)(code); + } catch (e) { + return null; + } + }, + args: [String(s.js)], + } as any); + value = result; + } else if (s.selector) { + const attr = String(s.attr || 'text'); + const sel = String(s.selector); + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: (selector: string, attr: string) => { + try { + const el = document.querySelector(selector) as any; + if (!el) return null; + if (attr === 'text' || attr === 'textContent') return (el.textContent || '').trim(); + return el.getAttribute ? el.getAttribute(attr) : null; + } catch { + return null; + } + }, + args: [sel, attr], + } as any); + value = result; + } + if (s.saveAs) ctx.vars[s.saveAs] = value; + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/fill.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/fill.ts new file mode 100644 index 0000000..843b88b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/fill.ts @@ -0,0 +1,116 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepFill } from '../types'; +import { locateElement } from '../selector-engine'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const fillNode: NodeRuntime = { + validate: (step) => { + const ok = !!(step as any).target?.candidates?.length && 'value' in (step as any); + return ok ? { ok } : { ok, errors: ['缺少目标选择器候选或输入值'] }; + }, + run: async (ctx: ExecCtx, step: StepFill) => { + const s: any = step; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const firstTab = tabs && tabs[0]; + const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined; + if (!tabId) throw new Error('Active tab not found'); + await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); + const located = await locateElement(tabId, s.target, ctx.frameId); + const frameId = (located as any)?.frameId ?? ctx.frameId; + const first = s.target?.candidates?.[0]?.type; + const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : ''); + const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first; + const interpolate = (v: any) => + typeof v === 'string' + ? v.replace(/\{([^}]+)\}/g, (_m, k) => (ctx.vars[k] ?? '').toString()) + : v; + const value = interpolate(s.value); + if ((located as any)?.ref) { + const resolved: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'resolveRef', ref: (located as any).ref } as any, + { frameId } as any, + )) as any; + const rect = resolved?.rect; + if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible'); + } + const cssSelector = !(located as any)?.ref + ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value + : undefined; + if (cssSelector) { + try { + const attr: any = (await chrome.tabs.sendMessage( + tabId, + { action: 'getAttributeForSelector', selector: cssSelector, name: 'type' } as any, + { frameId } as any, + )) as any; + const typeName = (attr && attr.value ? String(attr.value) : '').toLowerCase(); + if (typeName === 'file') { + const uploadRes = await handleCallTool({ + name: TOOL_NAMES.BROWSER.FILE_UPLOAD, + args: { selector: cssSelector, filePath: String(value ?? '') }, + }); + if ((uploadRes as any).isError) throw new Error('file upload failed'); + if (fallbackUsed) + ctx.logger({ + stepId: (step as any).id, + status: 'success', + message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`, + fallbackUsed: true, + fallbackFrom: String(first), + fallbackTo: String(resolvedBy), + } as any); + return {} as ExecResult; + } + } catch {} + } + try { + if (cssSelector) + await handleCallTool({ + name: TOOL_NAMES.BROWSER.INJECT_SCRIPT, + args: { + type: 'MAIN', + jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});} }catch(e){}`, + }, + }); + } catch {} + try { + if ((located as any)?.ref) + await chrome.tabs.sendMessage( + tabId, + { action: 'focusByRef', ref: (located as any).ref } as any, + { frameId } as any, + ); + else if (cssSelector) + await handleCallTool({ + name: TOOL_NAMES.BROWSER.INJECT_SCRIPT, + args: { + type: 'MAIN', + jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`, + }, + }); + } catch {} + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.FILL, + args: { + ref: (located as any)?.ref || (s as any).target?.ref, + selector: cssSelector, + value, + frameId, + }, + }); + if ((res as any).isError) throw new Error('fill failed'); + if (fallbackUsed) + ctx.logger({ + stepId: (step as any).id, + status: 'success', + message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`, + fallbackUsed: true, + fallbackFrom: String(first), + fallbackTo: String(resolvedBy), + } as any); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/http.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/http.ts new file mode 100644 index 0000000..3a6b1bf --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/http.ts @@ -0,0 +1,28 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepHttp } from '../types'; +import { applyAssign, expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const httpNode: NodeRuntime = { + run: async (ctx: ExecCtx, step: StepHttp) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.NETWORK_REQUEST, + args: { + url: s.url, + method: s.method || 'GET', + headers: s.headers || {}, + body: s.body, + formData: s.formData, + }, + }); + const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text; + try { + const payload = text ? JSON.parse(text) : null; + if (s.saveAs && payload !== undefined) ctx.vars[s.saveAs] = payload; + if (s.assign && payload !== undefined) applyAssign(ctx.vars, payload, s.assign); + } catch {} + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/index.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/index.ts new file mode 100644 index 0000000..882de36 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/index.ts @@ -0,0 +1,65 @@ +import type { Step } from '../types'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; +import { clickNode, dblclickNode } from './click'; +import { fillNode } from './fill'; +import { httpNode } from './http'; +import { extractNode } from './extract'; +import { scriptNode } from './script'; +import { openTabNode, switchTabNode, closeTabNode } from './tabs'; +import { scrollNode } from './scroll'; +import { dragNode } from './drag'; +import { keyNode } from './key'; +import { waitNode } from './wait'; +import { assertNode } from './assert'; +import { navigateNode } from './navigate'; +import { ifNode } from './conditional'; +import { STEP_TYPES } from 'chrome-mcp-shared'; +import { foreachNode, whileNode } from './loops'; +import { executeFlowNode } from './execute-flow'; +import { + handleDownloadNode, + screenshotNode, + triggerEventNode, + setAttributeNode, + switchFrameNode, + loopElementsNode, +} from './download-screenshot-attr-event-frame-loop'; + +const registry = new Map>([ + [STEP_TYPES.CLICK, clickNode], + [STEP_TYPES.DBLCLICK, dblclickNode], + [STEP_TYPES.FILL, fillNode], + [STEP_TYPES.HTTP, httpNode], + [STEP_TYPES.EXTRACT, extractNode], + [STEP_TYPES.SCRIPT, scriptNode], + [STEP_TYPES.OPEN_TAB, openTabNode], + [STEP_TYPES.SWITCH_TAB, switchTabNode], + [STEP_TYPES.CLOSE_TAB, closeTabNode], + [STEP_TYPES.SCROLL, scrollNode], + [STEP_TYPES.DRAG, dragNode], + [STEP_TYPES.KEY, keyNode], + [STEP_TYPES.WAIT, waitNode], + [STEP_TYPES.ASSERT, assertNode], + [STEP_TYPES.NAVIGATE, navigateNode], + [STEP_TYPES.IF, ifNode], + [STEP_TYPES.FOREACH, foreachNode], + [STEP_TYPES.WHILE, whileNode], + [STEP_TYPES.EXECUTE_FLOW, executeFlowNode], + [STEP_TYPES.HANDLE_DOWNLOAD, handleDownloadNode], + [STEP_TYPES.SCREENSHOT, screenshotNode], + [STEP_TYPES.TRIGGER_EVENT, triggerEventNode], + [STEP_TYPES.SET_ATTRIBUTE, setAttributeNode], + [STEP_TYPES.SWITCH_FRAME, switchFrameNode], + [STEP_TYPES.LOOP_ELEMENTS, loopElementsNode], +]); + +export async function executeStep(ctx: ExecCtx, step: Step): Promise { + const rt = registry.get(step.type); + if (!rt) throw new Error(`unsupported step type: ${String(step.type)}`); + const v = rt.validate ? rt.validate(step) : { ok: true }; + if (!v.ok) throw new Error((v.errors || []).join(', ') || 'validation failed'); + const out = await rt.run(ctx, step); + return out || {}; +} + +export type { ExecCtx, ExecResult, NodeRuntime } from './types'; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/key.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/key.ts new file mode 100644 index 0000000..adb31b4 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/key.ts @@ -0,0 +1,31 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepKey } from '../types'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const keyNode: NodeRuntime = { + run: async (ctx, step: StepKey) => { + const s = expandTemplatesDeep(step as StepKey, ctx.vars) as StepKey; + const args: { keys: string; frameId?: number; selector?: string } = { keys: s.keys }; + + // Support target selector for focusing before key input + if (s.target && s.target.candidates?.length) { + const selector = s.target.candidates[0]?.value; + if (selector) { + args.selector = selector; + } + } + + if (typeof ctx.frameId === 'number') { + args.frameId = ctx.frameId; + } + + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.KEYBOARD, + args, + }); + if ((res as any).isError) throw new Error('key failed'); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts new file mode 100644 index 0000000..2cf3740 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts @@ -0,0 +1,47 @@ +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; +import { ENGINE_CONSTANTS } from '../engine/constants'; + +export const foreachNode: NodeRuntime = { + validate: (step) => { + const s = step as any; + const ok = + typeof s.listVar === 'string' && s.listVar && typeof s.subflowId === 'string' && s.subflowId; + return ok ? { ok } : { ok, errors: ['foreach: 需提供 listVar 与 subflowId'] }; + }, + run: async (_ctx: ExecCtx, step) => { + const s: any = step; + const itemVar = typeof s.itemVar === 'string' && s.itemVar ? s.itemVar : 'item'; + return { + control: { + kind: 'foreach', + listVar: String(s.listVar), + itemVar, + subflowId: String(s.subflowId), + concurrency: Math.max( + 1, + Math.min(ENGINE_CONSTANTS.MAX_FOREACH_CONCURRENCY, Number(s.concurrency ?? 1)), + ), + }, + } as ExecResult; + }, +}; + +export const whileNode: NodeRuntime = { + validate: (step) => { + const s = step as any; + const ok = !!s.condition && typeof s.subflowId === 'string' && s.subflowId; + return ok ? { ok } : { ok, errors: ['while: 需提供 condition 与 subflowId'] }; + }, + run: async (_ctx: ExecCtx, step) => { + const s: any = step; + const max = Math.max(1, Math.min(10000, Number(s.maxIterations ?? 100))); + return { + control: { + kind: 'while', + condition: s.condition, + subflowId: String(s.subflowId), + maxIterations: max, + }, + } as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/navigate.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/navigate.ts new file mode 100644 index 0000000..dc6e182 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/navigate.ts @@ -0,0 +1,17 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { Step } from '../types'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const navigateNode: NodeRuntime = { + validate: (step) => { + const ok = !!(step as any).url; + return ok ? { ok } : { ok, errors: ['缺少 URL'] }; + }, + run: async (_ctx: ExecCtx, step: Step) => { + const url = (step as any).url; + const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url } }); + if ((res as any).isError) throw new Error('navigate failed'); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/script.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/script.ts new file mode 100644 index 0000000..a9f33c5 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/script.ts @@ -0,0 +1,32 @@ +import type { StepScript } from '../types'; +import { expandTemplatesDeep, applyAssign } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const scriptNode: NodeRuntime = { + run: async (ctx: ExecCtx, step: StepScript) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + if (s.when === 'after') return { deferAfterScript: s } as ExecResult; + const world = s.world || 'ISOLATED'; + const code = String(s.code || ''); + if (!code.trim()) return {} as ExecResult; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined; + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId, frameIds } as any, + func: (userCode: string) => { + try { + return (0, eval)(userCode); + } catch { + return null; + } + }, + args: [code], + world: world as any, + } as any); + if (s.saveAs) ctx.vars[s.saveAs] = result; + if (s.assign && typeof s.assign === 'object') applyAssign(ctx.vars, result, s.assign); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/scroll.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/scroll.ts new file mode 100644 index 0000000..26a0a56 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/scroll.ts @@ -0,0 +1,45 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepScroll } from '../types'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const scrollNode: NodeRuntime = { + run: async (ctx, step: StepScroll) => { + const s = expandTemplatesDeep(step as StepScroll, ctx.vars); + const top = s.offset?.y ?? undefined; + const left = s.offset?.x ?? undefined; + const selectorFromTarget = (s as any).target?.candidates?.find( + (c: any) => c.type === 'css' || c.type === 'attr', + )?.value; + let code = ''; + if (s.mode === 'offset' && !(s as any).target) { + const t = top != null ? Number(top) : 'undefined'; + const l = left != null ? Number(left) : 'undefined'; + code = `try { window.scrollTo({ top: ${t}, left: ${l}, behavior: 'instant' }); } catch (e) {}`; + } else if (s.mode === 'element' && selectorFromTarget) { + code = `(() => { try { const el = document.querySelector(${JSON.stringify(selectorFromTarget)}); if (el) el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }); } catch (e) {} })();`; + } else if (s.mode === 'container' && selectorFromTarget) { + const t = top != null ? Number(top) : 'undefined'; + const l = left != null ? Number(left) : 'undefined'; + code = `(() => { try { const el = document.querySelector(${JSON.stringify(selectorFromTarget)}); if (el && typeof el.scrollTo === 'function') el.scrollTo({ top: ${t}, left: ${l}, behavior: 'instant' }); } catch (e) {} })();`; + } else { + const direction = top != null && Number(top) < 0 ? 'up' : 'down'; + const amount = 3; + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.COMPUTER, + args: { action: 'scroll', scrollDirection: direction, scrollAmount: amount }, + }); + if ((res as any).isError) throw new Error('scroll failed'); + return {} as ExecResult; + } + if (code) { + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.INJECT_SCRIPT, + args: { type: 'MAIN', jsScript: code }, + }); + if ((res as any).isError) throw new Error('scroll failed'); + } + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/tabs.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/tabs.ts new file mode 100644 index 0000000..0523f23 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/tabs.ts @@ -0,0 +1,49 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { handleCallTool } from '@/entrypoints/background/tools'; +import type { StepOpenTab, StepSwitchTab, StepCloseTab } from '../types'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const openTabNode: NodeRuntime = { + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + if (s.newWindow) await chrome.windows.create({ url: s.url || undefined, focused: true }); + else await chrome.tabs.create({ url: s.url || undefined, active: true }); + return {} as ExecResult; + }, +}; + +export const switchTabNode: NodeRuntime = { + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + let targetTabId: number | undefined = s.tabId; + if (!targetTabId) { + const tabs = await chrome.tabs.query({}); + const hit = tabs.find( + (t) => + (s.urlContains && (t.url || '').includes(String(s.urlContains))) || + (s.titleContains && (t.title || '').includes(String(s.titleContains))), + ); + targetTabId = (hit && hit.id) as number | undefined; + } + if (!targetTabId) throw new Error('switchTab: no matching tab'); + const res = await handleCallTool({ + name: TOOL_NAMES.BROWSER.SWITCH_TAB, + args: { tabId: targetTabId }, + }); + if ((res as any).isError) throw new Error('switchTab failed'); + return {} as ExecResult; + }, +}; + +export const closeTabNode: NodeRuntime = { + run: async (ctx, step) => { + const s: any = expandTemplatesDeep(step as any, ctx.vars); + const args: any = {}; + if (Array.isArray(s.tabIds) && s.tabIds.length) args.tabIds = s.tabIds; + if (s.url) args.url = s.url; + const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.CLOSE_TABS, args }); + if ((res as any).isError) throw new Error('closeTab failed'); + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts new file mode 100644 index 0000000..315888e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts @@ -0,0 +1,36 @@ +import type { RunLogEntry, Step, StepScript } from '../types'; + +/** + * Execution context for step execution. + * Contains runtime state that may change during flow execution. + */ +export interface ExecCtx { + /** Runtime variables accessible to steps */ + vars: Record; + /** Logger function for recording execution events */ + logger: (e: RunLogEntry) => void; + /** + * Current tab ID for this execution context. + * Managed by Scheduler, may change after openTab/switchTab actions. + */ + tabId?: number; + /** + * Current frame ID within the tab. + * Used for iframe targeting, 0 for main frame. + */ + frameId?: number; +} + +export interface ExecResult { + alreadyLogged?: boolean; + deferAfterScript?: StepScript | null; + nextLabel?: string; + control?: + | { kind: 'foreach'; listVar: string; itemVar: string; subflowId: string; concurrency?: number } + | { kind: 'while'; condition: any; subflowId: string; maxIterations: number }; +} + +export interface NodeRuntime { + validate?: (step: S) => { ok: boolean; errors?: string[] }; + run: (ctx: ExecCtx, step: S) => Promise; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts new file mode 100644 index 0000000..b0f13d6 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts @@ -0,0 +1,73 @@ +import type { StepWait } from '../types'; +import { waitForNetworkIdle, waitForNavigation } from '../rr-utils'; +import { expandTemplatesDeep } from '../rr-utils'; +import type { ExecCtx, ExecResult, NodeRuntime } from './types'; + +export const waitNode: NodeRuntime = { + validate: (step) => { + const ok = !!(step as any).condition; + return ok ? { ok } : { ok, errors: ['缺少等待条件'] }; + }, + run: async (ctx: ExecCtx, step: StepWait) => { + const s = expandTemplatesDeep(step as StepWait, ctx.vars); + const cond = (s as StepWait).condition as + | { selector: string; visible?: boolean } + | { text: string; appear?: boolean } + | { navigation: true } + | { networkIdle: true } + | { sleep: number }; + if ('text' in cond) { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined; + await chrome.scripting.executeScript({ + target: { tabId, frameIds }, + files: ['inject-scripts/wait-helper.js'], + world: 'ISOLATED', + } as any); + const resp: any = (await chrome.tabs.sendMessage( + tabId, + { + action: 'waitForText', + text: cond.text, + appear: (cond as any).appear !== false, + timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)), + } as any, + { frameId: ctx.frameId } as any, + )) as any; + if (!resp || resp.success !== true) throw new Error('wait text failed'); + } else if ('networkIdle' in cond) { + const total = Math.min(Math.max(1000, (s as any).timeoutMs || 5000), 120000); + const idle = Math.min(1500, Math.max(500, Math.floor(total / 3))); + await waitForNetworkIdle(total, idle); + } else if ('navigation' in cond) { + await waitForNavigation((s as any).timeoutMs); + } else if ('sleep' in cond) { + const ms = Math.max(0, Number(cond.sleep ?? 0)); + await new Promise((r) => setTimeout(r, ms)); + } else if ('selector' in cond) { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined; + await chrome.scripting.executeScript({ + target: { tabId, frameIds }, + files: ['inject-scripts/wait-helper.js'], + world: 'ISOLATED', + } as any); + const resp: any = (await chrome.tabs.sendMessage( + tabId, + { + action: 'waitForSelector', + selector: (cond as any).selector, + visible: (cond as any).visible !== false, + timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)), + } as any, + { frameId: ctx.frameId } as any, + )) as any; + if (!resp || resp.success !== true) throw new Error('wait selector failed'); + } + return {} as ExecResult; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts new file mode 100644 index 0000000..af1867e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts @@ -0,0 +1,77 @@ +import { addNavigationStep } from './flow-builder'; +import { STEP_TYPES } from '@/common/step-types'; +import { ensureRecorderInjected, broadcastControlToTab, REC_CMD } from './content-injection'; +import type { RecordingSessionManager } from './session-manager'; +import type { Step } from '../types'; + +export function initBrowserEventListeners(session: RecordingSessionManager): void { + chrome.tabs.onActivated.addListener(async (activeInfo) => { + try { + if (session.getStatus() !== 'recording') return; + const tabId = activeInfo.tabId; + await ensureRecorderInjected(tabId); + await broadcastControlToTab(tabId, REC_CMD.START); + // Track active tab for targeted STOP later + session.addActiveTab(tabId); + + const flow = session.getFlow(); + if (!flow) return; + const tab = await chrome.tabs.get(tabId); + const url = tab.url; + const step: Step = { + id: '', + type: STEP_TYPES.SWITCH_TAB, + ...(url ? { urlContains: url } : {}), + }; + session.appendSteps([step]); + } catch (e) { + console.warn('onActivated handler failed', e); + } + }); + + chrome.webNavigation.onCommitted.addListener(async (details) => { + try { + if (session.getStatus() !== 'recording') return; + if (details.frameId !== 0) return; + const tabId = details.tabId; + const t = details.transitionType; + const link = t === 'link'; + if (!link) { + const shouldRecord = + t === 'reload' || + t === 'typed' || + t === 'generated' || + t === 'auto_bookmark' || + t === 'keyword' || + // include form_submit to better capture Enter-to-search navigations + t === 'form_submit'; + if (shouldRecord) { + const tab = await chrome.tabs.get(tabId); + const url = tab.url || details.url; + const flow = session.getFlow(); + if (flow && url) addNavigationStep(flow, url); + } + } + await ensureRecorderInjected(tabId); + await broadcastControlToTab(tabId, REC_CMD.START); + // Track active tab for targeted STOP later + session.addActiveTab(tabId); + if (session.getFlow()) { + session.broadcastTimelineUpdate(); + } + } catch (e) { + console.warn('onCommitted handler failed', e); + } + }); + + // Remove closed tabs from the active set to avoid stale broadcasts + chrome.tabs.onRemoved.addListener((tabId) => { + try { + // Even if not recording, removing is harmless; keep guard for clarity + if (session.getStatus() !== 'recording') return; + session.removeActiveTab(tabId); + } catch (e) { + console.warn('onRemoved handler failed', e); + } + }); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts new file mode 100644 index 0000000..d2cedca --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts @@ -0,0 +1,95 @@ +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; + +// Avoid magic strings for recorder control commands +export type RecorderCmd = 'start' | 'stop' | 'pause' | 'resume'; +export const REC_CMD = { + START: 'start', + STOP: 'stop', + PAUSE: 'pause', + RESUME: 'resume', +} as const satisfies Record; + +const RECORDER_JS_SCRIPT = 'inject-scripts/recorder.js'; + +export async function ensureRecorderInjected(tabId: number): Promise { + // Discover frames (top + subframes) + let frames: Array<{ frameId: number } & Record> = []; + try { + const res = (await chrome.webNavigation.getAllFrames({ tabId })) as + | Array<{ frameId: number } & Record> + | null + | undefined; + frames = Array.isArray(res) ? res : []; + } catch { + // ignore and fallback to top frame only + } + if (frames.length === 0) frames = [{ frameId: 0 }]; + + const needRecorder: number[] = []; + await Promise.all( + frames.map(async (f) => { + const frameId = f.frameId ?? 0; + try { + const res = await chrome.tabs.sendMessage( + tabId, + { action: 'rr_recorder_ping' }, + { frameId }, + ); + const pong = res?.status === 'pong'; + if (!pong) needRecorder.push(frameId); + } catch { + needRecorder.push(frameId); + } + }), + ); + + if (needRecorder.length > 0) { + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds: needRecorder }, + files: [RECORDER_JS_SCRIPT], + world: 'ISOLATED', + }); + } catch { + // Fallback: try allFrames to cover dynamic/subframe changes; safe due to idempotent guard in recorder.js + try { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: [RECORDER_JS_SCRIPT], + world: 'ISOLATED', + }); + } catch { + // ignore injection failures per-tab + } + } + } +} + +export async function broadcastControlToTab( + tabId: number, + cmd: RecorderCmd, + meta?: unknown, +): Promise { + try { + const res = (await chrome.webNavigation.getAllFrames({ tabId })) as + | Array<{ frameId: number } & Record> + | null + | undefined; + const targets = Array.isArray(res) && res.length ? res : [{ frameId: 0 }]; + await Promise.all( + targets.map(async (f) => { + try { + await chrome.tabs.sendMessage( + tabId, + { action: TOOL_MESSAGE_TYPES.RR_RECORDER_CONTROL, cmd, meta }, + { frameId: f.frameId }, + ); + } catch { + // ignore per-frame send failure + } + }), + ); + } catch { + // ignore + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts new file mode 100644 index 0000000..4d964cd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts @@ -0,0 +1,78 @@ +import type { RecordingSessionManager } from './session-manager'; +import type { Step, VariableDef } from '../types'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; + +/** + * Initialize the content message handler for receiving steps and variables from content scripts. + * + * Supports the following payload kinds: + * - 'steps' | 'step': Append steps to the current flow + * - 'variables': Append variables to the current flow (for sensitive input handling) + * - 'finalize': Content script has finished flushing (used during stop barrier) + */ +export function initContentMessageHandler(session: RecordingSessionManager): void { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + try { + if (!message || message.type !== TOOL_MESSAGE_TYPES.RR_RECORDER_EVENT) return false; + + // Accept messages during 'recording' or 'stopping' states + // 'stopping' allows final steps to arrive during the drain phase + if (!session.canAcceptSteps()) { + sendResponse({ ok: true, ignored: true }); + return true; + } + + const flow = session.getFlow(); + if (!flow) { + sendResponse({ ok: true, ignored: true }); + return true; + } + + const payload = message?.payload || {}; + + // Handle steps + if (payload.kind === 'steps' || payload.kind === 'step') { + const steps: Step[] = Array.isArray(payload.steps) + ? (payload.steps as Step[]) + : payload.step + ? [payload.step as Step] + : []; + if (steps.length > 0) { + session.appendSteps(steps); + } + } + + // Handle variables (for sensitive input handling) + if (payload.kind === 'variables') { + const variables: VariableDef[] = Array.isArray(payload.variables) + ? (payload.variables as VariableDef[]) + : []; + if (variables.length > 0) { + session.appendVariables(variables); + } + } + + // Handle combined payload (steps + variables in one message) + if (payload.kind === 'batch') { + const steps: Step[] = Array.isArray(payload.steps) ? (payload.steps as Step[]) : []; + const variables: VariableDef[] = Array.isArray(payload.variables) + ? (payload.variables as VariableDef[]) + : []; + if (steps.length > 0) { + session.appendSteps(steps); + } + if (variables.length > 0) { + session.appendVariables(variables); + } + } + + // payload.kind === 'start'|'stop'|'finalize' are no-ops here (lifecycle handled elsewhere) + sendResponse({ ok: true }); + return true; + } catch (e) { + console.warn('ContentMessageHandler: processing message failed', e); + sendResponse({ ok: false, error: String((e as Error)?.message || e) }); + return true; + } + }); +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts new file mode 100644 index 0000000..4e87e04 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts @@ -0,0 +1,100 @@ +import type { Edge, Flow, NodeBase, Step } from '../types'; +import { STEP_TYPES } from '@/common/step-types'; +import { recordingSession } from './session-manager'; +import { mapStepToNodeConfig, EDGE_LABELS } from 'chrome-mcp-shared'; + +const WORKFLOW_VERSION = 1; + +/** + * Creates an initial flow structure for recording. + * Initializes with nodes/edges (DAG) instead of steps. + */ +export function createInitialFlow(meta?: Partial): Flow { + const timeStamp = new Date().toISOString(); + const flow: Flow = { + id: meta?.id || `flow_${Date.now()}`, + name: meta?.name || 'new_workflow', + version: WORKFLOW_VERSION, + nodes: [], + edges: [], + variables: [], + meta: { + createdAt: timeStamp, + updatedAt: timeStamp, + ...meta?.meta, + }, + }; + return flow; +} + +export function generateStepId(): string { + return `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; +} + +/** + * Appends a navigation step to the flow. + * Prefers centralized session append when recording is active. + * Falls back to direct DAG mutation (does NOT write flow.steps). + */ +export function addNavigationStep(flow: Flow, url: string): void { + const step: Step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url } as Step; + + // Prefer centralized session append (single broadcast path) when active and matching flow + const sessFlow = recordingSession.getFlow?.(); + if (recordingSession.getStatus?.() === 'recording' && sessFlow === flow) { + recordingSession.appendSteps([step]); + return; + } + + // Fallback: mutate DAG directly (do not write flow.steps) + appendNodeToFlow(flow, step); +} + +/** + * Appends a step as a node to the flow's DAG structure. + * Creates node and edge from the previous node if exists. + * + * Internal helper - rarely invoked in practice. During active recording, + * addNavigationStep() routes to session.appendSteps() which handles DAG + * maintenance, caching, and timeline broadcast. This fallback only runs + * when session is not active or flow reference doesn't match. + */ +function appendNodeToFlow(flow: Flow, step: Step): void { + // Ensure DAG arrays exist + if (!Array.isArray(flow.nodes)) flow.nodes = []; + if (!Array.isArray(flow.edges)) flow.edges = []; + + const prevNodeId = flow.nodes.length > 0 ? flow.nodes[flow.nodes.length - 1]?.id : undefined; + + // Create new node + const newNode: NodeBase = { + id: step.id, + type: step.type as NodeBase['type'], + config: mapStepToNodeConfig(step), + }; + flow.nodes.push(newNode); + + // Create edge from previous node if exists + if (prevNodeId) { + const edgeId = `e_${flow.edges.length}_${prevNodeId}_${step.id}`; + const edge: Edge = { + id: edgeId, + from: prevNodeId, + to: step.id, + label: EDGE_LABELS.DEFAULT, + }; + flow.edges.push(edge); + } + + // Update meta timestamp (with error tolerance like session-manager) + try { + const timeStamp = new Date().toISOString(); + if (!flow.meta) { + flow.meta = { createdAt: timeStamp, updatedAt: timeStamp }; + } else { + flow.meta.updatedAt = timeStamp; + } + } catch { + // ignore meta update errors to not block recording + } +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts new file mode 100644 index 0000000..ee89344 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts @@ -0,0 +1,307 @@ +import type { Flow } from '../types'; +import { saveFlow } from '../flow-store'; +import { broadcastControlToTab, ensureRecorderInjected, REC_CMD } from './content-injection'; +import { recordingSession as session } from './session-manager'; +import { createInitialFlow, addNavigationStep } from './flow-builder'; +import { initBrowserEventListeners } from './browser-event-listener'; +import { initContentMessageHandler } from './content-message-handler'; + +/** Timeout for waiting for the top-frame content script to acknowledge stop. */ +const STOP_BARRIER_TOP_TIMEOUT_MS = 5000; + +/** Best-effort stop timeout for subframes (keeps top-frame still listening). */ +const STOP_BARRIER_SUBFRAME_TIMEOUT_MS = 1500; + +/** Small grace period for in-flight messages after all ACKs. */ +const STOP_BARRIER_GRACE_MS = 150; + +/** Types for stop barrier results */ +interface StopAckStats { + ack: boolean; + steps: number; + variables: number; +} + +interface StopFrameAck { + frameId: number; + ack: boolean; + timedOut: boolean; + error?: string; + stats?: StopAckStats; +} + +interface StopTabBarrierResult { + tabId: number; + ok: boolean; + skipped?: boolean; + reason?: string; + top?: StopFrameAck; + subframes: StopFrameAck[]; +} + +/** + * List frameIds for a tab. Always includes 0 (main frame). + */ +async function listFrameIds(tabId: number): Promise { + try { + const res = await chrome.webNavigation.getAllFrames({ tabId }); + const ids = Array.isArray(res) + ? res.map((f) => f.frameId).filter((n) => typeof n === 'number') + : []; + if (!ids.includes(0)) ids.unshift(0); + return Array.from(new Set(ids)).sort((a, b) => a - b); + } catch { + return [0]; + } +} + +/** + * Send stop command to a specific frame and wait for acknowledgment. + */ +async function sendStopToFrameWithAck( + tabId: number, + sessionId: string, + frameId: number, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + const t = setTimeout(() => { + resolve({ frameId, ack: false, timedOut: true }); + }, timeoutMs); + + chrome.tabs + .sendMessage( + tabId, + { + action: REC_CMD.STOP, + sessionId, + requireAck: true, + }, + { frameId }, + ) + .then((response) => { + clearTimeout(t); + const ack = !!(response && response.ack); + const stats = response && response.stats ? (response.stats as StopAckStats) : undefined; + resolve({ frameId, ack, timedOut: false, stats }); + }) + .catch((err) => { + clearTimeout(t); + resolve({ frameId, ack: false, timedOut: false, error: String(err) }); + }); + }); +} + +/** + * Stop a tab with full barrier support. + * 1. Stop subframes first (so they can finalize and postMessage to top while top is still listening) + * 2. Stop the main frame (top) and wait for ACK + */ +async function stopTabWithBarrier(tabId: number, sessionId: string): Promise { + // If the tab is already gone, don't block stop. + try { + await chrome.tabs.get(tabId); + } catch { + return { tabId, ok: true, skipped: true, reason: 'tab not found', subframes: [] }; + } + + // Ensure recorder is available in frames (best-effort). + try { + await ensureRecorderInjected(tabId); + } catch {} + + const frameIds = await listFrameIds(tabId); + const subframeIds = frameIds.filter((id) => id !== 0); + + // Stop subframes first so they can finalize and postMessage to top while top is still listening. + const subframes = await Promise.all( + subframeIds.map((fid) => + sendStopToFrameWithAck(tabId, sessionId, fid, STOP_BARRIER_SUBFRAME_TIMEOUT_MS), + ), + ); + + // Stop the main frame (top) with longer timeout + const top = await sendStopToFrameWithAck(tabId, sessionId, 0, STOP_BARRIER_TOP_TIMEOUT_MS); + + return { tabId, ok: top.ack, top, subframes }; +} + +class RecorderManagerImpl { + private initialized = false; + + async init(): Promise { + if (this.initialized) return; + initBrowserEventListeners(session); + initContentMessageHandler(session); + this.initialized = true; + } + + async start(meta?: Partial): Promise<{ success: boolean; error?: string }> { + if (session.getStatus() !== 'idle') + return { success: false, error: 'Recording already active' }; + // Resolve active tab + const [active] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!active?.id) return { success: false, error: 'Active tab not found' }; + + // Initialize flow & session + const flow: Flow = createInitialFlow(meta); + await session.startSession(flow, active.id); + + // Ensure recorder available and start listening + await ensureRecorderInjected(active.id); + await broadcastControlToTab(active.id, REC_CMD.START, { + id: flow.id, + name: flow.name, + description: flow.description, + sessionId: session.getSession().sessionId, + }); + // Track active tab for targeted STOP broadcasts + session.addActiveTab(active.id); + + // Record first step + const url = active.url; + if (url) { + addNavigationStep(flow, url); + try { + await saveFlow(flow); + } catch (e) { + console.warn('RecorderManager: initial saveFlow failed', e); + } + } + + return { success: true }; + } + + /** + * Stop recording with reliable step collection using barrier protocol. + * + * Flow: + * 1. Transition to 'stopping' state (still accepts final steps) + * 2. For each tab: stop subframes first (best-effort), then stop main frame + * 3. Wait for main frame ACK (required) with timeout + * 4. Grace period for any final messages in flight + * 5. Finalize session and save flow with barrier metadata + * + * The barrier ensures: + * - All tabs have flushed their data before save + * - Subframes finalize to top before top stops + * - Barrier status is recorded in flow.meta for debugging + */ + async stop(): Promise<{ success: boolean; error?: string; flow?: Flow }> { + const currentStatus = session.getStatus(); + if (currentStatus === 'idle' || !session.getFlow()) { + return { success: false, error: 'No active recording' }; + } + + // Already stopping - don't double-stop + if (currentStatus === 'stopping') { + return { success: false, error: 'Stop already in progress' }; + } + + // Step 1: Transition to stopping state + const sessionId = session.beginStopping(); + const tabs = session.getActiveTabs(); + + // Step 2: Send stop commands to all tabs with full barrier support + // Each tab: stop subframes first, then stop main frame and wait for ACK + let results: StopTabBarrierResult[] = []; + try { + results = await Promise.all(tabs.map((tabId) => stopTabWithBarrier(tabId, sessionId))); + } catch (e) { + console.warn('RecorderManager: Error during stop broadcast:', e); + } + + // Step 3: Allow a small grace period for any final messages in flight + await new Promise((resolve) => setTimeout(resolve, STOP_BARRIER_GRACE_MS)); + + // Step 4: Finalize - clear session state and save with barrier metadata + const flow = await session.stopSession(); + const barrierOk = results.length === tabs.length && results.every((r) => r.ok || r.skipped); + const stoppedAt = new Date().toISOString(); + + if (flow) { + // Add barrier metadata to flow + try { + if (!flow.meta) flow.meta = { createdAt: stoppedAt, updatedAt: stoppedAt }; + const failed = results + .filter((r) => !r.ok || r.skipped || r.subframes.some((sf) => !sf.ack)) + .map((r) => ({ + tabId: r.tabId, + skipped: r.skipped || undefined, + reason: r.reason || undefined, + topTimedOut: r.top?.timedOut || undefined, + topError: r.top?.error || undefined, + subframesFailed: r.subframes.filter((sf) => !sf.ack).length || undefined, + })) + .slice(0, 20); // Limit to first 20 to avoid bloating metadata + + flow.meta.stopBarrier = { + ok: barrierOk, + sessionId, + stoppedAt, + failed: failed.length ? failed : undefined, + }; + } catch {} + + await saveFlow(flow); + } + + // Return with barrier status + if (!barrierOk) { + const failedTabs = results.filter((r) => !r.ok && !r.skipped).map((r) => r.tabId); + return { + success: true, // Flow is still saved, but with incomplete barrier + flow: flow || undefined, + error: failedTabs.length + ? `Stop barrier incomplete; missing ACK from tabs: ${failedTabs.join(', ')}` + : 'Stop barrier incomplete; missing ACK(s)', + }; + } + + return flow ? { success: true, flow } : { success: true }; + } + + /** + * Pause recording. Steps are not collected while paused. + */ + async pause(): Promise<{ success: boolean; error?: string }> { + if (session.getStatus() !== 'recording') { + return { success: false, error: 'Not currently recording' }; + } + + session.pause(); + + // Broadcast pause to all active tabs + const tabs = session.getActiveTabs(); + try { + await Promise.all(tabs.map((id) => broadcastControlToTab(id, REC_CMD.PAUSE))); + } catch (e) { + console.warn('RecorderManager: Error during pause broadcast:', e); + } + + return { success: true }; + } + + /** + * Resume recording after pause. + */ + async resume(): Promise<{ success: boolean; error?: string }> { + if (session.getStatus() !== 'paused') { + return { success: false, error: 'Not currently paused' }; + } + + session.resume(); + + // Broadcast resume to all active tabs + const tabs = session.getActiveTabs(); + try { + await Promise.all(tabs.map((id) => broadcastControlToTab(id, REC_CMD.RESUME))); + } catch (e) { + console.warn('RecorderManager: Error during resume broadcast:', e); + } + + return { success: true }; + } +} + +export const RecorderManager = new RecorderManagerImpl(); diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts new file mode 100644 index 0000000..7a8c1d3 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts @@ -0,0 +1,495 @@ +import type { Edge, Flow, NodeBase, Step, VariableDef } from '../types'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { NODE_TYPES } from '@/common/node-types'; +import { mapStepToNodeConfig, stepsToDAG, EDGE_LABELS } from 'chrome-mcp-shared'; + +/** + * Recording status state machine: + * - idle: No active recording + * - recording: Actively capturing user interactions + * - paused: Temporarily paused (UI can resume) + * - stopping: Draining final steps from content scripts before save + */ +export type RecordingStatus = 'idle' | 'recording' | 'paused' | 'stopping'; + +export interface RecordingSessionState { + sessionId: string; + status: RecordingStatus; + originTabId: number | null; + flow: Flow | null; + // Track tabs that have participated in this recording session + activeTabs: Set; + // Track which tabs have acknowledged stop command + stoppedTabs: Set; +} + +// Valid node types for type checking +const VALID_NODE_TYPES = new Set(Object.values(NODE_TYPES)); + +export class RecordingSessionManager { + private state: RecordingSessionState = { + sessionId: '', + status: 'idle', + originTabId: null, + flow: null, + activeTabs: new Set(), + stoppedTabs: new Set(), + }; + + // Session-level cache for incremental DAG sync (cleared on session start/stop) + // Note: stepIndexMap removed - we no longer write to flow.steps + private nodeIndexMap: Map = new Map(); + // Monotonic counter for edge id generation (avoids collision on delete/reorder) + private edgeSeq: number = 0; + + getStatus(): RecordingStatus { + return this.state.status; + } + + getSession(): Readonly { + return this.state; + } + + getFlow(): Flow | null { + return this.state.flow; + } + + getOriginTabId(): number | null { + return this.state.originTabId; + } + + addActiveTab(tabId: number): void { + if (typeof tabId === 'number') this.state.activeTabs.add(tabId); + } + + removeActiveTab(tabId: number): void { + this.state.activeTabs.delete(tabId); + } + + getActiveTabs(): number[] { + return Array.from(this.state.activeTabs); + } + + async startSession(flow: Flow, originTabId: number): Promise { + // Clear cache for fresh session + this.nodeIndexMap.clear(); + this.edgeSeq = 0; + + this.state = { + sessionId: `sess_${Date.now()}`, + status: 'recording', + originTabId, + flow, + activeTabs: new Set([originTabId]), + stoppedTabs: new Set(), + }; + + // Initialize caches from existing flow data (supports resume scenarios) + this.rebuildCaches(); + } + + /** + * Transition to stopping state. Content scripts can still send final steps. + * Returns the sessionId for barrier verification. + */ + beginStopping(): string { + if (this.state.status === 'idle') return ''; + this.state.status = 'stopping'; + this.state.stoppedTabs.clear(); + return this.state.sessionId; + } + + /** + * Mark a tab as having acknowledged the stop command. + * Returns true if all active tabs have stopped. + */ + markTabStopped(tabId: number): boolean { + this.state.stoppedTabs.add(tabId); + // Check if all active tabs have acknowledged + for (const activeTabId of this.state.activeTabs) { + if (!this.state.stoppedTabs.has(activeTabId)) { + return false; + } + } + return true; + } + + /** + * Check if we're in stopping state (still accepting final steps). + */ + isStopping(): boolean { + return this.state.status === 'stopping'; + } + + /** + * Check if we can accept steps (recording or stopping). + */ + canAcceptSteps(): boolean { + return this.state.status === 'recording' || this.state.status === 'stopping'; + } + + /** + * Transition to paused state. + */ + pause(): void { + if (this.state.status === 'recording') { + this.state.status = 'paused'; + } + } + + /** + * Resume from paused state. + */ + resume(): void { + if (this.state.status === 'paused') { + this.state.status = 'recording'; + } + } + + /** + * Finalize stop and clear session state. + */ + async stopSession(): Promise { + const flow = this.state.flow; + this.state.status = 'idle'; + this.state.flow = null; + this.state.originTabId = null; + this.state.activeTabs.clear(); + this.state.stoppedTabs.clear(); + // Clear cache + this.nodeIndexMap.clear(); + this.edgeSeq = 0; + return flow; + } + + updateFlow(mutator: (f: Flow) => void): void { + const f = this.state.flow; + if (!f) return; + mutator(f); + try { + (f.meta as any).updatedAt = new Date().toISOString(); + } catch (e) { + // ignore meta update errors + } + } + + /** + * Append or upsert steps to the flow with incremental DAG sync. + * Uses upsert semantics: if a step with the same id exists, update it in place. + * This ensures fill steps get their final value even after initial flush. + * + * DAG sync: maintains flow.nodes/edges during recording. + * - New step → create node + edge from previous node + * - Upsert step → update node.config and node.type + * - Invariant violation → fallback to linear DAG rebuild + * + * Note: flow.steps is no longer written. Nodes are the source of truth. + */ + appendSteps(steps: Step[]): void { + const f = this.state.flow; + if (!f || !Array.isArray(steps) || steps.length === 0) return; + + // Initialize arrays if missing + if (!Array.isArray(f.nodes)) f.nodes = []; + if (!Array.isArray(f.edges)) f.edges = []; + + // Legacy compatibility: if flow only has steps, initialize DAG from them once + if (f.nodes.length === 0 && Array.isArray(f.steps) && f.steps.length > 0) { + this.rebuildDagFromSteps(); + } + + const nodes = f.nodes; + const edges = f.edges; + + // Check invariants: edges must match linear chain + // If violated (e.g., imported flow, manual edit), rebuild linear chain + if (!this.checkDagInvariant(nodes, edges)) { + this.rechainEdges(); + } + + // Process each incoming step with upsert semantics + incremental DAG sync + let needsRebuild = false; + for (const step of steps) { + // Ensure step has an id + if (!step.id) { + step.id = `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + } + + const nodeIdx = this.nodeIndexMap.get(step.id); + if (nodeIdx !== undefined) { + // Upsert: update existing node in place + if (!nodes[nodeIdx]) { + needsRebuild = true; + continue; + } + nodes[nodeIdx] = { + ...nodes[nodeIdx], + type: this.toNodeType(step.type), + config: mapStepToNodeConfig(step), + }; + } else { + // Append: new node + const prevNodeId = nodes.length > 0 ? nodes[nodes.length - 1]?.id : undefined; + + // Create corresponding node + const newNode: NodeBase = { + id: step.id, + type: this.toNodeType(step.type), + config: mapStepToNodeConfig(step), + }; + nodes.push(newNode); + this.nodeIndexMap.set(step.id, nodes.length - 1); + + // Create edge from previous node (if exists) + if (prevNodeId) { + if (!this.nodeIndexMap.has(prevNodeId)) { + needsRebuild = true; + continue; + } + const edgeId = `e_${this.edgeSeq++}_${prevNodeId}_${step.id}`; + edges.push({ + id: edgeId, + from: prevNodeId, + to: step.id, + label: EDGE_LABELS.DEFAULT, + }); + } + } + } + + // Final invariant check: if any inconsistency detected, rebuild edges + if (needsRebuild || !this.checkDagInvariant(nodes, edges)) { + this.rechainEdges(); + } + + // Update meta timestamp + try { + if (f.meta) { + f.meta.updatedAt = new Date().toISOString(); + } + } catch { + // ignore meta update errors + } + + this.broadcastTimelineUpdate(); + } + + /** + * Convert step type to valid NodeType with fallback to SCRIPT. + * Logs a warning for unknown types to help detect upstream type drift. + */ + private toNodeType(stepType: string): NodeBase['type'] { + if (VALID_NODE_TYPES.has(stepType)) { + return stepType as NodeBase['type']; + } + console.warn(`[RecordingSession] Unknown step type "${stepType}", falling back to "script"`); + return NODE_TYPES.SCRIPT; + } + + /** + * Check DAG invariant for linear recording: + * - edges.length === max(0, nodes.length - 1) + * - Last edge (if exists) points to the last node + */ + private checkDagInvariant(nodes: NodeBase[], edges: Edge[]): boolean { + const nodeCount = nodes.length; + const expectedEdgeCount = Math.max(0, nodeCount - 1); + + // Check edge count matches expected linear chain + if (edges.length !== expectedEdgeCount) { + return false; + } + + // Check last edge points to last node (if edges exist) + if (edges.length > 0 && nodes.length > 0) { + const lastEdge = edges[edges.length - 1]; + const lastNodeId = nodes[nodes.length - 1]?.id; + if (lastEdge.to !== lastNodeId) { + return false; + } + } + + return true; + } + + /** + * Rebuild caches from current flow state. + * Called on session start and after DAG rebuild. + */ + private rebuildCaches(): void { + const f = this.state.flow; + if (!f) return; + + this.nodeIndexMap.clear(); + + if (Array.isArray(f.nodes)) { + for (let i = 0; i < f.nodes.length; i++) { + const id = f.nodes[i]?.id; + if (id) this.nodeIndexMap.set(id, i); + } + } + + // Sync edgeSeq to continue from current edge count (avoids id collision) + this.edgeSeq = Array.isArray(f.edges) ? f.edges.length : 0; + } + + /** + * Full DAG rebuild from legacy steps. + * Used when flow only has steps[] but no nodes[]. + */ + private rebuildDagFromSteps(): void { + const f = this.state.flow; + if (!f || !Array.isArray(f.steps) || f.steps.length === 0) return; + + const dag = stepsToDAG(f.steps); + + // Clear and repopulate nodes + if (!Array.isArray(f.nodes)) f.nodes = []; + f.nodes.length = 0; + for (const n of dag.nodes) { + f.nodes.push({ + id: n.id, + type: this.toNodeType(n.type), + config: n.config, + }); + } + + // Clear and repopulate edges + if (!Array.isArray(f.edges)) f.edges = []; + f.edges.length = 0; + for (const e of dag.edges) { + f.edges.push({ + id: e.id, + from: e.from, + to: e.to, + label: e.label, + }); + } + + // Rebuild caches + this.rebuildCaches(); + } + + /** + * Re-chain edges linearly according to current nodes order. + * Used when edge invariant is violated but nodes exist. + */ + private rechainEdges(): void { + const f = this.state.flow; + if (!f) return; + + if (!Array.isArray(f.nodes)) f.nodes = []; + if (!Array.isArray(f.edges)) f.edges = []; + + // Clear and re-chain edges + f.edges.length = 0; + for (let i = 0; i < f.nodes.length - 1; i++) { + const from = f.nodes[i].id; + const to = f.nodes[i + 1].id; + f.edges.push({ + id: `e_${i}_${from}_${to}`, + from, + to, + label: EDGE_LABELS.DEFAULT, + }); + } + + // Rebuild caches + this.rebuildCaches(); + } + + /** + * Append variables to the flow. Deduplicates by key. + */ + appendVariables(variables: VariableDef[]): void { + const f = this.state.flow; + if (!f || !Array.isArray(variables) || variables.length === 0) return; + + if (!f.variables) { + f.variables = []; + } + + // Deduplicate by key - newer definitions override older ones + const existingKeys = new Set(f.variables.map((v) => v.key)); + for (const v of variables) { + if (!v.key) continue; + if (existingKeys.has(v.key)) { + // Update existing variable + const idx = f.variables.findIndex((fv) => fv.key === v.key); + if (idx >= 0) { + f.variables[idx] = v; + } + } else { + f.variables.push(v); + existingKeys.add(v.key); + } + } + + // Update meta timestamp + try { + if (f.meta) { + f.meta.updatedAt = new Date().toISOString(); + } + } catch { + // ignore meta update errors + } + } + + /** + * Derive timeline steps from nodes for UI broadcast. + * This keeps protocol compatibility with recorder.js without storing steps. + */ + private getTimelineSteps(): Step[] { + const f = this.state.flow; + if (!f) return []; + + // Primary: derive from nodes + if (Array.isArray(f.nodes) && f.nodes.length > 0) { + return f.nodes.map((n) => { + const cfg = + n && typeof n.config === 'object' && n.config != null + ? (n.config as Record) + : {}; + // Important: id and type must override any values in config + // (config may contain 'type' for trigger nodes, etc.) + return { ...cfg, id: n.id, type: n.type } as Step; + }); + } + + // Legacy fallback: use steps if no nodes (shouldn't happen in normal recording) + if (Array.isArray(f.steps) && f.steps.length > 0) { + return f.steps; + } + + return []; + } + + // Broadcast timeline updates to relevant tabs (top-frame only) + broadcastTimelineUpdate(): void { + try { + // Derive steps from nodes for UI consumption (protocol unchanged) + const fullSteps = this.getTimelineSteps(); + if (fullSteps.length === 0) return; + + // Prefer broadcasting to all tabs that participated in this session, so timeline + // stays consistent when user switches across tabs/windows during a single session. + const targets = this.getActiveTabs(); + const list = + targets && targets.length + ? targets + : this.state.originTabId != null + ? [this.state.originTabId] + : []; + for (const tabId of list) { + chrome.tabs.sendMessage( + tabId, + { action: TOOL_MESSAGE_TYPES.RR_TIMELINE_UPDATE, steps: fullSteps }, + { frameId: 0 }, + ); + } + } catch {} + } +} + +// Singleton for wiring convenience +export const recordingSession = new RecordingSessionManager(); diff --git a/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts b/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts new file mode 100644 index 0000000..6a747f2 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts @@ -0,0 +1,253 @@ +// rr-utils.ts — shared helpers for record-replay runner +// Note: comments in English + +import { + TOOL_NAMES, + topoOrder as sharedTopoOrder, + mapNodeToStep as sharedMapNodeToStep, +} from 'chrome-mcp-shared'; +import type { Edge as DagEdge, NodeBase as DagNode, Step } from './types'; +import { handleCallTool } from '../tools'; +import { EDGE_LABELS } from 'chrome-mcp-shared'; + +export function applyAssign( + target: Record, + source: any, + assign: Record, +) { + const getByPath = (obj: any, path: string) => { + try { + const parts = path + .replace(/\[(\d+)\]/g, '.$1') + .split('.') + .filter(Boolean); + let cur = obj; + for (const p of parts) { + if (cur == null) return undefined; + cur = (cur as any)[p as any]; + } + return cur; + } catch { + return undefined; + } + }; + for (const [k, v] of Object.entries(assign || {})) { + target[k] = getByPath(source, String(v)); + } +} + +export function expandTemplatesDeep(value: T, scope: Record): T { + const replaceOne = (s: string) => + s.replace(/\{([^}]+)\}/g, (_m, k) => (scope[k] ?? '').toString()); + const walk = (v: any): any => { + if (v == null) return v; + if (typeof v === 'string') return replaceOne(v); + if (Array.isArray(v)) return v.map((x) => walk(x)); + if (typeof v === 'object') { + const out: any = {}; + for (const [k, val] of Object.entries(v)) out[k] = walk(val); + return out; + } + return v; + }; + return walk(value); +} + +export async function ensureTab(options: { + tabTarget?: 'current' | 'new'; + startUrl?: string; + refresh?: boolean; +}): Promise<{ tabId: number; url?: string }> { + const target = options.tabTarget || 'current'; + const startUrl = options.startUrl; + const isWebUrl = (u?: string | null) => !!u && /^(https?:|file:)/i.test(u); + + const tabs = await chrome.tabs.query({ currentWindow: true }); + const [active] = tabs.filter((t) => t.active); + + if (target === 'new') { + let urlToOpen = startUrl; + if (!urlToOpen) urlToOpen = isWebUrl(active?.url) ? active!.url! : 'about:blank'; + const created = await chrome.tabs.create({ url: urlToOpen, active: true }); + await new Promise((r) => setTimeout(r, 300)); + return { tabId: created.id!, url: created.url }; + } + + // current tab target + if (startUrl) { + await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: startUrl } }); + } else if (options.refresh) { + // only refresh if current tab is a web page + if (isWebUrl(active?.url)) + await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } }); + } + + // Re-evaluate active after potential navigation + const cur = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]; + let tabId = cur?.id; + let url = cur?.url; + + // If still on extension/internal page and no startUrl, try switch to an existing web tab + if (!isWebUrl(url) && !startUrl) { + const candidate = tabs.find((t) => isWebUrl(t.url)); + if (candidate?.id) { + await chrome.tabs.update(candidate.id, { active: true }); + tabId = candidate.id; + url = candidate.url; + } + } + return { tabId: tabId!, url }; +} + +export async function waitForNetworkIdle(totalTimeoutMs: number, idleThresholdMs: number) { + const deadline = Date.now() + Math.max(500, totalTimeoutMs); + const threshold = Math.max(200, idleThresholdMs); + while (Date.now() < deadline) { + await handleCallTool({ + name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START, + args: { + includeStatic: false, + // Ensure capture remains active until we explicitly stop it + maxCaptureTime: Math.min(60_000, Math.max(threshold + 500, 2_000)), + inactivityTimeout: 0, + }, + }); + await new Promise((r) => setTimeout(r, threshold + 200)); + const stopRes = await handleCallTool({ + name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP, + args: {}, + }); + const text = (stopRes as any)?.content?.find((c: any) => c.type === 'text')?.text; + try { + const json = text ? JSON.parse(text) : null; + const captureEnd = Number(json?.captureEndTime) || Date.now(); + const reqs: any[] = Array.isArray(json?.requests) ? json.requests : []; + const lastActivity = reqs.reduce( + (acc, r) => { + const t = Number(r.responseTime || r.requestTime || 0); + return t > acc ? t : acc; + }, + Number(json?.captureStartTime || 0), + ); + if (captureEnd - lastActivity >= threshold) return; // idle reached + } catch { + // ignore parse errors + } + await new Promise((r) => setTimeout(r, Math.min(500, threshold))); + } + throw new Error('wait for network idle timed out'); +} + +// Event-driven navigation wait helper +// Waits for top-frame navigation completion or SPA history updates on active tab. +// Falls back to short network idle on timeout. +export async function waitForNavigation(timeoutMs?: number, prevUrl?: string): Promise { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Active tab not found'); + const timeout = Math.max(1000, Math.min(timeoutMs || 15000, 30000)); + const startedAt = Date.now(); + + await new Promise((resolve, reject) => { + let done = false; + let timer: any = null; + const cleanup = () => { + try { + chrome.webNavigation.onCommitted.removeListener(onCommitted); + } catch {} + try { + chrome.webNavigation.onCompleted.removeListener(onCompleted); + } catch {} + try { + (chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.( + onHistoryStateUpdated, + ); + } catch {} + try { + chrome.tabs.onUpdated.removeListener(onTabUpdated); + } catch {} + if (timer) { + try { + clearTimeout(timer); + } catch {} + } + }; + const finish = () => { + if (done) return; + done = true; + cleanup(); + resolve(); + }; + const onCommitted = (details: any) => { + if ( + details && + details.tabId === tabId && + details.frameId === 0 && + details.timeStamp >= startedAt + ) { + // committed observed; we'll wait for completion or SPA fallback + } + }; + const onCompleted = (details: any) => { + if ( + details && + details.tabId === tabId && + details.frameId === 0 && + details.timeStamp >= startedAt + ) + finish(); + }; + const onHistoryStateUpdated = (details: any) => { + if ( + details && + details.tabId === tabId && + details.frameId === 0 && + details.timeStamp >= startedAt + ) + finish(); + }; + const onTabUpdated = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + if (updatedTabId !== tabId) return; + if (changeInfo.status === 'complete') finish(); + if (typeof changeInfo.url === 'string' && (!prevUrl || changeInfo.url !== prevUrl)) finish(); + }; + const onTimeout = async () => { + cleanup(); + try { + await waitForNetworkIdle(2000, 800); + resolve(); + } catch { + reject(new Error('navigation timeout')); + } + }; + + chrome.webNavigation.onCommitted.addListener(onCommitted); + chrome.webNavigation.onCompleted.addListener(onCompleted); + try { + (chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated); + } catch {} + chrome.tabs.onUpdated.addListener(onTabUpdated); + timer = setTimeout(onTimeout, timeout); + }); +} + +export function topoOrder(nodes: DagNode[], edges: DagEdge[]): DagNode[] { + return sharedTopoOrder(nodes, edges as any); +} + +// Helper: filter only default edges (no label or label === 'default') +export function defaultEdgesOnly(edges: DagEdge[] = []): DagEdge[] { + return (edges || []).filter((e) => !e.label || e.label === EDGE_LABELS.DEFAULT); +} + +export function mapDagNodeToStep(n: DagNode): Step { + const s: any = sharedMapNodeToStep(n as any); + if ((n as any)?.type === 'if') { + // forward extended conditional config for DAG mode + const cfg: any = (n as any).config || {}; + if (Array.isArray(cfg.branches)) s.branches = cfg.branches; + if ('else' in cfg) s.else = cfg.else; + if (cfg.condition && !s.condition) s.condition = cfg.condition; // backward-compat + } + return s as Step; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts b/app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts new file mode 100644 index 0000000..e9cec02 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts @@ -0,0 +1,185 @@ +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { TargetLocator, SelectorCandidate } from './types'; + +// design note: minimal selector engine that tries ref then candidates + +export interface LocatedElement { + ref?: string; + center?: { x: number; y: number }; + resolvedBy?: 'ref' | SelectorCandidate['type']; + frameId?: number; +} + +// Helper: decide whether selector is a composite cross-frame selector +function isCompositeSelector(sel: string): boolean { + return typeof sel === 'string' && sel.includes('|>'); +} + +// Helper: typed wrapper for chrome.tabs.sendMessage with optional frameId +async function sendToTab(tabId: number, message: any, frameId?: number): Promise { + if (typeof frameId === 'number') { + return await chrome.tabs.sendMessage(tabId, message, { frameId }); + } + return await chrome.tabs.sendMessage(tabId, message); +} + +// Helper: ensure ref for a selector, handling composite selectors and mapping frameId +async function ensureRefForSelector( + tabId: number, + selector: string, + frameId?: number, +): Promise<{ ref: string; center: { x: number; y: number }; frameId?: number } | null> { + try { + let ensured: any = null; + if (isCompositeSelector(selector)) { + // Always query top for composite; helper will bridge to child and return href + ensured = await sendToTab(tabId, { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector, + }); + } else { + ensured = await sendToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector }, + frameId, + ); + } + if (!ensured || !ensured.success || !ensured.ref || !ensured.center) return null; + // Map frameId when composite via returned href + let locFrameId: number | undefined = undefined; + if (isCompositeSelector(selector) && ensured.href) { + try { + const frames = (await chrome.webNavigation.getAllFrames({ tabId })) as any[]; + const match = frames?.find((f) => typeof f.url === 'string' && f.url === ensured.href); + if (match) locFrameId = match.frameId; + } catch {} + } + return { ref: ensured.ref, center: ensured.center, frameId: locFrameId }; + } catch { + return null; + } +} + +/** + * Try to resolve an element using ref or candidates via content scripts + */ +export async function locateElement( + tabId: number, + target: TargetLocator, + frameId?: number, +): Promise { + // 0) Fast path: try primary selector if provided + const primarySel = (target as any)?.selector ? String((target as any).selector).trim() : ''; + if (primarySel) { + const ensured = await ensureRefForSelector(tabId, primarySel, frameId); + if (ensured) return { ...ensured, resolvedBy: 'css' }; + } + + // 1) Non-text candidates first for stability (css/attr/aria/xpath) + const nonText = (target.candidates || []).filter((c) => c.type !== 'text'); + for (const c of nonText) { + try { + if (c.type === 'css' || c.type === 'attr') { + const ensured = await ensureRefForSelector(tabId, String(c.value || ''), frameId); + if (ensured) return { ...ensured, resolvedBy: c.type }; + } else if (c.type === 'aria') { + // Minimal ARIA role+name parser like: "button[name=提交]" or "textbox[name=用户名]" + const v = String(c.value || '').trim(); + const m = v.match(/^(\w+)\s*\[\s*name\s*=\s*([^\]]+)\]$/); + const role = m ? m[1] : ''; + const name = m ? m[2] : ''; + const cleanName = name.replace(/^['"]|['"]$/g, ''); + const ariaSelectors: string[] = []; + if (role === 'textbox') { + ariaSelectors.push( + `[role="textbox"][aria-label=${JSON.stringify(cleanName)}]`, + `input[aria-label=${JSON.stringify(cleanName)}]`, + `textarea[aria-label=${JSON.stringify(cleanName)}]`, + ); + } else if (role === 'button') { + ariaSelectors.push( + `[role="button"][aria-label=${JSON.stringify(cleanName)}]`, + `button[aria-label=${JSON.stringify(cleanName)}]`, + ); + } else if (role === 'link') { + ariaSelectors.push( + `[role="link"][aria-label=${JSON.stringify(cleanName)}]`, + `a[aria-label=${JSON.stringify(cleanName)}]`, + ); + } + if (!ariaSelectors.length && role) { + ariaSelectors.push( + `[role=${JSON.stringify(role)}][aria-label=${JSON.stringify(cleanName)}]`, + ); + } + for (const sel of ariaSelectors) { + const ensured = await sendToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector: sel } as any, + frameId, + ); + if (ensured && ensured.success && ensured.ref && ensured.center) { + return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId }; + } + } + } else if (c.type === 'xpath') { + // Minimal xpath support via document.evaluate through injected helper + const ensured = await sendToTab( + tabId, + { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector: c.value, + isXPath: true, + } as any, + frameId, + ); + if (ensured && ensured.success && ensured.ref && ensured.center) { + return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId }; + } + } + } catch (e) { + // continue to next candidate + } + } + // 2) Human-intent fallback: text-based search as last resort + const textCands = (target.candidates || []).filter((c) => c.type === 'text'); + const tagName = ((target as any)?.tag || '').toString(); + for (const c of textCands) { + try { + const ensured = await sendToTab( + tabId, + { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + useText: true, + text: c.value, + tagName, + } as any, + frameId, + ); + if (ensured && ensured.success && ensured.ref && ensured.center) { + return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type }; + } + } catch {} + } + // Fallback: try ref (works when ref was produced in the same page lifecycle) + if (target.ref) { + try { + const res = await sendToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: target.ref } as any, + frameId, + ); + if (res && res.success && res.center) { + return { ref: target.ref, center: res.center, resolvedBy: 'ref' }; + } + } catch (e) { + // ignore + } + } + return null; +} + +/** + * Ensure screenshot context hostname is still valid for coordinate-based actions + */ +// Note: screenshot hostname validation is handled elsewhere; removed legacy stub. diff --git a/app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts new file mode 100644 index 0000000..22193dc --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts @@ -0,0 +1,174 @@ +// indexeddb-manager.ts +// IndexedDB storage manager for Record & Replay data. +// Stores: flows, runs, published, schedules, triggers. + +import type { Flow, RunRecord } from '../types'; +import type { FlowSchedule } from '../flow-store'; +import type { PublishedFlowInfo } from '../flow-store'; +import type { FlowTrigger } from '../trigger-store'; +import { IndexedDbClient } from '@/utils/indexeddb-client'; + +type StoreName = 'flows' | 'runs' | 'published' | 'schedules' | 'triggers'; + +const DB_NAME = 'rr_storage'; +// Version history: +// v1: Initial schema with flows, runs, published, schedules, triggers stores +// v2: (Previous iteration - no schema change, version was bumped during development) +// v3: Current - ensure all stores exist, support upgrade from any previous version +const DB_VERSION = 3; + +const REQUIRED_STORES = ['flows', 'runs', 'published', 'schedules', 'triggers'] as const; + +const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => { + // Idempotent upgrade: ensure all required stores exist regardless of oldVersion + // This handles both fresh installs (oldVersion=0) and upgrades from any version + for (const storeName of REQUIRED_STORES) { + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName, { keyPath: 'id' }); + } + } +}); + +const tx = ( + store: StoreName, + mode: IDBTransactionMode, + op: (s: IDBObjectStore, t: IDBTransaction) => T | Promise, +) => idb.tx(store, mode, op); + +async function getAll(store: StoreName): Promise { + return idb.getAll(store); +} + +async function getOne(store: StoreName, key: string): Promise { + return idb.get(store, key); +} + +async function putOne(store: StoreName, value: T): Promise { + return idb.put(store, value); +} + +async function deleteOne(store: StoreName, key: string): Promise { + return idb.delete(store, key); +} + +async function clearStore(store: StoreName): Promise { + return idb.clear(store); +} + +async function putMany(storeName: StoreName, values: T[]): Promise { + return idb.putMany(storeName, values); +} + +export const IndexedDbStorage = { + flows: { + async list(): Promise { + return getAll('flows'); + }, + async get(id: string): Promise { + return getOne('flows', id); + }, + async save(flow: Flow): Promise { + return putOne('flows', flow); + }, + async delete(id: string): Promise { + return deleteOne('flows', id); + }, + }, + runs: { + async list(): Promise { + return getAll('runs'); + }, + async save(record: RunRecord): Promise { + return putOne('runs', record); + }, + async replaceAll(records: RunRecord[]): Promise { + return tx('runs', 'readwrite', async (st) => { + st.clear(); + for (const r of records) st.put(r); + return; + }); + }, + }, + published: { + async list(): Promise { + return getAll('published'); + }, + async save(info: PublishedFlowInfo): Promise { + return putOne('published', info); + }, + async delete(id: string): Promise { + return deleteOne('published', id); + }, + }, + schedules: { + async list(): Promise { + return getAll('schedules'); + }, + async save(s: FlowSchedule): Promise { + return putOne('schedules', s); + }, + async delete(id: string): Promise { + return deleteOne('schedules', id); + }, + }, + triggers: { + async list(): Promise { + return getAll('triggers'); + }, + async save(t: FlowTrigger): Promise { + return putOne('triggers', t); + }, + async delete(id: string): Promise { + return deleteOne('triggers', id); + }, + }, +}; + +// One-time migration from chrome.storage.local to IndexedDB +let migrationPromise: Promise | null = null; +let migrationFailed = false; + +export async function ensureMigratedFromLocal(): Promise { + // If previous migration failed, allow retry + if (migrationFailed) { + migrationPromise = null; + migrationFailed = false; + } + if (migrationPromise) return migrationPromise; + + migrationPromise = (async () => { + try { + const flag = await chrome.storage.local.get(['rr_idb_migrated']); + if (flag && flag['rr_idb_migrated']) return; + + // Read existing data from chrome.storage.local + const res = await chrome.storage.local.get([ + 'rr_flows', + 'rr_runs', + 'rr_published_flows', + 'rr_schedules', + 'rr_triggers', + ]); + const flows = (res['rr_flows'] as Flow[]) || []; + const runs = (res['rr_runs'] as RunRecord[]) || []; + const published = (res['rr_published_flows'] as PublishedFlowInfo[]) || []; + const schedules = (res['rr_schedules'] as FlowSchedule[]) || []; + const triggers = (res['rr_triggers'] as FlowTrigger[]) || []; + + // Write into IDB + if (flows.length) await putMany('flows', flows); + if (runs.length) await putMany('runs', runs); + if (published.length) await putMany('published', published); + if (schedules.length) await putMany('schedules', schedules); + if (triggers.length) await putMany('triggers', triggers); + + await chrome.storage.local.set({ rr_idb_migrated: true }); + } catch (e) { + migrationFailed = true; + console.error('IndexedDbStorage migration failed:', e); + // Re-throw to let callers know migration failed + throw e; + } + })(); + return migrationPromise; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts b/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts new file mode 100644 index 0000000..0c1c08a --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts @@ -0,0 +1,56 @@ +import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager'; + +export type TriggerType = 'url' | 'contextMenu' | 'command' | 'dom'; + +export interface BaseTrigger { + id: string; + type: TriggerType; + enabled: boolean; + flowId: string; + args?: Record; +} + +export interface UrlTrigger extends BaseTrigger { + type: 'url'; + match: Array<{ kind: 'url' | 'domain' | 'path'; value: string }>; +} + +export interface ContextMenuTrigger extends BaseTrigger { + type: 'contextMenu'; + title: string; + contexts?: chrome.contextMenus.ContextType[]; +} + +export interface CommandTrigger extends BaseTrigger { + type: 'command'; + commandKey: string; // e.g., run_quick_trigger_1 +} + +export interface DomTrigger extends BaseTrigger { + type: 'dom'; + selector: string; + appear?: boolean; // default true + once?: boolean; // default true + debounceMs?: number; // default 800 +} + +export type FlowTrigger = UrlTrigger | ContextMenuTrigger | CommandTrigger | DomTrigger; + +export async function listTriggers(): Promise { + await ensureMigratedFromLocal(); + return await IndexedDbStorage.triggers.list(); +} + +export async function saveTrigger(t: FlowTrigger): Promise { + await ensureMigratedFromLocal(); + await IndexedDbStorage.triggers.save(t); +} + +export async function deleteTrigger(id: string): Promise { + await ensureMigratedFromLocal(); + await IndexedDbStorage.triggers.delete(id); +} + +export function toId(prefix = 'trg') { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay/types.ts b/app/chrome-extension/entrypoints/background/record-replay/types.ts new file mode 100644 index 0000000..c8f039d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay/types.ts @@ -0,0 +1,178 @@ +/** + * Record & Replay Core Types + * + * This file contains the core type definitions for the record-replay system. + * Legacy Step types have been moved to ./legacy-types.ts and are re-exported + * here for backward compatibility. + * + * Type system architecture: + * - Legacy types (./legacy-types.ts): Step-based execution model (being phased out) + * - Action types (./actions/types.ts): DAG-based execution model (new standard) + * - Core types (this file): Flow, Node, Edge, Run records (shared by both) + */ + +import { NODE_TYPES } from '@/common/node-types'; + +// ============================================================================= +// Re-export Legacy Types for Backward Compatibility +// ============================================================================= + +export type { + // Selector types + SelectorType, + SelectorCandidate, + TargetLocator, + // Step types + StepType, + StepBase, + StepClick, + StepFill, + StepTriggerEvent, + StepSetAttribute, + StepScreenshot, + StepSwitchFrame, + StepLoopElements, + StepKey, + StepScroll, + StepDrag, + StepWait, + StepAssert, + StepScript, + StepIf, + StepForeach, + StepWhile, + StepHttp, + StepExtract, + StepOpenTab, + StepSwitchTab, + StepCloseTab, + StepNavigate, + StepHandleDownload, + StepExecuteFlow, + Step, +} from './legacy-types'; + +// Import Step type for use in Flow interface +import type { Step } from './legacy-types'; + +// ============================================================================= +// Variable Definitions +// ============================================================================= + +export type VariableType = 'string' | 'number' | 'boolean' | 'enum' | 'array'; + +export interface VariableDef { + key: string; + label?: string; + sensitive?: boolean; + // default value can be string/number/boolean/array depending on type + default?: any; // keep broad for backward compatibility + type?: VariableType; // default to 'string' when omitted + rules?: { required?: boolean; pattern?: string; enum?: string[] }; +} + +// ============================================================================= +// DAG Node and Edge Types (Flow V2) +// ============================================================================= + +export type NodeType = (typeof NODE_TYPES)[keyof typeof NODE_TYPES]; + +export interface NodeBase { + id: string; + type: NodeType; + name?: string; + disabled?: boolean; + config?: any; + ui?: { x: number; y: number }; +} + +export interface Edge { + id: string; + from: string; + to: string; + // label identifies the logical branch. Keep 'default' for linear/main path. + // For conditionals, use arbitrary strings like 'case:' or 'else'. + label?: string; +} + +// ============================================================================= +// Flow Definition +// ============================================================================= + +export interface Flow { + id: string; + name: string; + description?: string; + version: number; + meta?: { + createdAt: string; + updatedAt: string; + domain?: string; + tags?: string[]; + bindings?: Array<{ type: 'domain' | 'path' | 'url'; value: string }>; + tool?: { category?: string; description?: string }; + exposedOutputs?: Array<{ nodeId: string; as: string }>; + /** Recording stop barrier status (used during recording stop) */ + stopBarrier?: { + ok: boolean; + sessionId?: string; + stoppedAt?: string; + failed?: Array<{ + tabId: number; + skipped?: boolean; + reason?: string; + topTimedOut?: boolean; + topError?: string; + subframesFailed?: number; + }>; + }; + }; + variables?: VariableDef[]; + /** + * @deprecated Use nodes/edges instead. This field is no longer written to storage. + * Kept as optional for backward compatibility with existing flows and imports. + */ + steps?: Step[]; + // Flow V2: DAG-based execution model + nodes?: NodeBase[]; + edges?: Edge[]; + subflows?: Record; +} + +// ============================================================================= +// Run Records and Results +// ============================================================================= + +export interface RunLogEntry { + stepId: string; + status: 'success' | 'failed' | 'retrying' | 'warning'; + message?: string; + tookMs?: number; + screenshotBase64?: string; // small thumbnail (optional) + consoleSnippets?: string[]; // critical lines + networkSnippets?: Array<{ method: string; url: string; status?: number; ms?: number }>; + // selector fallback info + fallbackUsed?: boolean; + fallbackFrom?: string; + fallbackTo?: string; +} + +export interface RunRecord { + id: string; + flowId: string; + startedAt: string; + finishedAt?: string; + success?: boolean; + entries: RunLogEntry[]; +} + +export interface RunResult { + runId: string; + success: boolean; + summary: { total: number; success: number; failed: number; tookMs: number }; + url?: string | null; + outputs?: Record | null; + logs?: RunLogEntry[]; + screenshots?: { onFailure?: string | null }; + paused?: boolean; // when true, the run was intentionally paused (e.g., breakpoint) +} diff --git a/app/chrome-extension/entrypoints/background/semantic-similarity.ts b/app/chrome-extension/entrypoints/background/semantic-similarity.ts new file mode 100644 index 0000000..f1626a9 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/semantic-similarity.ts @@ -0,0 +1,373 @@ +import type { ModelPreset } from '@/utils/semantic-similarity-engine'; +import { OffscreenManager } from '@/utils/offscreen-manager'; +import { BACKGROUND_MESSAGE_TYPES, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types'; +import { STORAGE_KEYS, ERROR_MESSAGES } from '@/common/constants'; +import { hasAnyModelCache } from '@/utils/semantic-similarity-engine'; + +/** + * Model configuration state management interface + */ +interface ModelConfig { + modelPreset: ModelPreset; + modelVersion: 'full' | 'quantized' | 'compressed'; + modelDimension: number; +} + +let currentBackgroundModelConfig: ModelConfig | null = null; + +/** + * Initialize semantic engine only if model cache exists + * This is called during plugin startup to avoid downloading models unnecessarily + */ +export async function initializeSemanticEngineIfCached(): Promise { + try { + console.log('Background: Checking if semantic engine should be initialized from cache...'); + + const hasCachedModel = await hasAnyModelCache(); + if (!hasCachedModel) { + console.log('Background: No cached models found, skipping semantic engine initialization'); + return false; + } + + console.log('Background: Found cached models, initializing semantic engine...'); + await initializeDefaultSemanticEngine(); + return true; + } catch (error) { + console.error('Background: Error during conditional semantic engine initialization:', error); + return false; + } +} + +/** + * Initialize default semantic engine model + */ +export async function initializeDefaultSemanticEngine(): Promise { + try { + console.log('Background: Initializing default semantic engine...'); + + // Update status to initializing + await updateModelStatus('initializing', 0); + + const result = await chrome.storage.local.get([STORAGE_KEYS.SEMANTIC_MODEL, 'selectedVersion']); + const defaultModel = + (result[STORAGE_KEYS.SEMANTIC_MODEL] as ModelPreset) || 'multilingual-e5-small'; + const defaultVersion = + (result.selectedVersion as 'full' | 'quantized' | 'compressed') || 'quantized'; + + const { PREDEFINED_MODELS } = await import('@/utils/semantic-similarity-engine'); + const modelInfo = PREDEFINED_MODELS[defaultModel]; + + await OffscreenManager.getInstance().ensureOffscreenDocument(); + + const response = await chrome.runtime.sendMessage({ + target: 'offscreen', + type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT, + config: { + useLocalFiles: false, + modelPreset: defaultModel, + modelVersion: defaultVersion, + modelDimension: modelInfo.dimension, + forceOffscreen: true, + }, + }); + + if (response && response.success) { + currentBackgroundModelConfig = { + modelPreset: defaultModel, + modelVersion: defaultVersion, + modelDimension: modelInfo.dimension, + }; + console.log('Semantic engine initialized successfully:', currentBackgroundModelConfig); + + // Update status to ready + await updateModelStatus('ready', 100); + + // Also initialize ContentIndexer now that semantic engine is ready + try { + const { getGlobalContentIndexer } = await import('@/utils/content-indexer'); + const contentIndexer = getGlobalContentIndexer(); + contentIndexer.startSemanticEngineInitialization(); + console.log('ContentIndexer initialization triggered after semantic engine initialization'); + } catch (indexerError) { + console.warn( + 'Failed to initialize ContentIndexer after semantic engine initialization:', + indexerError, + ); + } + } else { + const errorMessage = response?.error || ERROR_MESSAGES.TOOL_EXECUTION_FAILED; + await updateModelStatus('error', 0, errorMessage, 'unknown'); + throw new Error(errorMessage); + } + } catch (error: any) { + console.error('Background: Failed to initialize default semantic engine:', error); + const errorMessage = error?.message || 'Unknown error during semantic engine initialization'; + await updateModelStatus('error', 0, errorMessage, 'unknown'); + // Don't throw error, let the extension continue running + } +} + +/** + * Check if model switch is needed + */ +function needsModelSwitch( + modelPreset: ModelPreset, + modelVersion: 'full' | 'quantized' | 'compressed', + modelDimension?: number, +): boolean { + if (!currentBackgroundModelConfig) { + return true; + } + + const keyFields = ['modelPreset', 'modelVersion', 'modelDimension']; + for (const field of keyFields) { + const newValue = + field === 'modelPreset' + ? modelPreset + : field === 'modelVersion' + ? modelVersion + : modelDimension; + if (newValue !== currentBackgroundModelConfig[field as keyof ModelConfig]) { + return true; + } + } + + return false; +} + +/** + * Handle model switching + */ +export async function handleModelSwitch( + modelPreset: ModelPreset, + modelVersion: 'full' | 'quantized' | 'compressed' = 'quantized', + modelDimension?: number, + previousDimension?: number, +): Promise<{ success: boolean; error?: string }> { + try { + const needsSwitch = needsModelSwitch(modelPreset, modelVersion, modelDimension); + if (!needsSwitch) { + await updateModelStatus('ready', 100); + return { success: true }; + } + + await updateModelStatus('downloading', 0); + + try { + await OffscreenManager.getInstance().ensureOffscreenDocument(); + } catch (offscreenError) { + console.error('Background: Failed to create offscreen document:', offscreenError); + const errorMessage = `Failed to create offscreen document: ${offscreenError}`; + await updateModelStatus('error', 0, errorMessage, 'unknown'); + return { success: false, error: errorMessage }; + } + + const response = await chrome.runtime.sendMessage({ + target: 'offscreen', + type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT, + config: { + useLocalFiles: false, + modelPreset: modelPreset, + modelVersion: modelVersion, + modelDimension: modelDimension, + forceOffscreen: true, + }, + }); + + if (response && response.success) { + currentBackgroundModelConfig = { + modelPreset: modelPreset, + modelVersion: modelVersion, + modelDimension: modelDimension!, + }; + + // Only reinitialize ContentIndexer when dimension changes + try { + if (modelDimension && previousDimension && modelDimension !== previousDimension) { + const { getGlobalContentIndexer } = await import('@/utils/content-indexer'); + const contentIndexer = getGlobalContentIndexer(); + await contentIndexer.reinitialize(); + } + } catch (indexerError) { + console.warn('Background: Failed to reinitialize ContentIndexer:', indexerError); + } + + await updateModelStatus('ready', 100); + return { success: true }; + } else { + const errorMessage = response?.error || 'Failed to switch model'; + const errorType = analyzeErrorType(errorMessage); + await updateModelStatus('error', 0, errorMessage, errorType); + throw new Error(errorMessage); + } + } catch (error: any) { + console.error('Model switch failed:', error); + const errorMessage = error.message || 'Unknown error'; + const errorType = analyzeErrorType(errorMessage); + await updateModelStatus('error', 0, errorMessage, errorType); + return { success: false, error: errorMessage }; + } +} + +/** + * Get model status + */ +export async function handleGetModelStatus(): Promise<{ + success: boolean; + status?: any; + error?: string; +}> { + try { + if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) { + console.error('Background: chrome.storage.local is not available for status query'); + return { + success: true, + status: { + initializationStatus: 'idle', + downloadProgress: 0, + isDownloading: false, + lastUpdated: Date.now(), + }, + }; + } + + const result = await chrome.storage.local.get(['modelState']); + const modelState = result.modelState || { + status: 'idle', + downloadProgress: 0, + isDownloading: false, + lastUpdated: Date.now(), + }; + + return { + success: true, + status: { + initializationStatus: modelState.status, + downloadProgress: modelState.downloadProgress, + isDownloading: modelState.isDownloading, + lastUpdated: modelState.lastUpdated, + errorMessage: modelState.errorMessage, + errorType: modelState.errorType, + }, + }; + } catch (error: any) { + console.error('Failed to get model status:', error); + return { success: false, error: error.message }; + } +} + +/** + * Update model status + */ +export async function updateModelStatus( + status: string, + progress: number, + errorMessage?: string, + errorType?: string, +): Promise { + try { + // Check if chrome.storage is available + if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) { + console.error('Background: chrome.storage.local is not available for status update'); + return; + } + + const modelState = { + status, + downloadProgress: progress, + isDownloading: status === 'downloading' || status === 'initializing', + lastUpdated: Date.now(), + errorMessage: errorMessage || '', + errorType: errorType || '', + }; + await chrome.storage.local.set({ modelState }); + } catch (error) { + console.error('Failed to update model status:', error); + } +} + +/** + * Handle model status updates from offscreen document + */ +export async function handleUpdateModelStatus( + modelState: any, +): Promise<{ success: boolean; error?: string }> { + try { + // Check if chrome.storage is available + if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) { + console.error('Background: chrome.storage.local is not available'); + return { success: false, error: 'chrome.storage.local is not available' }; + } + + await chrome.storage.local.set({ modelState }); + return { success: true }; + } catch (error: any) { + console.error('Background: Failed to update model status:', error); + return { success: false, error: error.message }; + } +} + +/** + * Analyze error type based on error message + */ +function analyzeErrorType(errorMessage: string): 'network' | 'file' | 'unknown' { + const message = errorMessage.toLowerCase(); + + if ( + message.includes('network') || + message.includes('fetch') || + message.includes('timeout') || + message.includes('connection') || + message.includes('cors') || + message.includes('failed to fetch') + ) { + return 'network'; + } + + if ( + message.includes('corrupt') || + message.includes('invalid') || + message.includes('format') || + message.includes('parse') || + message.includes('decode') || + message.includes('onnx') + ) { + return 'file'; + } + + return 'unknown'; +} + +/** + * Initialize semantic similarity module message listeners + */ +export const initSemanticSimilarityListener = () => { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === BACKGROUND_MESSAGE_TYPES.SWITCH_SEMANTIC_MODEL) { + handleModelSwitch( + message.modelPreset, + message.modelVersion, + message.modelDimension, + message.previousDimension, + ) + .then((result: { success: boolean; error?: string }) => sendResponse(result)) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.type === BACKGROUND_MESSAGE_TYPES.GET_MODEL_STATUS) { + handleGetModelStatus() + .then((result: { success: boolean; status?: any; error?: string }) => sendResponse(result)) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.type === BACKGROUND_MESSAGE_TYPES.UPDATE_MODEL_STATUS) { + handleUpdateModelStatus(message.modelState) + .then((result: { success: boolean; error?: string }) => sendResponse(result)) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.type === BACKGROUND_MESSAGE_TYPES.INITIALIZE_SEMANTIC_ENGINE) { + initializeDefaultSemanticEngine() + .then(() => sendResponse({ success: true })) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + return true; + } + }); +}; diff --git a/app/chrome-extension/entrypoints/background/storage-manager.ts b/app/chrome-extension/entrypoints/background/storage-manager.ts new file mode 100644 index 0000000..e221492 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/storage-manager.ts @@ -0,0 +1,112 @@ +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +/** + * Get storage statistics + */ +export async function handleGetStorageStats(): Promise<{ + success: boolean; + stats?: any; + error?: string; +}> { + try { + // Get ContentIndexer statistics + const { getGlobalContentIndexer } = await import('@/utils/content-indexer'); + const contentIndexer = getGlobalContentIndexer(); + + // Note: Semantic engine initialization is now user-controlled + // ContentIndexer will be initialized when user manually triggers semantic engine initialization + + // Get statistics + const stats = contentIndexer.getStats(); + + return { + success: true, + stats: { + indexedPages: stats.indexedPages || 0, + totalDocuments: stats.totalDocuments || 0, + totalTabs: stats.totalTabs || 0, + indexSize: stats.indexSize || 0, + isInitialized: stats.isInitialized || false, + semanticEngineReady: stats.semanticEngineReady || false, + semanticEngineInitializing: stats.semanticEngineInitializing || false, + }, + }; + } catch (error: any) { + console.error('Background: Failed to get storage stats:', error); + return { + success: false, + error: error.message, + stats: { + indexedPages: 0, + totalDocuments: 0, + totalTabs: 0, + indexSize: 0, + isInitialized: false, + semanticEngineReady: false, + semanticEngineInitializing: false, + }, + }; + } +} + +/** + * Clear all data + */ +export async function handleClearAllData(): Promise<{ success: boolean; error?: string }> { + try { + // 1. Clear all ContentIndexer indexes + try { + const { getGlobalContentIndexer } = await import('@/utils/content-indexer'); + const contentIndexer = getGlobalContentIndexer(); + + await contentIndexer.clearAllIndexes(); + console.log('Storage: ContentIndexer indexes cleared successfully'); + } catch (indexerError) { + console.warn('Background: Failed to clear ContentIndexer indexes:', indexerError); + // Continue with other cleanup operations + } + + // 2. Clear all VectorDatabase data + try { + const { clearAllVectorData } = await import('@/utils/vector-database'); + await clearAllVectorData(); + console.log('Storage: Vector database data cleared successfully'); + } catch (vectorError) { + console.warn('Background: Failed to clear vector data:', vectorError); + // Continue with other cleanup operations + } + + // 3. Clear related data in chrome.storage (preserve model preferences) + try { + const keysToRemove = ['vectorDatabaseStats', 'lastCleanupTime', 'contentIndexerStats']; + await chrome.storage.local.remove(keysToRemove); + console.log('Storage: Chrome storage data cleared successfully'); + } catch (storageError) { + console.warn('Background: Failed to clear chrome storage data:', storageError); + } + + return { success: true }; + } catch (error: any) { + console.error('Background: Failed to clear all data:', error); + return { success: false, error: error.message }; + } +} + +/** + * Initialize storage manager module message listeners + */ +export const initStorageManagerListener = () => { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === BACKGROUND_MESSAGE_TYPES.GET_STORAGE_STATS) { + handleGetStorageStats() + .then((result: { success: boolean; stats?: any; error?: string }) => sendResponse(result)) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + return true; + } else if (message.type === BACKGROUND_MESSAGE_TYPES.CLEAR_ALL_DATA) { + handleClearAllData() + .then((result: { success: boolean; error?: string }) => sendResponse(result)) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + return true; + } + }); +}; diff --git a/app/chrome-extension/entrypoints/background/tools/base-browser.ts b/app/chrome-extension/entrypoints/background/tools/base-browser.ts new file mode 100644 index 0000000..1e0bc60 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/base-browser.ts @@ -0,0 +1,173 @@ +import { ToolExecutor } from '@/common/tool-handler'; +import type { ToolResult } from '@/common/tool-handler'; +import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants'; + +const PING_TIMEOUT_MS = 300; + +/** + * Base class for browser tool executors + */ +export abstract class BaseBrowserToolExecutor implements ToolExecutor { + abstract name: string; + abstract execute(args: any): Promise; + + /** + * Inject content script into tab + */ + protected async injectContentScript( + tabId: number, + files: string[], + injectImmediately = false, + world: 'MAIN' | 'ISOLATED' = 'ISOLATED', + allFrames: boolean = false, + frameIds?: number[], + ): Promise { + console.log(`Injecting ${files.join(', ')} into tab ${tabId}`); + + // check if script is already injected + try { + const pingFrameId = frameIds?.[0]; + const response = await Promise.race([ + typeof pingFrameId === 'number' + ? chrome.tabs.sendMessage( + tabId, + { action: `${this.name}_ping` }, + { frameId: pingFrameId }, + ) + : chrome.tabs.sendMessage(tabId, { action: `${this.name}_ping` }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`${this.name} Ping action to tab ${tabId} timed out`)), + PING_TIMEOUT_MS, + ), + ), + ]); + + if (response && response.status === 'pong') { + console.log( + `pong received for action '${this.name}' in tab ${tabId}. Assuming script is active.`, + ); + return; + } else { + console.warn(`Unexpected ping response in tab ${tabId}:`, response); + } + } catch (error) { + console.error( + `ping content script failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + const target: { tabId: number; allFrames?: boolean; frameIds?: number[] } = { tabId }; + if (frameIds && frameIds.length > 0) { + target.frameIds = frameIds; + } else if (allFrames) { + target.allFrames = true; + } + await chrome.scripting.executeScript({ + target, + files, + injectImmediately, + world, + } as any); + console.log(`'${files.join(', ')}' injection successful for tab ${tabId}`); + } catch (injectionError) { + const errorMessage = + injectionError instanceof Error ? injectionError.message : String(injectionError); + console.error( + `Content script '${files.join(', ')}' injection failed for tab ${tabId}: ${errorMessage}`, + ); + throw new Error( + `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to inject content script in tab ${tabId}: ${errorMessage}`, + ); + } + } + + /** + * Send message to tab + */ + protected async sendMessageToTab(tabId: number, message: any, frameId?: number): Promise { + try { + const response = + typeof frameId === 'number' + ? await chrome.tabs.sendMessage(tabId, message, { frameId }) + : await chrome.tabs.sendMessage(tabId, message); + + if (response && response.error) { + throw new Error(String(response.error)); + } + + return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Error sending message to tab ${tabId} for action ${message?.action || 'unknown'}: ${errorMessage}`, + ); + + if (error instanceof Error) { + throw error; + } + throw new Error(errorMessage); + } + } + + /** + * Try to get an existing tab by id. Returns null when not found. + */ + protected async tryGetTab(tabId?: number): Promise { + if (typeof tabId !== 'number') return null; + try { + return await chrome.tabs.get(tabId); + } catch { + return null; + } + } + + /** + * Get the active tab in the current window. Throws when not found. + */ + protected async getActiveTabOrThrow(): Promise { + const [active] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!active || !active.id) throw new Error('Active tab not found'); + return active; + } + + /** + * Optionally focus window and/or activate tab. Defaults preserve current behavior + * when caller sets activate/focus flags explicitly. + */ + protected async ensureFocus( + tab: chrome.tabs.Tab, + options: { activate?: boolean; focusWindow?: boolean } = {}, + ): Promise { + const activate = options.activate === true; + const focusWindow = options.focusWindow === true; + if (focusWindow && typeof tab.windowId === 'number') { + await chrome.windows.update(tab.windowId, { focused: true }); + } + if (activate && typeof tab.id === 'number') { + await chrome.tabs.update(tab.id, { active: true }); + } + } + + /** + * Get the active tab. When windowId provided, search within that window; otherwise currentWindow. + */ + protected async getActiveTabInWindow(windowId?: number): Promise { + if (typeof windowId === 'number') { + const tabs = await chrome.tabs.query({ active: true, windowId }); + return tabs && tabs[0] ? tabs[0] : null; + } + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + return tabs && tabs[0] ? tabs[0] : null; + } + + /** + * Same as getActiveTabInWindow, but throws if not found. + */ + protected async getActiveTabOrThrowInWindow(windowId?: number): Promise { + const tab = await this.getActiveTabInWindow(windowId); + if (!tab || !tab.id) throw new Error('Active tab not found'); + return tab; + } +} diff --git a/app/chrome-extension/entrypoints/background/tools/browser/bookmark.ts b/app/chrome-extension/entrypoints/background/tools/browser/bookmark.ts new file mode 100644 index 0000000..3da8d05 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/bookmark.ts @@ -0,0 +1,602 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { getMessage } from '@/utils/i18n'; + +/** + * Bookmark search tool parameters interface + */ +interface BookmarkSearchToolParams { + query?: string; // Search keywords for matching bookmark titles and URLs + maxResults?: number; // Maximum number of results to return + folderPath?: string; // Optional, specify which folder to search in (can be ID or path string like "Work/Projects") +} + +/** + * Bookmark add tool parameters interface + */ +interface BookmarkAddToolParams { + url?: string; // URL to add as bookmark, if not provided use current active tab URL + title?: string; // Bookmark title, if not provided use page title + parentId?: string; // Parent folder ID or path string (like "Work/Projects"), if not provided add to "Bookmarks Bar" folder + createFolder?: boolean; // Whether to automatically create parent folder if it doesn't exist +} + +/** + * Bookmark delete tool parameters interface + */ +interface BookmarkDeleteToolParams { + bookmarkId?: string; // ID of bookmark to delete + url?: string; // URL of bookmark to delete (if ID not provided, search by URL) + title?: string; // Title of bookmark to delete (used for auxiliary matching, used together with URL) +} + +// --- Helper Functions --- + +/** + * Get the complete folder path of a bookmark + * @param bookmarkNodeId ID of the bookmark or folder + * @returns Returns folder path string (e.g., "Bookmarks Bar > Folder A > Subfolder B") + */ +async function getBookmarkFolderPath(bookmarkNodeId: string): Promise { + const pathParts: string[] = []; + + try { + // First get the node itself to check if it's a bookmark or folder + const initialNodes = await chrome.bookmarks.get(bookmarkNodeId); + if (initialNodes.length > 0 && initialNodes[0]) { + const initialNode = initialNodes[0]; + + // Build path starting from parent node (same for both bookmarks and folders) + let pathNodeId = initialNode.parentId; + while (pathNodeId) { + const parentNodes = await chrome.bookmarks.get(pathNodeId); + if (parentNodes.length === 0) break; + + const parentNode = parentNodes[0]; + if (parentNode.title) { + pathParts.unshift(parentNode.title); + } + + if (!parentNode.parentId) break; + pathNodeId = parentNode.parentId; + } + } + } catch (error) { + console.error(`Error getting bookmark path for node ID ${bookmarkNodeId}:`, error); + return pathParts.join(' > ') || 'Error getting path'; + } + + return pathParts.join(' > '); +} + +/** + * Find bookmark folder by ID or path string + * If it's an ID, validate it + * If it's a path string, try to parse it + * @param pathOrId Path string (e.g., "Work/Projects") or folder ID + * @returns Returns folder node, or null if not found + */ +async function findFolderByPathOrId( + pathOrId: string, +): Promise { + try { + const nodes = await chrome.bookmarks.get(pathOrId); + if (nodes && nodes.length > 0 && !nodes[0].url) { + return nodes[0]; + } + } catch (e) { + // do nothing, try to parse as path string + } + + const pathParts = pathOrId + .split('/') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + if (pathParts.length === 0) return null; + + const rootChildren = await chrome.bookmarks.getChildren('0'); + + let currentNodes = rootChildren; + let foundFolder: chrome.bookmarks.BookmarkTreeNode | null = null; + + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + foundFolder = null; + let matchedNodeThisLevel: chrome.bookmarks.BookmarkTreeNode | null = null; + + for (const node of currentNodes) { + if (!node.url && node.title.toLowerCase() === part.toLowerCase()) { + matchedNodeThisLevel = node; + break; + } + } + + if (matchedNodeThisLevel) { + if (i === pathParts.length - 1) { + foundFolder = matchedNodeThisLevel; + } else { + currentNodes = await chrome.bookmarks.getChildren(matchedNodeThisLevel.id); + } + } else { + return null; + } + } + + return foundFolder; +} + +/** + * Create folder path (if it doesn't exist) + * @param folderPath Folder path string (e.g., "Work/Projects/Subproject") + * @param parentId Optional parent folder ID, defaults to "Bookmarks Bar" + * @returns Returns the created or found final folder node + */ +async function createFolderPath( + folderPath: string, + parentId?: string, +): Promise { + const pathParts = folderPath + .split('/') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + if (pathParts.length === 0) { + throw new Error('Folder path cannot be empty'); + } + + // If no parent ID specified, use "Bookmarks Bar" folder + let currentParentId: string = parentId || ''; + if (!currentParentId) { + const rootChildren = await chrome.bookmarks.getChildren('0'); + // Find "Bookmarks Bar" folder (usually ID is '1', but search by title for compatibility) + const bookmarkBarFolder = rootChildren.find( + (node) => + !node.url && + (node.title === getMessage('bookmarksBarLabel') || + node.title === 'Bookmarks bar' || + node.title === 'Bookmarks Bar'), + ); + currentParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID + } + + let currentFolder: chrome.bookmarks.BookmarkTreeNode | null = null; + + // Create or find folders level by level + for (const folderName of pathParts) { + const children: chrome.bookmarks.BookmarkTreeNode[] = + await chrome.bookmarks.getChildren(currentParentId); + + // Check if folder with same name already exists + const existingFolder: chrome.bookmarks.BookmarkTreeNode | undefined = children.find( + (child: chrome.bookmarks.BookmarkTreeNode) => + !child.url && child.title.toLowerCase() === folderName.toLowerCase(), + ); + + if (existingFolder) { + currentFolder = existingFolder; + currentParentId = existingFolder.id; + } else { + // Create new folder + currentFolder = await chrome.bookmarks.create({ + parentId: currentParentId, + title: folderName, + }); + currentParentId = currentFolder.id; + } + } + + if (!currentFolder) { + throw new Error('Failed to create folder path'); + } + + return currentFolder; +} + +/** + * Flatten bookmark tree (or node array) to bookmark list (excluding folders) + * @param nodes Bookmark tree nodes to flatten + * @returns Returns actual bookmark node array (nodes with URLs) + */ +function flattenBookmarkNodesToBookmarks( + nodes: chrome.bookmarks.BookmarkTreeNode[], +): chrome.bookmarks.BookmarkTreeNode[] { + const result: chrome.bookmarks.BookmarkTreeNode[] = []; + const stack = [...nodes]; // Use stack for iterative traversal to avoid deep recursion issues + + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + + if (node.url) { + // It's a bookmark + result.push(node); + } + + if (node.children) { + // Add child nodes to stack for processing + for (let i = node.children.length - 1; i >= 0; i--) { + stack.push(node.children[i]); + } + } + } + + return result; +} + +/** + * Find bookmarks by URL and title + * @param url Bookmark URL + * @param title Optional bookmark title for auxiliary matching + * @returns Returns array of matching bookmarks + */ +async function findBookmarksByUrl( + url: string, + title?: string, +): Promise { + // Use Chrome API to search by URL + const searchResults = await chrome.bookmarks.search({ url }); + + if (!title) { + return searchResults; + } + + // If title is provided, further filter results + const titleLower = title.toLowerCase(); + return searchResults.filter( + (bookmark) => bookmark.title && bookmark.title.toLowerCase().includes(titleLower), + ); +} + +/** + * Bookmark search tool + * Used to search bookmarks in Chrome browser + */ +class BookmarkSearchTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.BOOKMARK_SEARCH; + + /** + * Execute bookmark search + */ + async execute(args: BookmarkSearchToolParams): Promise { + const { query = '', maxResults = 50, folderPath } = args; + + console.log( + `BookmarkSearchTool: Searching bookmarks, keywords: "${query}", folder path: "${folderPath}"`, + ); + + try { + let bookmarksToSearch: chrome.bookmarks.BookmarkTreeNode[] = []; + let targetFolderNode: chrome.bookmarks.BookmarkTreeNode | null = null; + + // If folder path is specified, find that folder first + if (folderPath) { + targetFolderNode = await findFolderByPathOrId(folderPath); + if (!targetFolderNode) { + return createErrorResponse(`Specified folder not found: "${folderPath}"`); + } + // Get all bookmarks in that folder and its subfolders + const subTree = await chrome.bookmarks.getSubTree(targetFolderNode.id); + bookmarksToSearch = + subTree.length > 0 ? flattenBookmarkNodesToBookmarks(subTree[0].children || []) : []; + } + + let filteredBookmarks: chrome.bookmarks.BookmarkTreeNode[]; + + if (query) { + if (targetFolderNode) { + // Has query keywords and specified folder: manually filter bookmarks from folder + const lowerCaseQuery = query.toLowerCase(); + filteredBookmarks = bookmarksToSearch.filter( + (bookmark) => + (bookmark.title && bookmark.title.toLowerCase().includes(lowerCaseQuery)) || + (bookmark.url && bookmark.url.toLowerCase().includes(lowerCaseQuery)), + ); + } else { + // Has query keywords but no specified folder: use API search + filteredBookmarks = await chrome.bookmarks.search({ query }); + // API search may return folders (if title matches), filter them out + filteredBookmarks = filteredBookmarks.filter((item) => !!item.url); + } + } else { + // No query keywords + if (!targetFolderNode) { + // No folder path specified, get all bookmarks + const tree = await chrome.bookmarks.getTree(); + bookmarksToSearch = flattenBookmarkNodesToBookmarks(tree); + } + filteredBookmarks = bookmarksToSearch; + } + + // Limit number of results + const limitedResults = filteredBookmarks.slice(0, maxResults); + + // Add folder path information for each bookmark + const resultsWithPath = await Promise.all( + limitedResults.map(async (bookmark) => { + const path = await getBookmarkFolderPath(bookmark.id); + return { + id: bookmark.id, + title: bookmark.title, + url: bookmark.url, + dateAdded: bookmark.dateAdded, + folderPath: path, + }; + }), + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + totalResults: resultsWithPath.length, + query: query || null, + folderSearched: targetFolderNode + ? targetFolderNode.title || targetFolderNode.id + : 'All bookmarks', + bookmarks: resultsWithPath, + }, + null, + 2, + ), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error searching bookmarks:', error); + return createErrorResponse( + `Error searching bookmarks: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +/** + * Bookmark add tool + * Used to add new bookmarks to Chrome browser + */ +class BookmarkAddTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.BOOKMARK_ADD; + + /** + * Execute add bookmark operation + */ + async execute(args: BookmarkAddToolParams): Promise { + const { url, title, parentId, createFolder = false } = args; + + console.log(`BookmarkAddTool: Adding bookmark, options:`, args); + + try { + // If no URL provided, use current active tab + let bookmarkUrl = url; + let bookmarkTitle = title; + + if (!bookmarkUrl) { + // Get current active tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0] || !tabs[0].url) { + // tab.url might be undefined (e.g., chrome:// pages) + return createErrorResponse('No active tab with valid URL found, and no URL provided'); + } + + bookmarkUrl = tabs[0].url; + if (!bookmarkTitle) { + bookmarkTitle = tabs[0].title || bookmarkUrl; // If tab title is empty, use URL as title + } + } + + if (!bookmarkUrl) { + // Should have been caught above, but as a safety measure + return createErrorResponse('URL is required to create bookmark'); + } + + // Parse parentId (could be ID or path string) + let actualParentId: string | undefined = undefined; + if (parentId) { + let folderNode = await findFolderByPathOrId(parentId); + + if (!folderNode && createFolder) { + // If folder doesn't exist and creation is allowed, create folder path + try { + folderNode = await createFolderPath(parentId); + } catch (createError) { + return createErrorResponse( + `Failed to create folder path: ${createError instanceof Error ? createError.message : String(createError)}`, + ); + } + } + + if (folderNode) { + actualParentId = folderNode.id; + } else { + // Check if parentId might be a direct ID missed by findFolderByPathOrId (e.g., root folder '1') + try { + const nodes = await chrome.bookmarks.get(parentId); + if (nodes && nodes.length > 0 && !nodes[0].url) { + actualParentId = nodes[0].id; + } else { + return createErrorResponse( + `Specified parent folder (ID/path: "${parentId}") not found or is not a folder${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`, + ); + } + } catch (e) { + return createErrorResponse( + `Specified parent folder (ID/path: "${parentId}") not found or invalid${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`, + ); + } + } + } else { + // If no parentId specified, default to "Bookmarks Bar" + const rootChildren = await chrome.bookmarks.getChildren('0'); + const bookmarkBarFolder = rootChildren.find( + (node) => + !node.url && + (node.title === getMessage('bookmarksBarLabel') || + node.title === 'Bookmarks bar' || + node.title === 'Bookmarks Bar'), + ); + actualParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID + } + // If actualParentId is still undefined, chrome.bookmarks.create will use default "Other Bookmarks", but we've set Bookmarks Bar + + // Create bookmark + const newBookmark = await chrome.bookmarks.create({ + parentId: actualParentId, // If undefined, API uses default value + title: bookmarkTitle || bookmarkUrl, // Ensure title is never empty + url: bookmarkUrl, + }); + + // Get bookmark path + const path = await getBookmarkFolderPath(newBookmark.id); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + message: 'Bookmark added successfully', + bookmark: { + id: newBookmark.id, + title: newBookmark.title, + url: newBookmark.url, + dateAdded: newBookmark.dateAdded, + folderPath: path, + }, + folderCreated: createFolder && parentId ? 'Folder created if necessary' : false, + }, + null, + 2, + ), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error adding bookmark:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + + // Provide more specific error messages for common error cases, such as trying to bookmark chrome:// URLs + if (errorMessage.includes("Can't bookmark URLs of type")) { + return createErrorResponse( + `Error adding bookmark: Cannot bookmark this type of URL (e.g., chrome:// system pages). ${errorMessage}`, + ); + } + + return createErrorResponse(`Error adding bookmark: ${errorMessage}`); + } + } +} + +/** + * Bookmark delete tool + * Used to delete bookmarks in Chrome browser + */ +class BookmarkDeleteTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.BOOKMARK_DELETE; + + /** + * Execute delete bookmark operation + */ + async execute(args: BookmarkDeleteToolParams): Promise { + const { bookmarkId, url, title } = args; + + console.log(`BookmarkDeleteTool: Deleting bookmark, options:`, args); + + if (!bookmarkId && !url) { + return createErrorResponse('Must provide bookmark ID or URL to delete bookmark'); + } + + try { + let bookmarksToDelete: chrome.bookmarks.BookmarkTreeNode[] = []; + + if (bookmarkId) { + // Delete by ID + try { + const nodes = await chrome.bookmarks.get(bookmarkId); + if (nodes && nodes.length > 0 && nodes[0].url) { + bookmarksToDelete = nodes; + } else { + return createErrorResponse( + `Bookmark with ID "${bookmarkId}" not found, or the ID does not correspond to a bookmark`, + ); + } + } catch (error) { + return createErrorResponse(`Invalid bookmark ID: "${bookmarkId}"`); + } + } else if (url) { + // Delete by URL + bookmarksToDelete = await findBookmarksByUrl(url, title); + if (bookmarksToDelete.length === 0) { + return createErrorResponse( + `No bookmark found with URL "${url}"${title ? ` (title contains: "${title}")` : ''}`, + ); + } + } + + // Delete found bookmarks + const deletedBookmarks = []; + const errors = []; + + for (const bookmark of bookmarksToDelete) { + try { + // Get path information before deletion + const path = await getBookmarkFolderPath(bookmark.id); + + await chrome.bookmarks.remove(bookmark.id); + + deletedBookmarks.push({ + id: bookmark.id, + title: bookmark.title, + url: bookmark.url, + folderPath: path, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push( + `Failed to delete bookmark "${bookmark.title}" (ID: ${bookmark.id}): ${errorMsg}`, + ); + } + } + + if (deletedBookmarks.length === 0) { + return createErrorResponse(`Failed to delete bookmarks: ${errors.join('; ')}`); + } + + const result: any = { + success: true, + message: `Successfully deleted ${deletedBookmarks.length} bookmark(s)`, + deletedBookmarks, + }; + + if (errors.length > 0) { + result.partialSuccess = true; + result.errors = errors; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error deleting bookmark:', error); + return createErrorResponse( + `Error deleting bookmark: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const bookmarkSearchTool = new BookmarkSearchTool(); +export const bookmarkAddTool = new BookmarkAddTool(); +export const bookmarkDeleteTool = new BookmarkDeleteTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/common.ts b/app/chrome-extension/entrypoints/background/tools/browser/common.ts new file mode 100644 index 0000000..7cd9e20 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/common.ts @@ -0,0 +1,679 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { captureFrameOnAction, isAutoCaptureActive } from './gif-recorder'; + +// Default window dimensions +const DEFAULT_WINDOW_WIDTH = 1280; +const DEFAULT_WINDOW_HEIGHT = 720; + +interface NavigateToolParams { + url?: string; + newWindow?: boolean; + width?: number; + height?: number; + refresh?: boolean; + tabId?: number; + windowId?: number; + background?: boolean; // when true, do not activate tab or focus window +} + +/** + * Tool for navigating to URLs in browser tabs or windows + */ +class NavigateTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NAVIGATE; + + /** + * Trigger GIF auto-capture after successful navigation + */ + private async triggerAutoCapture(tabId: number, url?: string): Promise { + if (!isAutoCaptureActive(tabId)) { + return; + } + try { + await captureFrameOnAction(tabId, { type: 'navigate', url }); + } catch (error) { + console.warn('[NavigateTool] Auto-capture failed:', error); + } + } + + async execute(args: NavigateToolParams): Promise { + const { + newWindow = false, + width, + height, + url, + refresh = false, + tabId, + background, + windowId, + } = args; + + console.log( + `Attempting to ${refresh ? 'refresh current tab' : `open URL: ${url}`} with options:`, + args, + ); + + try { + // Handle refresh option first + if (refresh) { + console.log('Refreshing current active tab'); + const explicit = await this.tryGetTab(tabId); + // Get target tab (explicit or active in provided window) + const targetTab = explicit || (await this.getActiveTabOrThrowInWindow(windowId)); + if (!targetTab.id) return createErrorResponse('No target tab found to refresh'); + await chrome.tabs.reload(targetTab.id); + + console.log(`Refreshed tab ID: ${targetTab.id}`); + + // Get updated tab information + const updatedTab = await chrome.tabs.get(targetTab.id); + + // Trigger auto-capture on refresh + await this.triggerAutoCapture(updatedTab.id!, updatedTab.url); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Successfully refreshed current tab', + tabId: updatedTab.id, + windowId: updatedTab.windowId, + url: updatedTab.url, + }), + }, + ], + isError: false, + }; + } + + // Validate that url is provided when not refreshing + if (!url) { + return createErrorResponse('URL parameter is required when refresh is not true'); + } + + // Handle history navigation: url="back" or url="forward" + if (url === 'back' || url === 'forward') { + const explicitTab = await this.tryGetTab(tabId); + const targetTab = explicitTab || (await this.getActiveTabOrThrowInWindow(windowId)); + if (!targetTab.id) { + return createErrorResponse('No target tab found for history navigation'); + } + + // Respect background flag for focus behavior + await this.ensureFocus(targetTab, { + activate: background !== true, + focusWindow: background !== true, + }); + + if (url === 'forward') { + await chrome.tabs.goForward(targetTab.id); + console.log(`Navigated forward in tab ID: ${targetTab.id}`); + } else { + await chrome.tabs.goBack(targetTab.id); + console.log(`Navigated back in tab ID: ${targetTab.id}`); + } + + const updatedTab = await chrome.tabs.get(targetTab.id); + + // Trigger auto-capture on history navigation + await this.triggerAutoCapture(updatedTab.id!, updatedTab.url); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Successfully navigated ${url} in browser history`, + tabId: updatedTab.id, + windowId: updatedTab.windowId, + url: updatedTab.url, + }), + }, + ], + isError: false, + }; + } + + // 1. Check if URL is already open + // Prefer Chrome's URL match patterns for robust matching (host/path variations) + console.log(`Checking if URL is already open: ${url}`); + + // Build robust match patterns from the provided URL. + // This mirrors the approach in CloseTabsTool: ensure wildcard path and + // add common variants (www/no-www, http/https) to handle real-world redirects. + const buildUrlPatterns = (input: string): string[] => { + const patterns = new Set(); + try { + if (!input.includes('*')) { + const u = new URL(input); + // Use host-level wildcard to include all paths; we'll do precise selection later + const pathWildcard = '/*'; + + const hostNoWww = u.host.replace(/^www\./, ''); + const hostWithWww = hostNoWww.startsWith('www.') ? hostNoWww : `www.${hostNoWww}`; + + // Keep original host + patterns.add(`${u.protocol}//${u.host}${pathWildcard}`); + // Add no-www variant + patterns.add(`${u.protocol}//${hostNoWww}${pathWildcard}`); + // Add www variant + patterns.add(`${u.protocol}//${hostWithWww}${pathWildcard}`); + + // Add protocol variant to catch http↔https redirects + const altProtocol = u.protocol === 'https:' ? 'http:' : 'https:'; + patterns.add(`${altProtocol}//${u.host}${pathWildcard}`); + patterns.add(`${altProtocol}//${hostNoWww}${pathWildcard}`); + patterns.add(`${altProtocol}//${hostWithWww}${pathWildcard}`); + } else { + patterns.add(input); + } + } catch { + // Fallback: best-effort wildcard suffix + patterns.add(input.endsWith('/') ? `${input}*` : `${input}/*`); + } + return Array.from(patterns); + }; + + const urlPatterns = buildUrlPatterns(url); + const candidateTabs = await chrome.tabs.query({ url: urlPatterns }); + console.log(`Found ${candidateTabs.length} matching tabs with patterns:`, urlPatterns); + + // Prefer strict match when user specifies a concrete path/query. + // Only fall back to host-level activation when the target is site root. + const pickBestMatch = (target: string, tabsToPick: chrome.tabs.Tab[]) => { + let targetUrl: URL | undefined; + try { + targetUrl = new URL(target); + } catch { + // Not a fully-qualified URL; cannot do structured comparison + return tabsToPick[0]; + } + + const normalizePath = (p: string) => { + if (!p) return '/'; + // Ensure leading slash + const withLeading = p.startsWith('/') ? p : `/${p}`; + // Remove trailing slash except when root + return withLeading !== '/' && withLeading.endsWith('/') + ? withLeading.slice(0, -1) + : withLeading; + }; + + const hostBase = (h: string) => h.replace(/^www\./, '').toLowerCase(); + const isRootTarget = normalizePath(targetUrl.pathname) === '/' && !targetUrl.search; + const targetPath = normalizePath(targetUrl.pathname); + const targetSearch = targetUrl.search || ''; + const targetHostBase = hostBase(targetUrl.host); + + let best: { tab?: chrome.tabs.Tab; score: number } = { score: -1 }; + + for (const tab of tabsToPick) { + const tabUrlStr = tab.url || ''; + let tabUrl: URL | undefined; + try { + tabUrl = new URL(tabUrlStr); + } catch { + continue; + } + + const tabHostBase = hostBase(tabUrl.host); + if (tabHostBase !== targetHostBase) continue; + + const tabPath = normalizePath(tabUrl.pathname); + const tabSearch = tabUrl.search || ''; + + // Scoring: + // 3 - exact path match and (if target has query) exact query match + // 2 - exact path match ignoring query (target without query) + // 1 - same host, any path (only if target is root) + let score = -1; + const pathEqual = tabPath === targetPath; + const searchEqual = tabSearch === targetSearch; + + if (pathEqual && (targetSearch ? searchEqual : true)) { + score = 3; + } else if (pathEqual && !targetSearch) { + score = 2; + } + + if (score > best.score) { + best = { tab, score }; + if (score === 3) break; // Cannot do better + } + } + + return best.tab; + }; + + const explicitTab = await this.tryGetTab(tabId); + const existingTab = explicitTab || pickBestMatch(url, candidateTabs); + if (existingTab?.id !== undefined) { + console.log( + `URL already open in Tab ID: ${existingTab.id}, Window ID: ${existingTab.windowId}`, + ); + // Update URL only when explicit tab specified and url differs + if (explicitTab && typeof explicitTab.id === 'number') { + await chrome.tabs.update(explicitTab.id, { url }); + } + // Optionally bring to foreground based on background flag + await this.ensureFocus(existingTab, { + activate: background !== true, + focusWindow: background !== true, + }); + + console.log(`Activated existing Tab ID: ${existingTab.id}`); + // Get updated tab information and return it + const updatedTab = await chrome.tabs.get(existingTab.id); + + // Trigger auto-capture on existing tab activation + await this.triggerAutoCapture(updatedTab.id!, updatedTab.url); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Activated existing tab', + tabId: updatedTab.id, + windowId: updatedTab.windowId, + url: updatedTab.url, + }), + }, + ], + isError: false, + }; + } + + // 2. If URL is not already open, decide how to open it based on options + const openInNewWindow = newWindow || typeof width === 'number' || typeof height === 'number'; + + if (openInNewWindow) { + console.log('Opening URL in a new window.'); + + // Create new window + const newWindow = await chrome.windows.create({ + url: url, + width: typeof width === 'number' ? width : DEFAULT_WINDOW_WIDTH, + height: typeof height === 'number' ? height : DEFAULT_WINDOW_HEIGHT, + focused: background === true ? false : true, + }); + + if (newWindow && newWindow.id !== undefined) { + console.log(`URL opened in new Window ID: ${newWindow.id}`); + + // Trigger auto-capture if the new window has a tab + const firstTab = newWindow.tabs?.[0]; + if (firstTab?.id) { + await this.triggerAutoCapture(firstTab.id, firstTab.url); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Opened URL in new window', + windowId: newWindow.id, + tabs: newWindow.tabs + ? newWindow.tabs.map((tab) => ({ + tabId: tab.id, + url: tab.url, + })) + : [], + }), + }, + ], + isError: false, + }; + } + } else { + console.log('Opening URL in the last active window.'); + // Try to open a new tab in the specified window, otherwise the most recently active window + let targetWindow: chrome.windows.Window | null = null; + if (typeof windowId === 'number') { + targetWindow = await chrome.windows.get(windowId, { populate: false }); + } + if (!targetWindow) { + targetWindow = await chrome.windows.getLastFocused({ populate: false }); + } + + if (targetWindow && targetWindow.id !== undefined) { + console.log(`Found target Window ID: ${targetWindow.id}`); + + const newTab = await chrome.tabs.create({ + url: url, + windowId: targetWindow.id, + active: background === true ? false : true, + }); + if (background !== true) { + await chrome.windows.update(targetWindow.id, { focused: true }); + } + + console.log( + `URL opened in new Tab ID: ${newTab.id} in existing Window ID: ${targetWindow.id}`, + ); + + // Trigger auto-capture on new tab + if (newTab.id) { + await this.triggerAutoCapture(newTab.id, newTab.url); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Opened URL in new tab in existing window', + tabId: newTab.id, + windowId: targetWindow.id, + url: newTab.url, + }), + }, + ], + isError: false, + }; + } else { + // In rare cases, if there's no recently active window (e.g., browser just started with no windows) + // Fall back to opening in a new window + console.warn('No last focused window found, falling back to creating a new window.'); + + const fallbackWindow = await chrome.windows.create({ + url: url, + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + focused: true, + }); + + if (fallbackWindow && fallbackWindow.id !== undefined) { + console.log(`URL opened in fallback new Window ID: ${fallbackWindow.id}`); + + // Trigger auto-capture if fallback window has a tab + const firstTab = fallbackWindow.tabs?.[0]; + if (firstTab?.id) { + await this.triggerAutoCapture(firstTab.id, firstTab.url); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Opened URL in new window', + windowId: fallbackWindow.id, + tabs: fallbackWindow.tabs + ? fallbackWindow.tabs.map((tab) => ({ + tabId: tab.id, + url: tab.url, + })) + : [], + }), + }, + ], + isError: false, + }; + } + } + } + + // If all attempts fail, return a generic error + return createErrorResponse('Failed to open URL: Unknown error occurred'); + } catch (error) { + if (chrome.runtime.lastError) { + console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error); + return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`); + } else { + console.error('Error in navigate:', error); + return createErrorResponse( + `Error navigating to URL: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +} +export const navigateTool = new NavigateTool(); + +interface CloseTabsToolParams { + tabIds?: number[]; + url?: string; +} + +/** + * Tool for closing browser tabs + */ +class CloseTabsTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.CLOSE_TABS; + + async execute(args: CloseTabsToolParams): Promise { + const { tabIds, url } = args; + let urlPattern = url; + console.log(`Attempting to close tabs with options:`, args); + + try { + // If URL is provided, close all tabs matching that URL + if (urlPattern) { + console.log(`Searching for tabs with URL: ${url}`); + try { + // Build a proper Chrome match pattern from a concrete URL. + // If caller already provided a match pattern with '*', use as-is. + if (!urlPattern.includes('*')) { + // Ignore search/hash; match by origin + pathname prefix. + // Use URL to normalize; fallback to simple suffixing when parsing fails. + try { + const u = new URL(urlPattern); + const basePath = u.pathname || '/'; + const pathWithWildcard = basePath.endsWith('/') ? `${basePath}*` : `${basePath}/*`; + urlPattern = `${u.protocol}//${u.host}${pathWithWildcard}`; + } catch { + // Not a fully-qualified URL; ensure it ends with wildcard + urlPattern = urlPattern.endsWith('/') ? `${urlPattern}*` : `${urlPattern}/*`; + } + } + } catch { + // Best-effort: ensure we have some wildcard + urlPattern = urlPattern.endsWith('*') + ? urlPattern + : urlPattern.endsWith('/') + ? `${urlPattern}*` + : `${urlPattern}/*`; + } + + const tabs = await chrome.tabs.query({ url: urlPattern }); + + if (!tabs || tabs.length === 0) { + console.log(`No tabs found with URL pattern: ${urlPattern}`); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + message: `No tabs found with URL pattern: ${urlPattern}`, + closedCount: 0, + }), + }, + ], + isError: false, + }; + } + + console.log(`Found ${tabs.length} tabs with URL pattern: ${urlPattern}`); + const tabIdsToClose = tabs + .map((tab) => tab.id) + .filter((id): id is number => id !== undefined); + + if (tabIdsToClose.length === 0) { + return createErrorResponse('Found tabs but could not get their IDs'); + } + + await chrome.tabs.remove(tabIdsToClose); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Closed ${tabIdsToClose.length} tabs with URL: ${url}`, + closedCount: tabIdsToClose.length, + closedTabIds: tabIdsToClose, + }), + }, + ], + isError: false, + }; + } + + // If tabIds are provided, close those tabs + if (tabIds && tabIds.length > 0) { + console.log(`Closing tabs with IDs: ${tabIds.join(', ')}`); + + // Verify that all tabIds exist + const existingTabs = await Promise.all( + tabIds.map(async (tabId) => { + try { + return await chrome.tabs.get(tabId); + } catch (error) { + console.warn(`Tab with ID ${tabId} not found`); + return null; + } + }), + ); + + const validTabIds = existingTabs + .filter((tab): tab is chrome.tabs.Tab => tab !== null) + .map((tab) => tab.id) + .filter((id): id is number => id !== undefined); + + if (validTabIds.length === 0) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + message: 'None of the provided tab IDs exist', + closedCount: 0, + }), + }, + ], + isError: false, + }; + } + + await chrome.tabs.remove(validTabIds); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Closed ${validTabIds.length} tabs`, + closedCount: validTabIds.length, + closedTabIds: validTabIds, + invalidTabIds: tabIds.filter((id) => !validTabIds.includes(id)), + }), + }, + ], + isError: false, + }; + } + + // If no tabIds or URL provided, close the current active tab + console.log('No tabIds or URL provided, closing active tab'); + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!activeTab || !activeTab.id) { + return createErrorResponse('No active tab found'); + } + + await chrome.tabs.remove(activeTab.id); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Closed active tab', + closedCount: 1, + closedTabIds: [activeTab.id], + }), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in CloseTabsTool.execute:', error); + return createErrorResponse( + `Error closing tabs: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const closeTabsTool = new CloseTabsTool(); + +interface SwitchTabToolParams { + tabId: number; + windowId?: number; +} + +/** + * Tool for switching the active tab + */ +class SwitchTabTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.SWITCH_TAB; + + async execute(args: SwitchTabToolParams): Promise { + const { tabId, windowId } = args; + + console.log(`Attempting to switch to tab ID: ${tabId} in window ID: ${windowId}`); + + try { + if (windowId !== undefined) { + await chrome.windows.update(windowId, { focused: true }); + } + await chrome.tabs.update(tabId, { active: true }); + + const updatedTab = await chrome.tabs.get(tabId); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Successfully switched to tab ID: ${tabId}`, + tabId: updatedTab.id, + windowId: updatedTab.windowId, + url: updatedTab.url, + }), + }, + ], + isError: false, + }; + } catch (error) { + if (chrome.runtime.lastError) { + console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error); + return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`); + } else { + console.error('Error in SwitchTabTool.execute:', error); + return createErrorResponse( + `Error switching tab: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +} + +export const switchTabTool = new SwitchTabTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/computer.ts b/app/chrome-extension/entrypoints/background/tools/browser/computer.ts new file mode 100644 index 0000000..2850739 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/computer.ts @@ -0,0 +1,1427 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { ERROR_MESSAGES, TIMEOUTS } from '@/common/constants'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { clickTool, fillTool } from './interaction'; +import { keyboardTool } from './keyboard'; +import { screenshotTool } from './screenshot'; +import { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { + captureFrameOnAction, + isAutoCaptureActive, + type ActionMetadata, + type ActionType, +} from './gif-recorder'; + +type MouseButton = 'left' | 'right' | 'middle'; + +interface Coordinates { + x: number; + y: number; +} + +interface ZoomRegion { + x0: number; + y0: number; + x1: number; + y1: number; +} + +interface Modifiers { + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +} + +interface ComputerParams { + action: + | 'left_click' + | 'right_click' + | 'double_click' + | 'triple_click' + | 'left_click_drag' + | 'scroll' + | 'type' + | 'key' + | 'hover' + | 'wait' + | 'fill' + | 'fill_form' + | 'resize_page' + | 'scroll_to' + | 'zoom' + | 'screenshot'; + // click/scroll coordinates in screenshot space (if screenshot context exists) or viewport space + coordinates?: Coordinates; // for click/scroll; for drag, this is endCoordinates + startCoordinates?: Coordinates; // for drag start + // Optional element refs (from chrome_read_page) as alternative to coordinates + ref?: string; // click target or drag end + startRef?: string; // drag start + scrollDirection?: 'up' | 'down' | 'left' | 'right'; + scrollAmount?: number; + text?: string; // for type/key + repeat?: number; // for key action (1-100) + modifiers?: Modifiers; // for click actions + region?: ZoomRegion; // for zoom action + duration?: number; // seconds for wait + // For fill + selector?: string; + selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css') + value?: string; + frameId?: number; // Target frame for selector/ref resolution + tabId?: number; // target existing tab id + windowId?: number; + background?: boolean; // avoid focusing/activating +} + +// Minimal CDP helper encapsulated here to avoid scattering CDP code +class CDPHelper { + static async attach(tabId: number): Promise { + await cdpSessionManager.attach(tabId, 'computer'); + } + + static async detach(tabId: number): Promise { + await cdpSessionManager.detach(tabId, 'computer'); + } + + static async send(tabId: number, method: string, params?: object): Promise { + return await cdpSessionManager.sendCommand(tabId, method, params); + } + + static async dispatchMouseEvent(tabId: number, opts: any) { + const params: any = { + type: opts.type, + x: Math.round(opts.x), + y: Math.round(opts.y), + modifiers: opts.modifiers || 0, + }; + if ( + opts.type === 'mousePressed' || + opts.type === 'mouseReleased' || + opts.type === 'mouseMoved' + ) { + params.button = opts.button || 'none'; + if (opts.type === 'mousePressed' || opts.type === 'mouseReleased') { + params.clickCount = opts.clickCount || 1; + } + // Per CDP: buttons is ignored for mouseWheel + params.buttons = opts.buttons !== undefined ? opts.buttons : 0; + } + if (opts.type === 'mouseWheel') { + params.deltaX = opts.deltaX || 0; + params.deltaY = opts.deltaY || 0; + } + await this.send(tabId, 'Input.dispatchMouseEvent', params); + } + + static async insertText(tabId: number, text: string) { + await this.send(tabId, 'Input.insertText', { text }); + } + + static modifierMask(mods: string[]): number { + const map: Record = { + alt: 1, + ctrl: 2, + control: 2, + meta: 4, + cmd: 4, + command: 4, + win: 4, + windows: 4, + shift: 8, + }; + let mask = 0; + for (const m of mods) mask |= map[m] || 0; + return mask; + } + + // Enhanced key mapping for common non-character keys + private static KEY_ALIASES: Record = { + enter: { key: 'Enter', code: 'Enter' }, + return: { key: 'Enter', code: 'Enter' }, + backspace: { key: 'Backspace', code: 'Backspace' }, + delete: { key: 'Delete', code: 'Delete' }, + tab: { key: 'Tab', code: 'Tab' }, + escape: { key: 'Escape', code: 'Escape' }, + esc: { key: 'Escape', code: 'Escape' }, + space: { key: ' ', code: 'Space', text: ' ' }, + pageup: { key: 'PageUp', code: 'PageUp' }, + pagedown: { key: 'PageDown', code: 'PageDown' }, + home: { key: 'Home', code: 'Home' }, + end: { key: 'End', code: 'End' }, + arrowup: { key: 'ArrowUp', code: 'ArrowUp' }, + arrowdown: { key: 'ArrowDown', code: 'ArrowDown' }, + arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft' }, + arrowright: { key: 'ArrowRight', code: 'ArrowRight' }, + }; + + private static resolveKeyDef(token: string): { key: string; code?: string; text?: string } { + const t = (token || '').toLowerCase(); + if (this.KEY_ALIASES[t]) return this.KEY_ALIASES[t]; + if (/^f([1-9]|1[0-2])$/.test(t)) { + return { key: t.toUpperCase(), code: t.toUpperCase() }; + } + if (t.length === 1) { + const upper = t.toUpperCase(); + return { key: upper, code: `Key${upper}`, text: t }; + } + return { key: token }; + } + + static async dispatchSimpleKey(tabId: number, token: string) { + const def = this.resolveKeyDef(token); + if (def.text && def.text.length === 1) { + await this.insertText(tabId, def.text); + return; + } + await this.send(tabId, 'Input.dispatchKeyEvent', { + type: 'rawKeyDown', + key: def.key, + code: def.code, + }); + await this.send(tabId, 'Input.dispatchKeyEvent', { + type: 'keyUp', + key: def.key, + code: def.code, + }); + } + + static async dispatchKeyChord(tabId: number, chord: string) { + const parts = chord.split('+'); + const modifiers: string[] = []; + let keyToken = ''; + for (const pRaw of parts) { + const p = pRaw.trim().toLowerCase(); + if ( + ['ctrl', 'control', 'alt', 'shift', 'cmd', 'meta', 'command', 'win', 'windows'].includes(p) + ) + modifiers.push(p); + else keyToken = pRaw.trim(); + } + const mask = this.modifierMask(modifiers); + const def = this.resolveKeyDef(keyToken); + await this.send(tabId, 'Input.dispatchKeyEvent', { + type: 'rawKeyDown', + key: def.key, + code: def.code, + text: def.text, + modifiers: mask, + }); + await this.send(tabId, 'Input.dispatchKeyEvent', { + type: 'keyUp', + key: def.key, + code: def.code, + modifiers: mask, + }); + } +} + +class ComputerTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.COMPUTER; + + async execute(args: ComputerParams): Promise { + const params = args || ({} as ComputerParams); + if (!params.action) return createErrorResponse('Action parameter is required'); + + try { + const explicit = await this.tryGetTab(args.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId)); + if (!tab.id) + return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID'); + + // Execute the action and capture frame on success + const result = await this.executeAction(params, tab); + + // Trigger auto-capture on successful actions (except screenshot which is read-only) + if (!result.isError && params.action !== 'screenshot' && params.action !== 'wait') { + const actionType = this.mapActionToCapture(params.action); + if (actionType) { + // Convert to viewport-space coordinates for GIF overlays + // params.coordinates may be screenshot-space when screenshot context exists + const ctx = screenshotContextManager.getContext(tab.id); + const toViewport = (c?: Coordinates): { x: number; y: number } | undefined => { + if (!c) return undefined; + if (!ctx) return { x: c.x, y: c.y }; + const scaled = scaleCoordinates(c.x, c.y, ctx); + return { x: scaled.x, y: scaled.y }; + }; + + const endCoords = toViewport(params.coordinates); + const startCoords = toViewport(params.startCoordinates); + + await this.triggerAutoCapture(tab.id, actionType, { + coordinateSpace: 'viewport', + coordinates: endCoords, + startCoordinates: startCoords, + endCoordinates: actionType === 'drag' ? endCoords : undefined, + text: params.text, + ref: params.ref, + }); + } + } + + return result; + } catch (error) { + console.error('Error in computer tool:', error); + return createErrorResponse( + `Failed to execute action: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private mapActionToCapture(action: string): ActionType | null { + const mapping: Record = { + left_click: 'click', + right_click: 'right_click', + double_click: 'double_click', + triple_click: 'triple_click', + left_click_drag: 'drag', + scroll: 'scroll', + type: 'type', + key: 'key', + hover: 'hover', + fill: 'fill', + fill_form: 'fill', + resize_page: 'other', + scroll_to: 'scroll', + zoom: 'other', + }; + return mapping[action] || null; + } + + private async executeAction(params: ComputerParams, tab: chrome.tabs.Tab): Promise { + if (!tab.id) { + return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID'); + } + + // Helper to project coordinates using screenshot context when available + const project = (c?: Coordinates): Coordinates | undefined => { + if (!c) return undefined; + const ctx = screenshotContextManager.getContext(tab.id!); + if (!ctx) return c; + const scaled = scaleCoordinates(c.x, c.y, ctx); + return { x: scaled.x, y: scaled.y }; + }; + + switch (params.action) { + case 'resize_page': { + const width = Number((params as any).coordinates?.x || (params as any).text); + const height = Number((params as any).coordinates?.y || (params as any).value); + const w = Number((params as any).width ?? width); + const h = Number((params as any).height ?? height); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return createErrorResponse('Provide width and height for resize_page (positive numbers)'); + } + try { + // Prefer precise CDP emulation + await CDPHelper.attach(tab.id); + try { + await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', { + width: Math.round(w), + height: Math.round(h), + deviceScaleFactor: 0, + mobile: false, + screenWidth: Math.round(w), + screenHeight: Math.round(h), + }); + } finally { + await CDPHelper.detach(tab.id); + } + } catch (e) { + // Fallback: window resize + if (tab.windowId !== undefined) { + await chrome.windows.update(tab.windowId, { + width: Math.round(w), + height: Math.round(h), + }); + } else { + return createErrorResponse( + `Failed to resize via CDP and cannot determine windowId: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, action: 'resize_page', width: w, height: h }), + }, + ], + isError: false, + }; + } + case 'hover': { + // Resolve target point from ref | selector | coordinates + let coord: Coordinates | undefined = undefined; + let resolvedBy: 'ref' | 'selector' | 'coordinates' | undefined; + + try { + if (params.ref) { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + // Scroll element into view first to ensure it's visible + try { + await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: params.ref }); + } catch { + // Best effort - continue even if scroll fails + } + // Re-resolve coordinates after scroll + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: params.ref, + }); + if (resolved && resolved.success) { + coord = project({ x: resolved.center.x, y: resolved.center.y }); + resolvedBy = 'ref'; + } + } else if (params.selector) { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + const selectorType = params.selectorType || 'css'; + const ensured = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector: params.selector, + isXPath: selectorType === 'xpath', + }); + if (ensured && ensured.success) { + // Scroll element into view first to ensure it's visible + const resolvedRef = typeof ensured.ref === 'string' ? ensured.ref : undefined; + if (resolvedRef) { + try { + await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: resolvedRef }); + } catch { + // Best effort - continue even if scroll fails + } + // Re-resolve coordinates after scroll + const reResolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: resolvedRef, + }); + if (reResolved && reResolved.success) { + coord = project({ x: reResolved.center.x, y: reResolved.center.y }); + } else { + coord = project({ x: ensured.center.x, y: ensured.center.y }); + } + } else { + coord = project({ x: ensured.center.x, y: ensured.center.y }); + } + resolvedBy = 'selector'; + } + } else if (params.coordinates) { + coord = project(params.coordinates); + resolvedBy = 'coordinates'; + } + } catch (e) { + // fall through to error handling below + } + + if (!coord) + return createErrorResponse( + 'Provide ref or selector or coordinates for hover, or failed to resolve target', + ); + { + const stale = ((): any => { + if (!params.coordinates) return null; + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return ''; + } + }; + const currentHostname = getHostname(tab.url || ''); + const ctx = screenshotContextManager.getContext(tab.id!); + const contextHostname = (ctx as any)?.hostname as string | undefined; + if (contextHostname && contextHostname !== currentHostname) { + return createErrorResponse( + `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during hover. Capture a new screenshot or use ref/selector.`, + ); + } + return null; + })(); + if (stale) return stale; + } + + try { + await CDPHelper.attach(tab.id); + try { + // Move pointer to target. We can dispatch a single mouseMoved; browsers will generate mouseover/mouseenter as needed. + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseMoved', + x: coord.x, + y: coord.y, + button: 'none', + buttons: 0, + }); + } finally { + await CDPHelper.detach(tab.id); + } + + // Optional hold to allow UI (menus/tooltips) to appear + const holdMs = Math.max( + 0, + Math.min(params.duration ? params.duration * 1000 : 400, 5000), + ); + if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs)); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'hover', + coordinates: coord, + resolvedBy, + transport: 'cdp', + }), + }, + ], + isError: false, + }; + } catch (error) { + console.warn('[ComputerTool] CDP hover failed, attempting DOM fallback', error); + return await this.domHoverFallback(tab.id, coord, resolvedBy, params.ref); + } + } + case 'left_click': + case 'right_click': { + // Calculate CDP modifier mask for click events + const modifiersMask = CDPHelper.modifierMask( + [ + params.modifiers?.altKey ? 'alt' : undefined, + params.modifiers?.ctrlKey ? 'ctrl' : undefined, + params.modifiers?.metaKey ? 'meta' : undefined, + params.modifiers?.shiftKey ? 'shift' : undefined, + ].filter((v): v is string => typeof v === 'string'), + ); + + if (params.ref) { + // Prefer DOM click via ref + const domResult = await clickTool.execute({ + ref: params.ref, + waitForNavigation: false, + timeout: TIMEOUTS.DEFAULT_WAIT * 5, + button: params.action === 'right_click' ? 'right' : 'left', + modifiers: params.modifiers, + }); + return domResult; + } + if (params.selector) { + // Support selector-based click + const domResult = await clickTool.execute({ + selector: params.selector, + selectorType: params.selectorType, + frameId: params.frameId, + waitForNavigation: false, + timeout: TIMEOUTS.DEFAULT_WAIT * 5, + button: params.action === 'right_click' ? 'right' : 'left', + modifiers: params.modifiers, + }); + return domResult; + } + if (!params.coordinates) + return createErrorResponse('Provide ref, selector, or coordinates for click action'); + { + const stale = ((): any => { + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return ''; + } + }; + const currentHostname = getHostname(tab.url || ''); + const ctx = screenshotContextManager.getContext(tab.id!); + const contextHostname = (ctx as any)?.hostname as string | undefined; + if (contextHostname && contextHostname !== currentHostname) { + return createErrorResponse( + `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`, + ); + } + return null; + })(); + if (stale) return stale; + } + const coord = project(params.coordinates)!; + // Prefer DOM path via existing click tool + const domResult = await clickTool.execute({ + coordinates: coord, + waitForNavigation: false, + timeout: TIMEOUTS.DEFAULT_WAIT * 5, + button: params.action === 'right_click' ? 'right' : 'left', + modifiers: params.modifiers, + }); + if (!domResult.isError) { + return domResult; // Standardized response from click tool + } + // Fallback to CDP if DOM failed + try { + await CDPHelper.attach(tab.id); + const button: MouseButton = params.action === 'right_click' ? 'right' : 'left'; + const clickCount = 1; + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseMoved', + x: coord.x, + y: coord.y, + button: 'none', + buttons: 0, + modifiers: modifiersMask, + }); + for (let i = 1; i <= clickCount; i++) { + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mousePressed', + x: coord.x, + y: coord.y, + button, + buttons: button === 'left' ? 1 : 2, + clickCount: i, + modifiers: modifiersMask, + }); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseReleased', + x: coord.x, + y: coord.y, + button, + buttons: 0, + clickCount: i, + modifiers: modifiersMask, + }); + } + await CDPHelper.detach(tab.id); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: params.action, + coordinates: coord, + }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + return createErrorResponse( + `CDP click failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + case 'double_click': + case 'triple_click': { + // Calculate CDP modifier mask for click events + const modifiersMask = CDPHelper.modifierMask( + [ + params.modifiers?.altKey ? 'alt' : undefined, + params.modifiers?.ctrlKey ? 'ctrl' : undefined, + params.modifiers?.metaKey ? 'meta' : undefined, + params.modifiers?.shiftKey ? 'shift' : undefined, + ].filter((v): v is string => typeof v === 'string'), + ); + + if (!params.coordinates && !params.ref && !params.selector) + return createErrorResponse( + 'Provide ref, selector, or coordinates for double/triple click', + ); + let coord = params.coordinates ? project(params.coordinates)! : (undefined as any); + // If ref is provided, resolve center via accessibility helper + if (params.ref) { + try { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: params.ref, + }); + if (resolved && resolved.success) { + coord = project({ x: resolved.center.x, y: resolved.center.y })!; + } + } catch (e) { + // ignore and use provided coordinates + } + } else if (params.selector) { + // Support selector-based click + try { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + const selectorType = params.selectorType || 'css'; + const ensured = await this.sendMessageToTab( + tab.id, + { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector: params.selector, + isXPath: selectorType === 'xpath', + }, + params.frameId, + ); + if (ensured && ensured.success) { + coord = project({ x: ensured.center.x, y: ensured.center.y })!; + } + } catch (e) { + // ignore + } + } + if (!coord) return createErrorResponse('Failed to resolve coordinates from ref/selector'); + { + const stale = ((): any => { + if (!params.coordinates) return null; + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return ''; + } + }; + const currentHostname = getHostname(tab.url || ''); + const ctx = screenshotContextManager.getContext(tab.id!); + const contextHostname = (ctx as any)?.hostname as string | undefined; + if (contextHostname && contextHostname !== currentHostname) { + return createErrorResponse( + `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`, + ); + } + return null; + })(); + if (stale) return stale; + } + try { + await CDPHelper.attach(tab.id); + const button: MouseButton = 'left'; + const clickCount = params.action === 'double_click' ? 2 : 3; + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseMoved', + x: coord.x, + y: coord.y, + button: 'none', + buttons: 0, + modifiers: modifiersMask, + }); + for (let i = 1; i <= clickCount; i++) { + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mousePressed', + x: coord.x, + y: coord.y, + button, + buttons: 1, + clickCount: i, + modifiers: modifiersMask, + }); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseReleased', + x: coord.x, + y: coord.y, + button, + buttons: 0, + clickCount: i, + modifiers: modifiersMask, + }); + } + await CDPHelper.detach(tab.id); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: params.action, + coordinates: coord, + }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + return createErrorResponse( + `CDP ${params.action} failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + case 'left_click_drag': { + if (!params.startCoordinates && !params.startRef) + return createErrorResponse('Provide startRef or startCoordinates for drag'); + if (!params.coordinates && !params.ref) + return createErrorResponse('Provide ref or end coordinates for drag'); + let start = params.startCoordinates + ? project(params.startCoordinates)! + : (undefined as any); + let end = params.coordinates ? project(params.coordinates)! : (undefined as any); + { + const stale = ((): any => { + if (!params.startCoordinates && !params.coordinates) return null; + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return ''; + } + }; + const currentHostname = getHostname(tab.url || ''); + const ctx = screenshotContextManager.getContext(tab.id!); + const contextHostname = (ctx as any)?.hostname as string | undefined; + if (contextHostname && contextHostname !== currentHostname) { + return createErrorResponse( + `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during left_click_drag. Capture a new screenshot or use ref/selector.`, + ); + } + return null; + })(); + if (stale) return stale; + } + if (params.startRef || params.ref) { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + } + if (params.startRef) { + try { + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: params.startRef, + }); + if (resolved && resolved.success) + start = project({ x: resolved.center.x, y: resolved.center.y })!; + } catch { + // ignore + } + } + if (params.ref) { + try { + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: params.ref, + }); + if (resolved && resolved.success) + end = project({ x: resolved.center.x, y: resolved.center.y })!; + } catch { + // ignore + } + } + if (!start || !end) return createErrorResponse('Failed to resolve drag coordinates'); + try { + await CDPHelper.attach(tab.id); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseMoved', + x: start.x, + y: start.y, + button: 'none', + buttons: 0, + }); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mousePressed', + x: start.x, + y: start.y, + button: 'left', + buttons: 1, + clickCount: 1, + }); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseMoved', + x: end.x, + y: end.y, + button: 'left', + buttons: 1, + }); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseReleased', + x: end.x, + y: end.y, + button: 'left', + buttons: 0, + clickCount: 1, + }); + await CDPHelper.detach(tab.id); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, action: 'left_click_drag', start, end }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + return createErrorResponse(`Drag failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + case 'scroll': { + if (!params.coordinates && !params.ref) + return createErrorResponse('Provide ref or coordinates for scroll'); + let coord = params.coordinates ? project(params.coordinates)! : (undefined as any); + if (params.ref) { + try { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: params.ref, + }); + if (resolved && resolved.success) + coord = project({ x: resolved.center.x, y: resolved.center.y })!; + } catch { + // ignore + } + } + if (!coord) return createErrorResponse('Failed to resolve scroll coordinates'); + { + const stale = ((): any => { + if (!params.coordinates) return null; + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return ''; + } + }; + const currentHostname = getHostname(tab.url || ''); + const ctx = screenshotContextManager.getContext(tab.id!); + const contextHostname = (ctx as any)?.hostname as string | undefined; + if (contextHostname && contextHostname !== currentHostname) { + return createErrorResponse( + `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during scroll. Capture a new screenshot or use ref/selector.`, + ); + } + return null; + })(); + if (stale) return stale; + } + const direction = params.scrollDirection || 'down'; + const amount = Math.max(1, Math.min(params.scrollAmount || 3, 10)); + // Convert to deltas (~100px per tick) + const unit = 100; + let deltaX = 0, + deltaY = 0; + if (direction === 'up') deltaY = -amount * unit; + if (direction === 'down') deltaY = amount * unit; + if (direction === 'left') deltaX = -amount * unit; + if (direction === 'right') deltaX = amount * unit; + try { + await CDPHelper.attach(tab.id); + await CDPHelper.dispatchMouseEvent(tab.id, { + type: 'mouseWheel', + x: coord.x, + y: coord.y, + deltaX, + deltaY, + }); + await CDPHelper.detach(tab.id); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'scroll', + coordinates: coord, + deltaX, + deltaY, + }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + return createErrorResponse( + `Scroll failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + case 'type': { + if (!params.text) return createErrorResponse('Text parameter is required for type action'); + try { + // Optional focus via ref before typing + if (params.ref) { + await clickTool.execute({ + ref: params.ref, + waitForNavigation: false, + timeout: TIMEOUTS.DEFAULT_WAIT * 5, + }); + } + await CDPHelper.attach(tab.id); + // Use CDP insertText to avoid complex KeyboardEvent emulation for long text + await CDPHelper.insertText(tab.id, params.text); + await CDPHelper.detach(tab.id); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'type', + length: params.text.length, + }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + // Fallback to DOM-based keyboard tool + const res = await keyboardTool.execute({ + keys: params.text.split('').join(','), + delay: 0, + selector: undefined, + }); + return res; + } + } + case 'fill': { + if (!params.ref && !params.selector) { + return createErrorResponse('Provide ref or selector and a value for fill'); + } + // Reuse existing fill tool to leverage robust DOM event behavior + const res = await fillTool.execute({ + selector: params.selector as any, + selectorType: params.selectorType as any, + ref: params.ref as any, + value: params.value as any, + } as any); + return res; + } + case 'fill_form': { + const elements = (params as any).elements as Array<{ + ref: string; + value: string | number | boolean; + }>; + if (!Array.isArray(elements) || elements.length === 0) { + return createErrorResponse('elements must be a non-empty array for fill_form'); + } + const results: Array<{ ref: string; ok: boolean; error?: string }> = []; + for (const item of elements) { + if (!item || !item.ref) { + results.push({ ref: String(item?.ref || ''), ok: false, error: 'missing ref' }); + continue; + } + try { + const r = await fillTool.execute({ + ref: item.ref as any, + value: item.value as any, + } as any); + const ok = !r.isError; + results.push({ ref: item.ref, ok, error: ok ? undefined : 'failed' }); + } catch (e) { + results.push({ + ref: item.ref, + ok: false, + error: String(e instanceof Error ? e.message : e), + }); + } + } + const successCount = results.filter((r) => r.ok).length; + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'fill_form', + filled: successCount, + total: results.length, + results, + }), + }, + ], + isError: false, + }; + } + case 'key': { + if (!params.text) + return createErrorResponse( + 'text is required for key action (e.g., "Backspace Backspace Enter" or "cmd+a")', + ); + const tokens = params.text.trim().split(/\s+/).filter(Boolean); + const repeat = params.repeat ?? 1; + if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100) { + return createErrorResponse('repeat must be an integer between 1 and 100 for key action'); + } + try { + // Optional focus via ref before key events + if (params.ref) { + await clickTool.execute({ + ref: params.ref, + waitForNavigation: false, + timeout: TIMEOUTS.DEFAULT_WAIT * 5, + }); + } + await CDPHelper.attach(tab.id); + for (let i = 0; i < repeat; i++) { + for (const t of tokens) { + if (t.includes('+')) await CDPHelper.dispatchKeyChord(tab.id, t); + else await CDPHelper.dispatchSimpleKey(tab.id, t); + } + } + await CDPHelper.detach(tab.id); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, action: 'key', keys: tokens, repeat }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + // Fallback to DOM keyboard simulation (comma-separated combinations) + const keysStr = tokens.join(','); + const repeatedKeys = + repeat === 1 ? keysStr : Array.from({ length: repeat }, () => keysStr).join(','); + const res = await keyboardTool.execute({ keys: repeatedKeys }); + return res; + } + } + case 'wait': { + const hasTextCondition = + typeof (params as any).text === 'string' && (params as any).text.trim().length > 0; + if (hasTextCondition) { + try { + // Conditional wait for text appearance/disappearance using content script + await this.injectContentScript( + tab.id, + ['inject-scripts/wait-helper.js'], + false, + 'ISOLATED', + true, + ); + const appear = (params as any).appear !== false; // default to true + const timeoutMs = Math.max( + 0, + Math.min(((params as any).timeout as number) || 10000, 120000), + ); + const resp = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.WAIT_FOR_TEXT, + text: (params as any).text, + appear, + timeout: timeoutMs, + }); + if (!resp || resp.success !== true) { + return createErrorResponse( + resp && resp.reason === 'timeout' + ? `wait_for timed out after ${timeoutMs}ms for text: ${(params as any).text}` + : `wait_for failed: ${resp && resp.error ? resp.error : 'unknown error'}`, + ); + } + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'wait_for', + appear, + text: (params as any).text, + matched: resp.matched || null, + tookMs: resp.tookMs, + }), + }, + ], + isError: false, + }; + } catch (e) { + return createErrorResponse( + `wait_for failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } else { + const seconds = Math.max(0, Math.min((params as any).duration || 0, 30)); + if (!seconds) + return createErrorResponse('Duration parameter is required and must be > 0'); + await new Promise((r) => setTimeout(r, seconds * 1000)); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, action: 'wait', duration: seconds }), + }, + ], + isError: false, + }; + } + } + case 'scroll_to': { + if (!params.ref) { + return createErrorResponse('ref is required for scroll_to action'); + } + try { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + const resp = await this.sendMessageToTab(tab.id, { + action: 'focusByRef', + ref: params.ref, + }); + if (!resp || resp.success !== true) { + return createErrorResponse(resp?.error || 'scroll_to failed: element not found'); + } + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'scroll_to', + ref: params.ref, + }), + }, + ], + isError: false, + }; + } catch (e) { + return createErrorResponse( + `scroll_to failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + case 'zoom': { + const region = params.region; + if (!region) { + return createErrorResponse('region is required for zoom action'); + } + const x0 = Number(region.x0); + const y0 = Number(region.y0); + const x1 = Number(region.x1); + const y1 = Number(region.y1); + if (![x0, y0, x1, y1].every(Number.isFinite)) { + return createErrorResponse('region must contain finite numbers (x0, y0, x1, y1)'); + } + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0) { + return createErrorResponse('Invalid region: require x0>=0, y0>=0 and x1>x0, y1>y0'); + } + + // Project coordinates from screenshot space to viewport space + const p0 = project({ x: x0, y: y0 })!; + const p1 = project({ x: x1, y: y1 })!; + const rx0 = Math.min(p0.x, p1.x); + const ry0 = Math.min(p0.y, p1.y); + const rx1 = Math.max(p0.x, p1.x); + const ry1 = Math.max(p0.y, p1.y); + const w = rx1 - rx0; + const h = ry1 - ry0; + if (w <= 0 || h <= 0) { + return createErrorResponse('Invalid region after projection'); + } + + // Security check: verify domain hasn't changed since last screenshot + { + const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return ''; + } + }; + const ctx = screenshotContextManager.getContext(tab.id!); + const contextHostname = (ctx as any)?.hostname as string | undefined; + const currentHostname = getHostname(tab.url || ''); + if (contextHostname && contextHostname !== currentHostname) { + return createErrorResponse( + `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during zoom. Capture a new screenshot first.`, + ); + } + } + + try { + await CDPHelper.attach(tab.id); + const metrics: any = await CDPHelper.send(tab.id, 'Page.getLayoutMetrics', {}); + const viewport = metrics?.layoutViewport || + metrics?.visualViewport || { + clientWidth: 800, + clientHeight: 600, + pageX: 0, + pageY: 0, + }; + const vw = Math.round(Number(viewport.clientWidth || 800)); + const vh = Math.round(Number(viewport.clientHeight || 600)); + if (rx1 > vw || ry1 > vh) { + await CDPHelper.detach(tab.id); + return createErrorResponse( + `Region exceeds viewport boundaries (${vw}x${vh}). Choose a region within the visible viewport.`, + ); + } + const pageX = Number(viewport.pageX || 0); + const pageY = Number(viewport.pageY || 0); + + const shot: any = await CDPHelper.send(tab.id, 'Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: false, + fromSurface: true, + clip: { + x: pageX + rx0, + y: pageY + ry0, + width: w, + height: h, + scale: 1, + }, + }); + await CDPHelper.detach(tab.id); + + const base64Data = String(shot?.data || ''); + if (!base64Data) { + return createErrorResponse('Failed to capture zoom screenshot via CDP'); + } + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'zoom', + mimeType: 'image/png', + base64Data, + region: { x0: rx0, y0: ry0, x1: rx1, y1: ry1 }, + }), + }, + ], + isError: false, + }; + } catch (e) { + await CDPHelper.detach(tab.id); + return createErrorResponse(`zoom failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + case 'screenshot': { + // Reuse existing screenshot tool; it already supports base64 save option + const result = await screenshotTool.execute({ + name: 'computer', + storeBase64: true, + fullPage: false, + }); + return result; + } + default: + return createErrorResponse(`Unsupported action: ${params.action}`); + } + } + + /** + * DOM-based hover fallback when CDP is unavailable + * Tries ref-based approach first (works with iframes), falls back to coordinates + */ + private async domHoverFallback( + tabId: number, + coord?: Coordinates, + resolvedBy?: 'ref' | 'selector' | 'coordinates', + ref?: string, + ): Promise { + // Try ref-based approach first (handles iframes correctly) + if (ref) { + try { + const resp = await this.sendMessageToTab(tabId, { + action: TOOL_MESSAGE_TYPES.DISPATCH_HOVER_FOR_REF, + ref, + }); + if (resp?.success) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'hover', + resolvedBy: 'ref', + transport: 'dom-ref', + target: resp.target, + }), + }, + ], + isError: false, + }; + } + } catch (error) { + console.warn('[ComputerTool] DOM ref hover failed, falling back to coordinates', error); + } + } + + // Fallback to coordinate-based approach + if (!coord) { + return createErrorResponse('Hover fallback requires coordinates or ref'); + } + + try { + const [injection] = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: (point) => { + const target = document.elementFromPoint(point.x, point.y); + if (!target) { + return { success: false, error: 'No element found at coordinates' }; + } + + // Dispatch hover-related events + for (const type of ['mousemove', 'mouseover', 'mouseenter']) { + target.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: point.x, + clientY: point.y, + view: window, + }), + ); + } + + return { + success: true, + target: { + tagName: target.tagName, + id: target.id, + className: target.className, + text: target.textContent?.trim()?.slice(0, 100) || '', + }, + }; + }, + args: [coord], + }); + + const payload = injection?.result; + if (!payload?.success) { + return createErrorResponse(payload?.error || 'DOM hover fallback failed'); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + action: 'hover', + coordinates: coord, + resolvedBy, + transport: 'dom', + target: payload.target, + }), + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResponse( + `DOM hover fallback failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Trigger GIF auto-capture after a successful action. + * This is a no-op if auto-capture is not active. + */ + private async triggerAutoCapture( + tabId: number, + actionType: ActionType, + metadata?: Partial, + ): Promise { + if (!isAutoCaptureActive(tabId)) { + return; + } + + try { + await captureFrameOnAction(tabId, { + type: actionType, + ...metadata, + }); + } catch (error) { + // Log but don't fail the main action + console.warn('[ComputerTool] Auto-capture failed:', error); + } + } +} + +export const computerTool = new ComputerTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/console-buffer.ts b/app/chrome-extension/entrypoints/background/tools/browser/console-buffer.ts new file mode 100644 index 0000000..4dd9fdb --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/console-buffer.ts @@ -0,0 +1,450 @@ +import { cdpSessionManager } from '@/utils/cdp-session-manager'; + +/** + * ConsoleBuffer - 持久化的控制台日志缓冲管理器 + * + * 为每个 tab 维护一个滚动缓冲区,持续收集控制台事件。 + * 当 tab 导航到新域名时会自动清空缓冲,避免不同站点日志混淆。 + */ + +const DEFAULT_MAX_BUFFER_MESSAGES = 2000; +const DEFAULT_MAX_BUFFER_EXCEPTIONS = 500; + +export interface BufferedConsoleMessage { + timestamp: number; + level: string; + text: string; + args?: unknown[]; + source?: string; + url?: string; + lineNumber?: number; + stackTrace?: unknown; +} + +export interface BufferedConsoleException { + timestamp: number; + text: string; + url?: string; + lineNumber?: number; + columnNumber?: number; + stackTrace?: unknown; +} + +interface TabConsoleBufferState { + tabId: number; + tabUrl: string; + tabTitle: string; + hostname: string; + captureStartTime: number; + messages: BufferedConsoleMessage[]; + exceptions: BufferedConsoleException[]; + droppedMessageCount: number; + droppedExceptionCount: number; +} + +export interface ConsoleBufferReadOptions { + pattern?: RegExp; + onlyErrors?: boolean; + limit?: number; + includeExceptions?: boolean; +} + +export interface ConsoleBufferReadResult { + tabId: number; + tabUrl: string; + tabTitle: string; + captureStartTime: number; + captureEndTime: number; + totalDurationMs: number; + messages: BufferedConsoleMessage[]; + exceptions: BufferedConsoleException[]; + totalBufferedMessages: number; + totalBufferedExceptions: number; + messageCount: number; + exceptionCount: number; + messageLimitReached: boolean; + droppedMessageCount: number; + droppedExceptionCount: number; +} + +function extractHostname(url?: string): string { + if (!url) return ''; + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +function isErrorLevel(level?: string): boolean { + const normalized = (level || '').toLowerCase(); + return normalized === 'error' || normalized === 'assert'; +} + +function matchesPattern(pattern: RegExp, text: string): boolean { + pattern.lastIndex = 0; + return pattern.test(text); +} + +function formatConsoleArgs(args: unknown[]): string { + if (!args || args.length === 0) return ''; + + return args + .map((arg: unknown) => { + const a = arg as Record; + if (a.type === 'string') return (a.value as string) || ''; + if (a.type === 'number') return String(a.value ?? ''); + if (a.type === 'boolean') return String(a.value ?? ''); + if (a.type === 'object') return (a.description as string) || '[Object]'; + if (a.type === 'undefined') return 'undefined'; + if (a.type === 'function') return (a.description as string) || '[Function]'; + return (a.description as string) || (a.value as string) || String(arg); + }) + .join(' '); +} + +/** + * 从 CDP RemoteObject 提取安全的预览数据,丢弃 objectId 避免内存泄漏 + */ +function extractArgPreview(arg: unknown): unknown { + const a = arg as Record; + if (!a || typeof a !== 'object') return arg; + + // 只保留安全的字段,丢弃 objectId + const preview: Record = { + type: a.type, + }; + + if ('value' in a) preview.value = a.value; + if ('unserializableValue' in a) preview.unserializableValue = a.unserializableValue; + if ('description' in a) preview.description = a.description; + if ('subtype' in a) preview.subtype = a.subtype; + if ('className' in a) preview.className = a.className; + + return preview; +} + +function safeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + return Date.now(); +} + +function safeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function safeNumber(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +class ConsoleBuffer { + private buffers = new Map(); + private starting = new Map>(); + private static instance: ConsoleBuffer | null = null; + + constructor() { + if (ConsoleBuffer.instance) { + return ConsoleBuffer.instance; + } + ConsoleBuffer.instance = this; + + chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this)); + chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this)); + chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this)); + chrome.tabs.onUpdated.addListener(this.handleTabUpdated.bind(this)); + } + + /** + * 检查指定 tab 是否正在进行 buffer 模式的捕获 + */ + isCapturing(tabId: number): boolean { + return this.buffers.has(tabId); + } + + /** + * 确保指定 tab 的 buffer 捕获已启动 + */ + async ensureStarted(tabId: number): Promise { + if (this.buffers.has(tabId)) return; + + const existing = this.starting.get(tabId); + if (existing) return existing; + + const promise = this.startCapture(tabId).finally(() => { + this.starting.delete(tabId); + }); + this.starting.set(tabId, promise); + return promise; + } + + /** + * 清空指定 tab 的缓冲区 + */ + clear( + tabId: number, + reason: string = 'manual', + ): { clearedMessages: number; clearedExceptions: number } | null { + const state = this.buffers.get(tabId); + if (!state) return null; + + const clearedMessages = state.messages.length; + const clearedExceptions = state.exceptions.length; + + state.messages.length = 0; + state.exceptions.length = 0; + state.droppedMessageCount = 0; + state.droppedExceptionCount = 0; + state.captureStartTime = Date.now(); + + console.log( + `ConsoleBuffer: Cleared buffer for tab ${tabId} (reason=${reason}). ` + + `${clearedMessages} messages, ${clearedExceptions} exceptions.`, + ); + + return { clearedMessages, clearedExceptions }; + } + + /** + * 读取指定 tab 的缓冲区内容 + */ + read(tabId: number, options: ConsoleBufferReadOptions = {}): ConsoleBufferReadResult | null { + const state = this.buffers.get(tabId); + if (!state) return null; + + const { pattern, onlyErrors = false, limit, includeExceptions = true } = options; + + const totalBufferedMessages = state.messages.length; + const totalBufferedExceptions = state.exceptions.length; + + // 过滤消息 + let messages = state.messages; + if (onlyErrors) { + messages = messages.filter((m) => isErrorLevel(m.level)); + } + if (pattern) { + messages = messages.filter((m) => matchesPattern(pattern, m.text || '')); + } + + // 按时间排序 + messages = [...messages].sort((a, b) => a.timestamp - b.timestamp); + + // 应用 limit + let messageLimitReached = false; + const normalizedLimit = + typeof limit === 'number' && Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : null; + if (normalizedLimit !== null && messages.length > normalizedLimit) { + messageLimitReached = true; + // 保留最新的消息 + messages = messages.slice(messages.length - normalizedLimit); + } + + // 过滤异常 + let exceptions: BufferedConsoleException[] = []; + if (includeExceptions) { + exceptions = state.exceptions; + if (pattern) { + exceptions = exceptions.filter((e) => matchesPattern(pattern, e.text || '')); + } + exceptions = [...exceptions].sort((a, b) => a.timestamp - b.timestamp); + } + + const now = Date.now(); + + return { + tabId, + tabUrl: state.tabUrl, + tabTitle: state.tabTitle, + captureStartTime: state.captureStartTime, + captureEndTime: now, + totalDurationMs: now - state.captureStartTime, + messages, + exceptions, + totalBufferedMessages, + totalBufferedExceptions, + messageCount: messages.length, + exceptionCount: exceptions.length, + messageLimitReached, + droppedMessageCount: state.droppedMessageCount, + droppedExceptionCount: state.droppedExceptionCount, + }; + } + + private async startCapture(tabId: number): Promise { + const tab = await chrome.tabs.get(tabId); + const url = tab.url || ''; + const title = tab.title || ''; + const hostname = extractHostname(url); + + const state: TabConsoleBufferState = { + tabId, + tabUrl: url, + tabTitle: title, + hostname, + captureStartTime: Date.now(), + messages: [], + exceptions: [], + droppedMessageCount: 0, + droppedExceptionCount: 0, + }; + + this.buffers.set(tabId, state); + + try { + await cdpSessionManager.attach(tabId, 'console-buffer'); + await cdpSessionManager.sendCommand(tabId, 'Runtime.enable'); + await cdpSessionManager.sendCommand(tabId, 'Log.enable'); + } catch (error) { + this.buffers.delete(tabId); + await cdpSessionManager.detach(tabId, 'console-buffer').catch(() => {}); + throw error; + } + } + + private handleTabRemoved(tabId: number): void { + if (!this.buffers.has(tabId)) return; + void this.stopCapture(tabId, 'tab_closed'); + } + + private handleTabUpdated( + tabId: number, + changeInfo: chrome.tabs.TabChangeInfo, + tab: chrome.tabs.Tab, + ): void { + const state = this.buffers.get(tabId); + if (!state) return; + + const nextUrl = changeInfo.url ?? tab.url; + const nextTitle = tab.title; + + if (typeof nextUrl === 'string') { + const nextHost = extractHostname(nextUrl); + // 域名变化时清空缓冲 + if (nextHost !== state.hostname) { + this.clear(tabId, 'domain_changed'); + state.hostname = nextHost; + } + state.tabUrl = nextUrl; + } + + if (typeof nextTitle === 'string') { + state.tabTitle = nextTitle; + } + } + + private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void { + if (typeof source.tabId !== 'number') return; + if (!this.buffers.has(source.tabId)) return; + + console.log( + `ConsoleBuffer: Debugger detached from tab ${source.tabId} (reason=${reason}), cleaning up.`, + ); + + this.buffers.delete(source.tabId); + this.starting.delete(source.tabId); + cdpSessionManager.detach(source.tabId, 'console-buffer').catch(() => {}); + } + + private handleDebuggerEvent( + source: chrome.debugger.Debuggee, + method: string, + params?: unknown, + ): void { + const tabId = source.tabId; + if (typeof tabId !== 'number') return; + + const state = this.buffers.get(tabId); + if (!state) return; + + const p = params as Record; + + if (method === 'Log.entryAdded' && p?.entry) { + const entry = p.entry as Record; + state.messages.push({ + timestamp: safeTimestamp(entry.timestamp), + level: safeString(entry.level) || 'log', + text: safeString(entry.text), + source: safeString(entry.source), + url: safeString(entry.url), + lineNumber: safeNumber(entry.lineNumber), + stackTrace: entry.stackTrace, + }); + this.trimMessages(state); + return; + } + + if (method === 'Runtime.consoleAPICalled' && p) { + const stackTrace = p.stackTrace as Record | undefined; + const callFrame = stackTrace?.callFrames?.[0] as Record | undefined; + const rawArgs = (p.args as unknown[]) || []; + + state.messages.push({ + timestamp: safeTimestamp(p.timestamp), + level: safeString(p.type) || 'log', + text: formatConsoleArgs(rawArgs), + source: 'console-api', + url: safeString(callFrame?.url), + lineNumber: safeNumber(callFrame?.lineNumber), + stackTrace: stackTrace, + // 只存储安全的预览数据,避免内存泄漏 + args: rawArgs.map(extractArgPreview), + }); + this.trimMessages(state); + return; + } + + if (method === 'Runtime.exceptionThrown' && p?.exceptionDetails) { + const exceptionDetails = p.exceptionDetails as Record; + const exception = exceptionDetails.exception as Record | undefined; + state.exceptions.push({ + timestamp: Date.now(), + text: + safeString(exceptionDetails.text) || + safeString(exception?.description) || + 'Unknown exception', + url: safeString(exceptionDetails.url), + lineNumber: safeNumber(exceptionDetails.lineNumber), + columnNumber: safeNumber(exceptionDetails.columnNumber), + stackTrace: exceptionDetails.stackTrace, + }); + this.trimExceptions(state); + } + } + + private trimMessages(state: TabConsoleBufferState): void { + const overflow = state.messages.length - DEFAULT_MAX_BUFFER_MESSAGES; + if (overflow <= 0) return; + state.messages.splice(0, overflow); + state.droppedMessageCount += overflow; + } + + private trimExceptions(state: TabConsoleBufferState): void { + const overflow = state.exceptions.length - DEFAULT_MAX_BUFFER_EXCEPTIONS; + if (overflow <= 0) return; + state.exceptions.splice(0, overflow); + state.droppedExceptionCount += overflow; + } + + private async stopCapture(tabId: number, reason: string): Promise { + if (!this.buffers.has(tabId)) return; + + this.buffers.delete(tabId); + this.starting.delete(tabId); + + try { + await cdpSessionManager.sendCommand(tabId, 'Runtime.disable'); + } catch { + // best effort + } + try { + await cdpSessionManager.sendCommand(tabId, 'Log.disable'); + } catch { + // best effort + } + await cdpSessionManager.detach(tabId, 'console-buffer').catch(() => {}); + console.log(`ConsoleBuffer: Stopped buffer for tab ${tabId} (reason=${reason}).`); + } +} + +export const consoleBuffer = new ConsoleBuffer(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/console.ts b/app/chrome-extension/entrypoints/background/tools/browser/console.ts new file mode 100644 index 0000000..e524ac9 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/console.ts @@ -0,0 +1,628 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { consoleBuffer, BufferedConsoleMessage, BufferedConsoleException } from './console-buffer'; + +const DEFAULT_MAX_MESSAGES = 100; + +type ConsoleMode = 'snapshot' | 'buffer'; + +interface ConsoleToolParams { + url?: string; + tabId?: number; + background?: boolean; + windowId?: number; + includeExceptions?: boolean; + maxMessages?: number; + // 新增参数 + mode?: ConsoleMode; + buffer?: boolean; // mode="buffer" 的别名 + clear?: boolean; // 读取前清空 + clearAfterRead?: boolean; // 读取后清空(mcp-tools.js 风格) + pattern?: string; + onlyErrors?: boolean; + limit?: number; +} + +interface ConsoleMessage { + timestamp: number; + level: string; + text: string; + args?: any[]; + argsSerialized?: any[]; + source?: string; + url?: string; + lineNumber?: number; + stackTrace?: any; +} + +interface ConsoleException { + timestamp: number; + text: string; + url?: string; + lineNumber?: number; + columnNumber?: number; + stackTrace?: any; +} + +interface ConsoleResult { + success: boolean; + message: string; + tabId: number; + tabUrl: string; + tabTitle: string; + captureStartTime: number; + captureEndTime: number; + totalDurationMs: number; + messages: ConsoleMessage[]; + exceptions: ConsoleException[]; + messageCount: number; + exceptionCount: number; + messageLimitReached: boolean; + droppedMessageCount: number; + droppedExceptionCount: number; +} + +// 辅助函数 + +function normalizeLimit(value: unknown, fallback: number): number { + const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback; + return Math.max(0, n); +} + +function parseRegexPattern(pattern?: string): RegExp | undefined { + if (typeof pattern !== 'string') return undefined; + const trimmed = pattern.trim(); + if (!trimmed) return undefined; + // 支持 /pattern/flags 语法 + const match = trimmed.match(/^\/(.+)\/([gimsuy]*)$/); + try { + return match ? new RegExp(match[1], match[2]) : new RegExp(trimmed); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`Invalid regex pattern: ${msg}`); + } +} + +function matchesPattern(pattern: RegExp, text: string): boolean { + pattern.lastIndex = 0; + return pattern.test(text); +} + +function isErrorLevel(level?: string): boolean { + const normalized = (level || '').toLowerCase(); + return normalized === 'error' || normalized === 'assert'; +} + +function applyResultFilters( + result: ConsoleResult, + options: { pattern?: RegExp; onlyErrors?: boolean; includeExceptions: boolean }, +): ConsoleResult { + const { pattern, onlyErrors = false, includeExceptions } = options; + + let messages = result.messages; + if (onlyErrors) { + messages = messages.filter((m) => isErrorLevel(m.level)); + } + if (pattern) { + messages = messages.filter((m) => matchesPattern(pattern, m.text || '')); + } + + let exceptions = includeExceptions ? result.exceptions : []; + if (includeExceptions && pattern) { + exceptions = exceptions.filter((e) => matchesPattern(pattern, e.text || '')); + } + + return { + ...result, + messages, + exceptions, + messageCount: messages.length, + exceptionCount: exceptions.length, + }; +} + +function isDebuggerConflictError(error: unknown): boolean { + const msg = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return msg.includes('debugger is already attached') || msg.includes('another client'); +} + +function formatDebuggerConflictMessage(tabId: number, originalMessage: string): string { + return ( + `Failed to attach Chrome Debugger to tab ${tabId}: another debugger client is already attached ` + + `(likely DevTools or another extension). Close DevTools for this tab or disable the conflicting extension, ` + + `then retry. Original error: ${originalMessage}` + ); +} + +/** + * Tool for capturing console output from browser tabs + */ +class ConsoleTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.CONSOLE; + + async execute(args: ConsoleToolParams): Promise { + const { + url, + tabId, + windowId, + background = false, + includeExceptions = true, + maxMessages = DEFAULT_MAX_MESSAGES, + mode = 'snapshot', + buffer, + clear = false, + clearAfterRead = false, + pattern, + onlyErrors = false, + limit, + } = args; + + let targetTab: chrome.tabs.Tab; + let targetTabId: number | undefined; + + // 解析正则表达式 + let compiledPattern: RegExp | undefined; + try { + compiledPattern = parseRegexPattern(pattern); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return createErrorResponse(msg); + } + + try { + if (typeof tabId === 'number') { + // Use explicit tab + const t = await chrome.tabs.get(tabId); + if (!t?.id) return createErrorResponse('Failed to identify target tab.'); + targetTab = t; + } else if (url) { + // Navigate to the specified URL + targetTab = await this.navigateToUrl(url, background === true, windowId); + } else { + // Use current active tab + const [activeTab] = + typeof windowId === 'number' + ? await chrome.tabs.query({ active: true, windowId }) + : await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) { + return createErrorResponse('No active tab found and no URL provided.'); + } + targetTab = activeTab; + } + + if (!targetTab?.id) { + return createErrorResponse('Failed to identify target tab.'); + } + + targetTabId = targetTab.id; + + // 确定模式:buffer 参数是 mode="buffer" 的别名 + const resolvedMode: ConsoleMode = + mode === 'buffer' || buffer === true ? 'buffer' : 'snapshot'; + + // 计算有效的消息限制 + const normalizedMaxMessages = normalizeLimit(maxMessages, DEFAULT_MAX_MESSAGES); + const effectiveLimit = + typeof limit === 'number' + ? normalizeLimit(limit, normalizedMaxMessages) + : normalizedMaxMessages; + + // Buffer 模式 + if (resolvedMode === 'buffer') { + try { + await consoleBuffer.ensureStarted(targetTabId); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + if (isDebuggerConflictError(error)) { + return createErrorResponse(formatDebuggerConflictMessage(targetTabId, msg)); + } + throw error; + } + + // 处理读取前清空请求 + let clearedBefore: { clearedMessages: number; clearedExceptions: number } | null = null; + if (clear === true) { + clearedBefore = consoleBuffer.clear(targetTabId, 'manual'); + } + + // 读取缓冲区 + const read = consoleBuffer.read(targetTabId, { + pattern: compiledPattern, + onlyErrors, + limit: effectiveLimit, + includeExceptions, + }); + + if (!read) { + return createErrorResponse('Console buffer is not available for this tab.'); + } + + // 处理读取后清空请求(mcp-tools.js 风格,避免重复读取) + let clearedAfter: { clearedMessages: number; clearedExceptions: number } | null = null; + if (clearAfterRead === true) { + clearedAfter = consoleBuffer.clear(targetTabId, 'manual'); + } + + // 构建清空摘要 + let clearedSummary = ''; + if (clearedBefore) { + clearedSummary += ` Cleared ${clearedBefore.clearedMessages} messages and ${clearedBefore.clearedExceptions} exceptions before reading.`; + } + if (clearedAfter) { + clearedSummary += ` Cleared ${clearedAfter.clearedMessages} messages and ${clearedAfter.clearedExceptions} exceptions after reading.`; + } + + const result: ConsoleResult = { + success: true, + message: + `Console buffer read for tab ${targetTabId}.` + + clearedSummary + + ` Returned ${read.messageCount} messages and ${read.exceptionCount} exceptions.`, + tabId: targetTabId, + tabUrl: read.tabUrl || '', + tabTitle: read.tabTitle || '', + captureStartTime: read.captureStartTime, + captureEndTime: read.captureEndTime, + totalDurationMs: read.totalDurationMs, + messages: read.messages as ConsoleMessage[], + exceptions: read.exceptions as ConsoleException[], + messageCount: read.messageCount, + exceptionCount: read.exceptionCount, + messageLimitReached: read.messageLimitReached, + droppedMessageCount: read.droppedMessageCount, + droppedExceptionCount: read.droppedExceptionCount, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: false, + }; + } + + // Snapshot 模式(一次性捕获) + const result = await this.captureConsoleMessages(targetTabId, { + includeExceptions, + maxMessages: effectiveLimit, + }); + + // 应用过滤器 + const filtered = applyResultFilters(result, { + pattern: compiledPattern, + onlyErrors, + includeExceptions, + }); + + return { + content: [{ type: 'text', text: JSON.stringify(filtered) }], + isError: false, + }; + } catch (error: unknown) { + console.error('ConsoleTool: Critical error during execute:', error); + const msg = error instanceof Error ? error.message : String(error); + if (typeof targetTabId === 'number' && isDebuggerConflictError(error)) { + return createErrorResponse(formatDebuggerConflictMessage(targetTabId, msg)); + } + return createErrorResponse(`Error in ConsoleTool: ${msg}`); + } + } + + private async navigateToUrl( + url: string, + background = false, + windowId?: number, + ): Promise { + // Check if URL is already open + const existingTabs = await chrome.tabs.query({ url }); + + if (existingTabs.length > 0 && existingTabs[0]?.id) { + const tab = existingTabs[0]; + if (!background) { + // Activate the existing tab + await chrome.tabs.update(tab.id!, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + } + return tab; + } else { + // Create new tab with the URL + const createInfo: chrome.tabs.CreateProperties = { url, active: background ? false : true }; + if (typeof windowId === 'number') createInfo.windowId = windowId; + const newTab = await chrome.tabs.create(createInfo); + // Wait for tab to be ready + await this.waitForTabReady(newTab.id!); + return newTab; + } + } + + private async waitForTabReady(tabId: number): Promise { + return new Promise((resolve) => { + const checkTab = async () => { + try { + const tab = await chrome.tabs.get(tabId); + if (tab.status === 'complete') { + resolve(); + } else { + setTimeout(checkTab, 100); + } + } catch (error) { + // Tab might be closed, resolve anyway + resolve(); + } + }; + checkTab(); + }); + } + + private formatConsoleArgs(args: any[]): string { + if (!args || args.length === 0) return ''; + + return args + .map((arg) => { + if (arg.type === 'string') { + return arg.value || ''; + } else if (arg.type === 'number') { + return String(arg.value || ''); + } else if (arg.type === 'boolean') { + return String(arg.value || ''); + } else if (arg.type === 'object') { + return arg.description || '[Object]'; + } else if (arg.type === 'undefined') { + return 'undefined'; + } else if (arg.type === 'function') { + return arg.description || '[Function]'; + } else { + return arg.description || arg.value || String(arg); + } + }) + .join(' '); + } + + private async captureConsoleMessages( + tabId: number, + options: { + includeExceptions: boolean; + maxMessages: number; + }, + ): Promise { + const { includeExceptions, maxMessages } = options; + const startTime = Date.now(); + const messages: ConsoleMessage[] = []; + const exceptions: ConsoleException[] = []; + let limitReached = false; + + try { + // Get tab information + const tab = await chrome.tabs.get(tabId); + + // Attach via shared manager + await cdpSessionManager.attach(tabId, 'console'); + + // Set up event listener to collect messages + const collectedMessages: any[] = []; + const collectedExceptions: any[] = []; + + const eventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => { + if (source.tabId !== tabId) return; + + if (method === 'Log.entryAdded' && params?.entry) { + collectedMessages.push(params.entry); + } else if (method === 'Runtime.consoleAPICalled' && params) { + // Convert Runtime.consoleAPICalled to Log.entryAdded format + const logEntry = { + timestamp: params.timestamp, + level: params.type || 'log', + text: this.formatConsoleArgs(params.args || []), + source: 'console-api', + url: params.stackTrace?.callFrames?.[0]?.url, + lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber, + stackTrace: params.stackTrace, + args: params.args, + }; + collectedMessages.push(logEntry); + } else if ( + method === 'Runtime.exceptionThrown' && + includeExceptions && + params?.exceptionDetails + ) { + collectedExceptions.push(params.exceptionDetails); + } + }; + + chrome.debugger.onEvent.addListener(eventListener); + + try { + // Enable Runtime domain first to capture console API calls and exceptions + await cdpSessionManager.sendCommand(tabId, 'Runtime.enable'); + + // Also enable Log domain to capture other log entries + await cdpSessionManager.sendCommand(tabId, 'Log.enable'); + + // Wait for all messages to be flushed + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Process collected messages + // Helper to deeply serialize console arguments when possible + const serializeArg = async (arg: any): Promise => { + try { + if (!arg) return arg; + if (Object.prototype.hasOwnProperty.call(arg, 'unserializableValue')) { + return arg.unserializableValue; + } + if (Object.prototype.hasOwnProperty.call(arg, 'value')) { + return arg.value; + } + if (arg.objectId) { + const resp = await cdpSessionManager.sendCommand(tabId, 'Runtime.callFunctionOn', { + objectId: arg.objectId, + functionDeclaration: + 'function(maxDepth, maxProps){\n' + + ' const seen=new WeakSet();\n' + + ' function S(v,d){\n' + + ' try{\n' + + ' if(d<0) return "[MaxDepth]";\n' + + ' if(v===null) return null;\n' + + ' const t=typeof v;\n' + + ' if(t!=="object"){\n' + + ' if(t==="bigint") return v.toString()+"n";\n' + + ' return v;\n' + + ' }\n' + + ' if(seen.has(v)) return "[Circular]";\n' + + ' seen.add(v);\n' + + ' if(Array.isArray(v)){\n' + + ' const out=[];\n' + + ' for(let i=0;i=maxProps){ out.push("[...truncated]"); break; }\n' + + ' out.push(S(v[i], d-1));\n' + + ' }\n' + + ' return out;\n' + + ' }\n' + + ' if(v instanceof Date) return {__type:"Date", value:v.toISOString()};\n' + + ' if(v instanceof RegExp) return {__type:"RegExp", value:String(v)};\n' + + ' if(v instanceof Map){\n' + + ' const out={__type:"Map", entries:[]}; let c=0;\n' + + ' for(const [k,val] of v.entries()){\n' + + ' if(c++>=maxProps){ out.entries.push(["[...truncated]","[...truncated]"]); break; }\n' + + ' out.entries.push([S(k,d-1), S(val,d-1)]);\n' + + ' }\n' + + ' return out;\n' + + ' }\n' + + ' if(v instanceof Set){\n' + + ' const out={__type:"Set", values:[]}; let c=0;\n' + + ' for(const val of v.values()){\n' + + ' if(c++>=maxProps){ out.values.push("[...truncated]"); break; }\n' + + ' out.values.push(S(val,d-1));\n' + + ' }\n' + + ' return out;\n' + + ' }\n' + + ' const out={}; let c=0;\n' + + ' for(const key in v){\n' + + ' if(c++>=maxProps){ out.__truncated__=true; break; }\n' + + ' try{ out[key]=S(v[key], d-1); }catch(e){ out[key]="[Thrown]"; }\n' + + ' }\n' + + ' return out;\n' + + ' }catch(e){ return "[Unserializable]" }\n' + + ' }\n' + + ' return S(this, maxDepth);\n' + + '}', + arguments: [{ value: 3 }, { value: 100 }], + silent: true, + returnByValue: true, + }); + return resp?.result?.value ?? '[Unavailable]'; + } + return '[Unknown]'; + } catch (e) { + return '[SerializeError]'; + } + }; + + for (const entry of collectedMessages) { + if (messages.length >= maxMessages) { + limitReached = true; + break; + } + + const message: ConsoleMessage = { + timestamp: entry.timestamp, + level: entry.level || 'log', + text: entry.text || '', + source: entry.source, + url: entry.url, + lineNumber: entry.lineNumber, + }; + + if (entry.stackTrace) { + message.stackTrace = entry.stackTrace; + } + + if (entry.args && Array.isArray(entry.args)) { + message.args = entry.args; + // Attempt deep serialization for better fidelity + const serialized: any[] = []; + for (const a of entry.args) { + serialized.push(await serializeArg(a)); + } + message.argsSerialized = serialized; + } + + messages.push(message); + } + + // Process collected exceptions + for (const exceptionDetails of collectedExceptions) { + const exception: ConsoleException = { + timestamp: Date.now(), + text: + exceptionDetails.text || + exceptionDetails.exception?.description || + 'Unknown exception', + url: exceptionDetails.url, + lineNumber: exceptionDetails.lineNumber, + columnNumber: exceptionDetails.columnNumber, + }; + + if (exceptionDetails.stackTrace) { + exception.stackTrace = exceptionDetails.stackTrace; + } + + exceptions.push(exception); + } + } finally { + // Clean up + chrome.debugger.onEvent.removeListener(eventListener); + + // 如果 buffer 模式正在使用这个 tab,不要关闭 Runtime/Log 域 + const keepDomainsEnabled = consoleBuffer.isCapturing(tabId); + if (!keepDomainsEnabled) { + try { + await cdpSessionManager.sendCommand(tabId, 'Runtime.disable'); + } catch (e) { + console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e); + } + + try { + await cdpSessionManager.sendCommand(tabId, 'Log.disable'); + } catch (e) { + console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e); + } + } + + try { + await cdpSessionManager.detach(tabId, 'console'); + } catch (e) { + console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e); + } + } + + const endTime = Date.now(); + + // Sort messages by timestamp + messages.sort((a, b) => a.timestamp - b.timestamp); + exceptions.sort((a, b) => a.timestamp - b.timestamp); + + return { + success: true, + message: `Console capture completed for tab ${tabId}. ${messages.length} messages, ${exceptions.length} exceptions captured.`, + tabId, + tabUrl: tab.url || '', + tabTitle: tab.title || '', + captureStartTime: startTime, + captureEndTime: endTime, + totalDurationMs: endTime - startTime, + messages, + exceptions, + messageCount: messages.length, + exceptionCount: exceptions.length, + messageLimitReached: limitReached, + droppedMessageCount: 0, + droppedExceptionCount: 0, + }; + } catch (error: any) { + console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error); + throw error; + } + } +} + +export const consoleTool = new ConsoleTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts b/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts new file mode 100644 index 0000000..b30fca3 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts @@ -0,0 +1,54 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; + +interface HandleDialogParams { + action: 'accept' | 'dismiss'; + promptText?: string; +} + +/** + * Handle JavaScript dialogs (alert/confirm/prompt) via CDP Page.handleJavaScriptDialog + */ +class HandleDialogTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.HANDLE_DIALOG; + + async execute(args: HandleDialogParams): Promise { + const { action, promptText } = args || ({} as HandleDialogParams); + if (!action || (action !== 'accept' && action !== 'dismiss')) { + return createErrorResponse('action must be "accept" or "dismiss"'); + } + + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) return createErrorResponse('No active tab found'); + const tabId = activeTab.id!; + + // Use shared CDP session manager for safe attach/detach with refcount + await cdpSessionManager.withSession(tabId, 'dialog', async () => { + await cdpSessionManager.sendCommand(tabId, 'Page.enable'); + await cdpSessionManager.sendCommand(tabId, 'Page.handleJavaScriptDialog', { + accept: action === 'accept', + promptText: action === 'accept' ? promptText : undefined, + }); + }); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, action, promptText: promptText || null }), + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResponse( + `Failed to handle dialog: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const handleDialogTool = new HandleDialogTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/download.ts b/app/chrome-extension/entrypoints/background/tools/browser/download.ts new file mode 100644 index 0000000..5477953 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/download.ts @@ -0,0 +1,123 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; + +interface HandleDownloadParams { + filenameContains?: string; + timeoutMs?: number; // default 60000 + waitForComplete?: boolean; // default true +} + +/** + * Tool: wait for a download and return info + */ +class HandleDownloadTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD as any; + + async execute(args: HandleDownloadParams): Promise { + const filenameContains = String(args?.filenameContains || '').trim(); + const waitForComplete = args?.waitForComplete !== false; + const timeoutMs = Math.max(1000, Math.min(Number(args?.timeoutMs ?? 60000), 300000)); + + try { + const result = await waitForDownload({ filenameContains, waitForComplete, timeoutMs }); + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, download: result }) }], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Handle download failed: ${e?.message || String(e)}`); + } + } +} + +async function waitForDownload(opts: { + filenameContains?: string; + waitForComplete: boolean; + timeoutMs: number; +}) { + const { filenameContains, waitForComplete, timeoutMs } = opts; + return new Promise((resolve, reject) => { + let timer: any = null; + const onError = (err: any) => { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + const cleanup = () => { + try { + if (timer) clearTimeout(timer); + } catch {} + try { + chrome.downloads.onCreated.removeListener(onCreated); + } catch {} + try { + chrome.downloads.onChanged.removeListener(onChanged); + } catch {} + }; + const matches = (item: chrome.downloads.DownloadItem) => { + if (!filenameContains) return true; + const name = (item.filename || '').split(/[/\\]/).pop() || ''; + return name.includes(filenameContains) || (item.url || '').includes(filenameContains); + }; + const fulfill = async (item: chrome.downloads.DownloadItem) => { + // try to fill more details via downloads.search + try { + const [found] = await chrome.downloads.search({ id: item.id }); + const out = found || item; + cleanup(); + resolve({ + id: out.id, + filename: out.filename, + url: out.url, + mime: (out as any).mime || undefined, + fileSize: out.fileSize ?? out.totalBytes ?? undefined, + state: out.state, + danger: out.danger, + startTime: out.startTime, + endTime: (out as any).endTime || undefined, + exists: (out as any).exists, + }); + return; + } catch { + cleanup(); + resolve({ id: item.id, filename: item.filename, url: item.url, state: item.state }); + } + }; + const onCreated = (item: chrome.downloads.DownloadItem) => { + try { + if (!matches(item)) return; + if (!waitForComplete) { + fulfill(item); + } + } catch {} + }; + const onChanged = (delta: chrome.downloads.DownloadDelta) => { + try { + if (!delta || typeof delta.id !== 'number') return; + // pull item and check + chrome.downloads + .search({ id: delta.id }) + .then((arr) => { + const item = arr && arr[0]; + if (!item) return; + if (!matches(item)) return; + if (waitForComplete && item.state === 'complete') fulfill(item); + }) + .catch(() => {}); + } catch {} + }; + chrome.downloads.onCreated.addListener(onCreated); + chrome.downloads.onChanged.addListener(onChanged); + timer = setTimeout(() => onError(new Error('Download wait timed out')), timeoutMs); + // Try to find an already-running matching download + chrome.downloads + .search({ state: waitForComplete ? 'in_progress' : undefined }) + .then((arr) => { + const hit = (arr || []).find((d) => matches(d)); + if (hit && !waitForComplete) fulfill(hit); + }) + .catch(() => {}); + }); +} + +export const handleDownloadTool = new HandleDownloadTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts b/app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts new file mode 100644 index 0000000..76fe9ab --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts @@ -0,0 +1,557 @@ +/** + * Element Picker Tool + * + * Implements chrome_request_element_selection - a human-in-the-loop tool that allows + * users to manually select elements on the page when AI cannot reliably locate them. + */ + +import { createErrorResponse, type ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { ERROR_MESSAGES } from '@/common/constants'; +import { + TOOL_NAMES, + type ElementPickerRequest, + type ElementPickerResult, + type ElementPickerResultItem, + type PickedElement, +} from 'chrome-mcp-shared'; + +// ============================================================ +// Types +// ============================================================ + +interface NormalizedRequest { + id: string; + name: string; + description?: string; +} + +interface ElementPickerToolParams { + requests: ElementPickerRequest[]; + timeoutMs?: number; + tabId?: number; + windowId?: number; +} + +interface PickerUiEvent { + type: string; + sessionId: string; + event: 'cancel' | 'confirm' | 'set_active_request' | 'clear_selection'; + requestId?: string; +} + +interface PickerFrameEvent { + type: string; + sessionId: string; + event: 'selected' | 'cancel'; + requestId?: string; + element?: Omit; +} + +// ============================================================ +// Constants +// ============================================================ + +const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +const MAX_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const MIN_TIMEOUT_MS = 10 * 1000; // 10 seconds + +// ============================================================ +// Utility Functions +// ============================================================ + +function toTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeTimeoutMs(value: unknown): number { + if (value === undefined || value === null) return DEFAULT_TIMEOUT_MS; + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS; + return Math.min(Math.max(Math.floor(n), MIN_TIMEOUT_MS), MAX_TIMEOUT_MS); +} + +function normalizeRequests(requests: ElementPickerRequest[]): NormalizedRequest[] { + const out: NormalizedRequest[] = []; + const seen = new Set(); + + for (let i = 0; i < requests.length; i++) { + const r = requests[i] || ({} as ElementPickerRequest); + const name = toTrimmedString(r.name); + if (!name) continue; + + // Generate or use provided ID, ensuring uniqueness + const baseId = toTrimmedString(r.id) || `req_${i + 1}`; + let id = baseId; + let suffix = 2; + while (seen.has(id)) { + id = `${baseId}_${suffix++}`; + } + seen.add(id); + + const description = toTrimmedString(r.description); + out.push({ id, name, description: description || undefined }); + } + + return out; +} + +function buildResultItems( + requests: NormalizedRequest[], + pickedById: Map, +): ElementPickerResultItem[] { + return requests.map((r) => ({ + id: r.id, + name: r.name, + element: pickedById.get(r.id) || null, + })); +} + +function listMissingRequestIds( + requests: NormalizedRequest[], + pickedById: Map, +): string[] { + const missing: string[] = []; + for (const r of requests) { + if (!pickedById.has(r.id)) missing.push(r.id); + } + return missing; +} + +// ============================================================ +// Element Picker Tool +// ============================================================ + +class ElementPickerTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION; + + /** + * Inject picker scripts into all frames of the tab. + */ + private async injectPickerScripts(tabId: number): Promise { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['inject-scripts/element-picker.js'], + world: 'ISOLATED', + injectImmediately: false, + } as any); + } + + /** + * Call the picker API in all frames via scripting.executeScript. + */ + private async callPickerApi( + tabId: number, + method: 'startSession' | 'stopSession' | 'setActiveRequest', + payload: Record, + ): Promise { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + world: 'ISOLATED', + injectImmediately: false, + func: (methodName: string, data: Record) => { + try { + const api = ( + globalThis as unknown as { + __mcpElementPicker?: Record) => void>; + } + ).__mcpElementPicker; + const fn = api && api[methodName]; + if (typeof fn === 'function') { + fn(data); + } + } catch { + // Best-effort + } + }, + args: [method, payload], + } as any); + } + + async execute(args: ElementPickerToolParams): Promise { + // Validate requests + const rawRequests = Array.isArray(args?.requests) ? args.requests : []; + if (rawRequests.length === 0) { + return createErrorResponse(`${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] is required`); + } + + const requests = normalizeRequests(rawRequests); + if (requests.length === 0) { + return createErrorResponse( + `${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] must contain at least one non-empty name`, + ); + } + + const timeoutMs = normalizeTimeoutMs(args?.timeoutMs); + const sessionId = `ep_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + const deadlineTs = Date.now() + timeoutMs; + + // Resolve tab + let tab: chrome.tabs.Tab; + try { + const explicit = await this.tryGetTab(args?.tabId); + tab = explicit || (await this.getActiveTabOrThrowInWindow(args?.windowId)); + } catch (error) { + return createErrorResponse( + `${ERROR_MESSAGES.TAB_NOT_FOUND}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!tab.id) { + return createErrorResponse(`${ERROR_MESSAGES.TAB_NOT_FOUND}: Active tab has no ID`); + } + const tabId = tab.id; + + // Focus the tab/window for user interaction + try { + await this.ensureFocus(tab, { activate: true, focusWindow: true }); + } catch { + // Best-effort: some environments disallow focusing + } + + // State tracking + const pickedById = new Map(); + let activeRequestId: string | null = requests[0]?.id || null; + let uiErrorMessage: string | null = null; + let uiAvailable = true; + + let finished = false; + let timer: ReturnType | null = null; + let resolveResult: ((result: ElementPickerResult) => void) | null = null; + + // Send UI update to content script + const sendUiUpdate = async (): Promise => { + if (!uiAvailable) return; + try { + const selections: Record = {}; + for (const r of requests) { + selections[r.id] = pickedById.get(r.id) || null; + } + await this.sendMessageToTab( + tabId, + { + action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE, + sessionId, + activeRequestId, + selections, + deadlineTs, + errorMessage: uiErrorMessage, + }, + 0, // Top frame only for UI + ); + } catch { + uiAvailable = false; + } + }; + + // Set the active request and notify all frames + UI + const setActiveRequest = async (requestId: string | null): Promise => { + activeRequestId = requestId; + await this.callPickerApi(tabId, 'setActiveRequest', { + sessionId, + activeRequestId: requestId, + }); + await sendUiUpdate(); + }; + + // Finish the tool execution + const finish = async (final: { + success: boolean; + cancelled?: boolean; + timedOut?: boolean; + }): Promise => { + if (finished) return; + finished = true; + + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + + chrome.runtime.onMessage.removeListener(onRuntimeMessage); + + // Cleanup: stop picker in all frames and hide UI + await Promise.allSettled([ + this.callPickerApi(tabId, 'stopSession', { sessionId }), + uiAvailable + ? this.sendMessageToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId }, + 0, + ) + : Promise.resolve(), + ]); + + const missing = listMissingRequestIds(requests, pickedById); + const result: ElementPickerResult = { + success: final.success, + sessionId, + timeoutMs, + cancelled: final.cancelled, + timedOut: final.timedOut, + missingRequestIds: missing.length > 0 ? missing : undefined, + results: buildResultItems(requests, pickedById), + }; + + resolveResult?.(result); + }; + + // Handle messages from content scripts + const onRuntimeMessage = ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, + ): boolean | void => { + const senderTabId = sender?.tab?.id; + if (senderTabId !== tabId) return; + + const msg = message as Partial | undefined; + if (!msg || msg.sessionId !== sessionId) return; + + // Handle frame events (element selection) + if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_FRAME_EVENT) { + if (msg.event === 'cancel') { + void finish({ success: false, cancelled: true }); + sendResponse?.({ success: true }); + return true; + } + + if (msg.event === 'selected') { + const requestId = toTrimmedString(msg.requestId); + const frameId = typeof sender.frameId === 'number' ? sender.frameId : 0; + + // Validate request ID + const reqExists = requestId && requests.some((r) => r.id === requestId); + if (!reqExists) { + sendResponse?.({ success: false, error: 'Unknown requestId' }); + return true; + } + + // Validate element data + const raw = (msg.element || {}) as Partial>; + const ref = toTrimmedString(raw.ref); + if (!ref) { + sendResponse?.({ success: false, error: 'Missing element.ref' }); + return true; + } + + // Build picked element with frameId + const selector = toTrimmedString(raw.selector); + const rect = raw.rect as PickedElement['rect'] | undefined; + const center = raw.center as PickedElement['center'] | undefined; + const picked: PickedElement = { + ref, + selector, + selectorType: 'css', + rect: rect && typeof rect === 'object' ? rect : { x: 0, y: 0, width: 0, height: 0 }, + center: center && typeof center === 'object' ? center : { x: 0, y: 0 }, + text: typeof raw.text === 'string' ? raw.text : undefined, + tagName: typeof raw.tagName === 'string' ? raw.tagName : undefined, + frameId, + }; + + pickedById.set(requestId, picked); + uiErrorMessage = null; + + // Auto-advance to next missing request + const missing = listMissingRequestIds(requests, pickedById); + const next = missing.length > 0 ? missing[0] : null; + + void (async () => { + try { + if (next) { + await setActiveRequest(next); + } else { + // All selected: update UI (user still needs to confirm) + await sendUiUpdate(); + // If UI is unavailable, auto-confirm + if (!uiAvailable) { + await finish({ success: true }); + } + } + } catch { + // Best-effort + } + })(); + + sendResponse?.({ success: true }); + return true; + } + } + + // Handle UI events (cancel, confirm, etc.) + if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT) { + if (msg.event === 'cancel') { + void finish({ success: false, cancelled: true }); + sendResponse?.({ success: true }); + return true; + } + + if (msg.event === 'confirm') { + const missing = listMissingRequestIds(requests, pickedById); + if (missing.length > 0) { + uiErrorMessage = `Please select all elements: missing ${missing.join(', ')}`; + void sendUiUpdate(); + sendResponse?.({ success: false, error: 'missing_selections', missing }); + return true; + } + void finish({ success: true }); + sendResponse?.({ success: true }); + return true; + } + + if (msg.event === 'set_active_request') { + const requestId = toTrimmedString(msg.requestId); + if (!requestId || !requests.some((r) => r.id === requestId)) { + sendResponse?.({ success: false, error: 'Unknown requestId' }); + return true; + } + void setActiveRequest(requestId); + sendResponse?.({ success: true }); + return true; + } + + if (msg.event === 'clear_selection') { + const requestId = toTrimmedString(msg.requestId); + if (!requestId || !requests.some((r) => r.id === requestId)) { + sendResponse?.({ success: false, error: 'Unknown requestId' }); + return true; + } + pickedById.delete(requestId); + uiErrorMessage = null; + void setActiveRequest(requestId); + sendResponse?.({ success: true }); + return true; + } + } + + return; + }; + + try { + // Step 1: Ensure UI content script is ready (ping + inject fallback) + const ensureUiReady = async (): Promise => { + // Try to ping UI content script with retries + const pingWithTimeout = async (timeoutMs = 500): Promise => { + try { + const resp = await Promise.race([ + this.sendMessageToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING }, + 0, + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Ping timeout')), timeoutMs), + ), + ]); + return resp?.success === true; + } catch { + return false; + } + }; + + // First ping attempt (content script may already be loaded) + if (await pingWithTimeout()) return true; + + // Try to inject UI content script as fallback + // Try multiple possible paths (production vs dev builds) + const possiblePaths = ['content-scripts/element-picker.js', 'element-picker.js']; + + for (const path of possiblePaths) { + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [0] }, + files: [path], + injectImmediately: true, + } as any); + // Wait a bit for script to initialize + await new Promise((r) => setTimeout(r, 150)); + // Check if injection worked + if (await pingWithTimeout(300)) return true; + } catch (e) { + // Try next path + console.debug(`[ElementPicker] Path ${path} failed:`, e); + } + } + + // Final attempt with longer timeout (in case of slow page) + return pingWithTimeout(1000); + }; + + const uiReady = await ensureUiReady(); + if (!uiReady) { + console.error('[ElementPicker] UI not available after all attempts'); + return createErrorResponse( + `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Element Picker UI is not available. This may happen if: (1) The page blocks content scripts, (2) You're using dev mode - try restarting the dev server or use production build, (3) The page needs to be refreshed.`, + ); + } + + // Step 2: Show UI in top frame (must receive success:true) + try { + const showResp = await this.sendMessageToTab( + tabId, + { + action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW, + sessionId, + requests, + activeRequestId, + deadlineTs, + }, + 0, + ); + if (showResp?.success !== true) { + throw new Error('UI did not acknowledge show message'); + } + } catch (e) { + console.error('[ElementPicker] UI show failed:', e); + return createErrorResponse( + `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to show Element Picker UI. Please refresh the page and try again.`, + ); + } + + // Step 3: Inject picker scripts and start selection engine in all frames + await this.injectPickerScripts(tabId); + await this.callPickerApi(tabId, 'startSession', { sessionId, activeRequestId }); + + // Register message listener + chrome.runtime.onMessage.addListener(onRuntimeMessage); + + // Create result promise + const resultPromise = new Promise((resolve) => { + resolveResult = resolve; + }); + + // Set timeout + timer = setTimeout(() => { + void finish({ success: false, timedOut: true }); + }, timeoutMs); + + // Initial UI update + void sendUiUpdate(); + + // Wait for result + const result = await resultPromise; + return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false }; + } catch (error) { + console.error('Error in element picker tool:', error); + // Cleanup on error + try { + await Promise.allSettled([ + this.callPickerApi(tabId, 'stopSession', { sessionId }), + this.sendMessageToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId }, + 0, + ), + ]); + } catch { + // Best-effort cleanup + } + return createErrorResponse( + `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const elementPickerTool = new ElementPickerTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts b/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts new file mode 100644 index 0000000..25ed43d --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts @@ -0,0 +1,232 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; + +interface FileUploadToolParams { + selector: string; // CSS selector for the file input element + filePath?: string; // Local file path + fileUrl?: string; // URL to download file from + base64Data?: string; // Base64 encoded file data + fileName?: string; // Optional filename when using base64 or URL + multiple?: boolean; // Whether to allow multiple files + tabId?: number; // Target existing tab id + windowId?: number; // When no tabId, pick active tab from this window +} + +/** + * Tool for uploading files to web forms using Chrome DevTools Protocol + * Similar to Playwright's setInputFiles implementation + */ +class FileUploadTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.FILE_UPLOAD; + constructor() { + super(); + } + + /** + * Execute file upload operation using Chrome DevTools Protocol + */ + async execute(args: FileUploadToolParams): Promise { + const { selector, filePath, fileUrl, base64Data, fileName, multiple = false } = args; + + console.log(`Starting file upload operation with options:`, args); + + // Validate input + if (!selector) { + return createErrorResponse('Selector is required for file upload'); + } + + if (!filePath && !fileUrl && !base64Data) { + return createErrorResponse('One of filePath, fileUrl, or base64Data must be provided'); + } + + try { + // Resolve tab + const explicit = await this.tryGetTab(args.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId)); + if (!tab.id) return createErrorResponse('No active tab found'); + const tabId = tab.id; + + // Prepare file paths + let files: string[] = []; + + if (filePath) { + // Direct file path provided + files = [filePath]; + } else if (fileUrl || base64Data) { + // For URL or base64, we need to use the native messaging host + // to download or save the file temporarily + const tempFilePath = await this.prepareFileFromRemote({ + fileUrl, + base64Data, + fileName: fileName || 'uploaded-file', + }); + if (!tempFilePath) { + return createErrorResponse('Failed to prepare file for upload'); + } + files = [tempFilePath]; + } + + // Use shared CDP session manager to attach/do work/detach safely + await cdpSessionManager.withSession(tabId, 'file-upload', async () => { + // Enable necessary CDP domains + await cdpSessionManager.sendCommand(tabId, 'DOM.enable', {}); + await cdpSessionManager.sendCommand(tabId, 'Runtime.enable', {}); + + // Get the document + const { root } = (await cdpSessionManager.sendCommand(tabId, 'DOM.getDocument', { + depth: -1, + pierce: true, + })) as { root: { nodeId: number } }; + + // Find the file input element using the selector + const { nodeId } = (await cdpSessionManager.sendCommand(tabId, 'DOM.querySelector', { + nodeId: root.nodeId, + selector: selector, + })) as { nodeId: number }; + + if (!nodeId || nodeId === 0) { + throw new Error(`Element with selector "${selector}" not found`); + } + + // Verify it's actually a file input + const { node } = (await cdpSessionManager.sendCommand(tabId, 'DOM.describeNode', { + nodeId, + })) as { node: { nodeName: string; attributes?: string[] } }; + + if (node.nodeName !== 'INPUT') { + throw new Error(`Element with selector "${selector}" is not an input element`); + } + + // Check if it's a file input by looking for type="file" in attributes + const attributes = node.attributes || []; + let isFileInput = false; + for (let i = 0; i < attributes.length; i += 2) { + if (attributes[i] === 'type' && attributes[i + 1] === 'file') { + isFileInput = true; + break; + } + } + + if (!isFileInput) { + throw new Error(`Element with selector "${selector}" is not a file input (type="file")`); + } + + // Set the files on the input element + await cdpSessionManager.sendCommand(tabId, 'DOM.setFileInputFiles', { + nodeId, + files, + }); + + // Trigger change event to ensure the page reacts to the file upload + await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', { + expression: ` + (function() { + const element = document.querySelector('${selector.replace(/'/g, "\\'")}'); + if (element) { + const event = new Event('change', { bubbles: true }); + element.dispatchEvent(event); + return true; + } + return false; + })() + `, + }); + }); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'File(s) uploaded successfully', + files: files, + selector: selector, + fileCount: files.length, + }), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in file upload operation:', error); + + // Session manager handles detach; nothing extra needed here + + return createErrorResponse( + `Error uploading file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // All debugger attach/detach is centrally managed by cdpSessionManager + + /** + * Prepare file from URL or base64 data using native messaging host + */ + private async prepareFileFromRemote(options: { + fileUrl?: string; + base64Data?: string; + fileName: string; + }): Promise { + const { fileUrl, base64Data, fileName } = options; + + return new Promise((resolve) => { + const requestId = `file-upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const timeout = setTimeout(() => { + console.error('File preparation request timed out'); + resolve(null); + }, 30000); // 30 second timeout + + // Create listener for the response + const handleMessage = (message: any) => { + if ( + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timeout); + chrome.runtime.onMessage.removeListener(handleMessage); + + if (message.payload?.success && message.payload?.filePath) { + resolve(message.payload.filePath); + } else { + console.error( + 'Native host failed to prepare file:', + message.error || message.payload?.error, + ); + resolve(null); + } + } + }; + + // Add listener + chrome.runtime.onMessage.addListener(handleMessage); + + // Send message to background script to forward to native host + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId: requestId, + payload: { + action: 'prepareFile', + fileUrl, + base64Data, + fileName, + }, + }, + }) + .catch((error) => { + console.error('Error sending message to background:', error); + clearTimeout(timeout); + chrome.runtime.onMessage.removeListener(handleMessage); + resolve(null); + }); + }); + } +} + +export const fileUploadTool = new FileUploadTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/gif-auto-capture.ts b/app/chrome-extension/entrypoints/background/tools/browser/gif-auto-capture.ts new file mode 100644 index 0000000..0e3d8fb --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/gif-auto-capture.ts @@ -0,0 +1,520 @@ +/** + * GIF Auto-Capture Hook System + * + * Provides automatic frame capture for GIF recording when browser actions succeed. + * Tools like chrome_computer and chrome_navigate can trigger frame captures + * after successful operations, creating smooth recordings of user interactions. + * + * Architecture: + * - Centralized capture manager with per-tab recording state + * - Hooks can be registered/unregistered per tab + * - Configurable capture delay for UI stabilization + * - Enhanced rendering overlays (click indicators, drag paths, labels) + */ + +import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { OFFSCREEN_MESSAGE_TYPES, MessageTarget } from '@/common/message-types'; +import { offscreenManager } from '@/utils/offscreen-manager'; +import { createImageBitmapFromUrl } from '@/utils/image-utils'; +import { + pruneActionEventsInPlace, + renderGifEnhancedOverlays, + resolveCapturePlanForAction, + resolveGifEnhancedRenderingConfig, + type ActionEvent, + type ActionMetadata, + type ActionType, + type GifEnhancedRenderingConfig, + type ResolvedGifEnhancedRenderingConfig, +} from './gif-enhanced-renderer'; + +// Re-export types for consumers +export type { + ActionMetadata, + ActionType, + GifEnhancedRenderingConfig, +} from './gif-enhanced-renderer'; + +// ============================================================================ +// Constants +// ============================================================================ + +const CDP_SESSION_KEY = 'gif-auto-capture'; +const DEFAULT_CAPTURE_DELAY_MS = 150; +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 600; +const DEFAULT_FRAME_DELAY_CS = 20; // 20 centiseconds = 200ms per frame +const DEFAULT_MAX_COLORS = 256; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AutoCaptureConfig { + width: number; + height: number; + maxColors: number; + frameDelayCs: number; + captureDelayMs: number; + maxFrames: number; + enhancedRendering?: GifEnhancedRenderingConfig; +} + +interface TabCaptureState { + tabId: number; + config: AutoCaptureConfig; + rendering: ResolvedGifEnhancedRenderingConfig; + frameCount: number; + startTime: number; + canvas: OffscreenCanvas; + ctx: OffscreenCanvasRenderingContext2D; + pendingCapture: Promise | null; + actions: ActionMetadata[]; + actionEvents: ActionEvent[]; + lastViewportWidth: number; + lastViewportHeight: number; +} + +// ============================================================================ +// State Management +// ============================================================================ + +const tabStates = new Map(); + +// ============================================================================ +// Utilities +// ============================================================================ + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeActionMetadata(action: ActionMetadata, atMs: number): ActionMetadata { + const normalized: ActionMetadata = { + ...action, + timestampMs: atMs, + coordinateSpace: action.coordinateSpace ?? 'viewport', + }; + + // For drag, treat `coordinates` as end position (legacy) and also populate `endCoordinates` + if (normalized.type === 'drag') { + const end = normalized.endCoordinates ?? normalized.coordinates; + if (end) { + normalized.endCoordinates = end; + normalized.coordinates = end; + } + } + + return normalized; +} + +// ============================================================================ +// Offscreen Communication +// ============================================================================ + +async function sendToOffscreen( + type: string, + payload: Record = {}, +): Promise { + await offscreenManager.ensureOffscreenDocument(); + + const response = (await chrome.runtime.sendMessage({ + target: MessageTarget.Offscreen, + type, + ...payload, + })) as T | undefined; + + if (!response) { + throw new Error('No response from offscreen document'); + } + if (!response.success) { + throw new Error(response.error || 'Unknown offscreen error'); + } + + return response; +} + +// ============================================================================ +// Frame Capture +// ============================================================================ + +async function captureFrameData(tabId: number, state: TabCaptureState): Promise { + const width = state.config.width; + const height = state.config.height; + const ctx = state.ctx; + + // Get viewport metrics + const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } = + await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {}); + + const viewportWidth = metrics.layoutViewport?.clientWidth || width; + const viewportHeight = metrics.layoutViewport?.clientHeight || height; + + // Store viewport dimensions for coordinate projection + state.lastViewportWidth = viewportWidth; + state.lastViewportHeight = viewportHeight; + + // Capture screenshot + const screenshot: { data: string } = await cdpSessionManager.sendCommand( + tabId, + 'Page.captureScreenshot', + { + format: 'png', + clip: { + x: 0, + y: 0, + width: viewportWidth, + height: viewportHeight, + scale: 1, + }, + }, + ); + + const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`); + + // Scale to target dimensions + ctx.clearRect(0, 0, width, height); + ctx.drawImage(imageBitmap, 0, 0, width, height); + imageBitmap.close(); + + // Apply enhanced rendering overlays + if (state.rendering.enabled) { + const nowMs = Date.now(); + renderGifEnhancedOverlays({ + ctx, + outputWidth: width, + outputHeight: height, + viewportWidth, + viewportHeight, + nowMs, + events: state.actionEvents, + config: state.rendering, + }); + pruneActionEventsInPlace(state.actionEvents, nowMs, state.rendering); + } + + return ctx.getImageData(0, 0, width, height).data; +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Start auto-capture for a tab. This initializes the GIF encoder + * and prepares for automatic frame capture on tool actions. + */ +export async function startAutoCapture( + tabId: number, + config?: Partial, +): Promise<{ success: boolean; error?: string }> { + if (tabStates.has(tabId)) { + return { success: false, error: 'Auto-capture already active for this tab' }; + } + + const finalConfig: AutoCaptureConfig = { + width: config?.width ?? DEFAULT_WIDTH, + height: config?.height ?? DEFAULT_HEIGHT, + maxColors: config?.maxColors ?? DEFAULT_MAX_COLORS, + frameDelayCs: config?.frameDelayCs ?? DEFAULT_FRAME_DELAY_CS, + captureDelayMs: config?.captureDelayMs ?? DEFAULT_CAPTURE_DELAY_MS, + maxFrames: config?.maxFrames ?? 100, + enhancedRendering: config?.enhancedRendering, + }; + + try { + // Attach CDP session + await cdpSessionManager.attach(tabId, CDP_SESSION_KEY); + + // Reset offscreen encoder + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {}); + + // Create canvas + if (typeof OffscreenCanvas === 'undefined') { + throw new Error('OffscreenCanvas not available'); + } + + const canvas = new OffscreenCanvas(finalConfig.width, finalConfig.height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + const state: TabCaptureState = { + tabId, + config: finalConfig, + rendering: resolveGifEnhancedRenderingConfig(finalConfig.enhancedRendering), + frameCount: 0, + startTime: Date.now(), + canvas, + ctx, + pendingCapture: null, + actions: [], + actionEvents: [], + lastViewportWidth: finalConfig.width, + lastViewportHeight: finalConfig.height, + }; + + tabStates.set(tabId, state); + + return { success: true }; + } catch (error) { + // Cleanup on failure + try { + await cdpSessionManager.detach(tabId, CDP_SESSION_KEY); + } catch { + // Ignore + } + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Stop auto-capture and finalize the GIF. + * Returns the GIF data for saving/downloading. + */ +export async function stopAutoCapture(tabId: number): Promise<{ + success: boolean; + gifData?: Uint8Array; + frameCount?: number; + durationMs?: number; + actions?: ActionMetadata[]; + error?: string; +}> { + const state = tabStates.get(tabId); + if (!state) { + return { success: false, error: 'No auto-capture active for this tab' }; + } + + try { + // Wait for any pending capture + if (state.pendingCapture) { + await state.pendingCapture; + } + + const frameCount = state.frameCount; + const durationMs = Date.now() - state.startTime; + const actions = [...state.actions]; + + if (frameCount === 0) { + return { + success: false, + error: 'No frames captured', + frameCount: 0, + durationMs, + actions, + }; + } + + // Finalize GIF + const response = await sendToOffscreen<{ + success: boolean; + gifData?: number[]; + byteLength?: number; + error?: string; + }>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {}); + + if (!response.gifData || response.gifData.length === 0) { + return { + success: false, + error: 'Failed to encode GIF', + frameCount, + durationMs, + actions, + }; + } + + return { + success: true, + gifData: new Uint8Array(response.gifData), + frameCount, + durationMs, + actions, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + // Cleanup + tabStates.delete(tabId); + try { + await cdpSessionManager.detach(tabId, CDP_SESSION_KEY); + } catch { + // Ignore + } + } +} + +/** + * Check if auto-capture is active for a tab. + */ +export function isAutoCaptureActive(tabId: number): boolean { + return tabStates.has(tabId); +} + +/** + * Get current auto-capture status for a tab. + */ +export function getAutoCaptureStatus(tabId: number): { + active: boolean; + frameCount?: number; + durationMs?: number; + actionsCount?: number; + enhancedRenderingEnabled?: boolean; +} { + const state = tabStates.get(tabId); + if (!state) { + return { active: false }; + } + + return { + active: true, + frameCount: state.frameCount, + durationMs: Date.now() - state.startTime, + actionsCount: state.actions.length, + enhancedRenderingEnabled: state.rendering.enabled, + }; +} + +/** + * Trigger a frame capture after a successful action. + * This is the main hook that tools should call. + * + * @param tabId - The tab to capture + * @param action - Optional action metadata for overlay rendering + * @param immediate - If true, capture immediately without delay + */ +export async function captureFrameOnAction( + tabId: number, + action?: ActionMetadata, + immediate = false, +): Promise<{ success: boolean; frameNumber?: number; error?: string }> { + const state = tabStates.get(tabId); + if (!state) { + // No auto-capture active - silently succeed (tools shouldn't fail because recording isn't active) + return { success: true }; + } + + // Check frame limit + if (state.frameCount >= state.config.maxFrames) { + return { success: false, error: 'Max frame limit reached' }; + } + + // Wait for any pending capture to complete + if (state.pendingCapture) { + try { + await state.pendingCapture; + } catch { + // Ignore errors from previous capture + } + } + + // Verify state still exists (might have been stopped while awaiting) + const currentState = tabStates.get(tabId); + if (!currentState) { + return { success: true }; + } + + // Calculate delay for UI stabilization + const delayMs = immediate ? 0 : currentState.config.captureDelayMs; + + // Normalize and record action metadata + let normalizedAction: ActionMetadata | undefined; + if (action) { + const atMs = Date.now() + delayMs; + normalizedAction = normalizeActionMetadata(action, atMs); + currentState.actions.push(normalizedAction); + currentState.actionEvents.push({ action: normalizedAction, atMs }); + } + + // Determine capture plan (may involve multiple frames for click animations) + const plan = resolveCapturePlanForAction( + currentState.rendering, + normalizedAction, + currentState.config.frameDelayCs, + ); + + const capturePromise = (async () => { + if (delayMs > 0) await sleep(delayMs); + + for (let i = 0; i < plan.frames; i++) { + const activeState = tabStates.get(tabId); + if (!activeState) return; + + if (activeState.frameCount >= activeState.config.maxFrames) return; + + try { + const frameData = await captureFrameData(tabId, activeState); + + // Use animation delay for intermediate frames, regular delay for final frame + const delayCs = i < plan.frames - 1 ? plan.delayCs : activeState.config.frameDelayCs; + + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, { + imageData: Array.from(frameData), + width: activeState.config.width, + height: activeState.config.height, + delay: delayCs, + maxColors: activeState.config.maxColors, + }); + + activeState.frameCount += 1; + } catch (error) { + console.error('[GIF Auto-Capture] Frame capture failed:', error); + return; + } + + // Wait between animation frames + if (i < plan.frames - 1 && plan.intervalMs > 0) { + await sleep(plan.intervalMs); + } + } + })(); + + state.pendingCapture = capturePromise; + + try { + await capturePromise; + return { success: true, frameNumber: state.frameCount }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + // Clean up reference to avoid holding completed Promise + const currentState = tabStates.get(tabId); + if (currentState?.pendingCapture === capturePromise) { + currentState.pendingCapture = null; + } + } +} + +/** + * Capture an initial frame immediately (useful for recording start state). + */ +export async function captureInitialFrame( + tabId: number, +): Promise<{ success: boolean; error?: string }> { + return captureFrameOnAction(tabId, undefined, true); +} + +/** + * Clear all auto-capture state (useful for cleanup). + */ +export async function clearAllAutoCapture(): Promise { + const tabIds = Array.from(tabStates.keys()); + for (const tabId of tabIds) { + try { + await stopAutoCapture(tabId); + } catch { + // Ignore errors during cleanup + tabStates.delete(tabId); + } + } +} diff --git a/app/chrome-extension/entrypoints/background/tools/browser/gif-enhanced-renderer.ts b/app/chrome-extension/entrypoints/background/tools/browser/gif-enhanced-renderer.ts new file mode 100644 index 0000000..04cbd91 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/gif-enhanced-renderer.ts @@ -0,0 +1,834 @@ +/** + * GIF Enhanced Renderer + * + * Draws visual affordances (click indicators, drag paths, labels) onto a canvas + * before encoding frames. This keeps the offscreen document focused on encoding + * while the background capture pipeline handles compositing. + * + * Coordinates are expected to be in viewport CSS pixels. If a caller provides + * screenshot-space coordinates, it should convert them to viewport space first. + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type ActionType = + | 'click' + | 'double_click' + | 'triple_click' + | 'right_click' + | 'drag' + | 'scroll' + | 'type' + | 'key' + | 'navigate' + | 'hover' + | 'fill' + | 'annotation' + | 'other'; + +export type CoordinateSpace = 'viewport' | 'screenshot'; + +export interface Point { + x: number; + y: number; +} + +export interface ActionMetadata { + type: ActionType; + coordinates?: Point; + startCoordinates?: Point; + endCoordinates?: Point; + text?: string; + url?: string; + ref?: string; + + // Enhanced rendering hints + label?: string; + coordinateSpace?: CoordinateSpace; + timestampMs?: number; +} + +export interface GifEnhancedRenderingConfig { + enabled?: boolean; + + clickIndicators?: { + enabled?: boolean; + color?: string; + fillColor?: string; + radiusPx?: number; + lineWidthPx?: number; + durationMs?: number; + // Capture-side animation hints (auto-capture mode only) + animationFrames?: number; + animationIntervalMs?: number; + animationFrameDelayCs?: number; + }; + + dragPaths?: { + enabled?: boolean; + color?: string; + lineWidthPx?: number; + durationMs?: number; + arrowSizePx?: number; + dash?: number[]; + startDotRadiusPx?: number; + endDotRadiusPx?: number; + }; + + labels?: { + enabled?: boolean; + mode?: 'action' | 'annotation' | 'both'; + showForClicks?: boolean; + font?: string; + maxLength?: number; + durationMs?: number; + backgroundColor?: string; + borderColor?: string; + textColor?: string; + paddingX?: number; + paddingY?: number; + radiusPx?: number; + offsetPx?: number; + }; +} + +// ============================================================================ +// Resolved Config Types +// ============================================================================ + +export interface ResolvedClickIndicatorConfig { + enabled: boolean; + color: string; + fillColor: string; + radiusPx: number; + lineWidthPx: number; + durationMs: number; + animationFrames: number; + animationIntervalMs: number; + animationFrameDelayCs: number; +} + +export interface ResolvedDragPathConfig { + enabled: boolean; + color: string; + lineWidthPx: number; + durationMs: number; + arrowSizePx: number; + dash: number[]; + startDotRadiusPx: number; + endDotRadiusPx: number; +} + +export interface ResolvedLabelsConfig { + enabled: boolean; + mode: 'action' | 'annotation' | 'both'; + showForClicks: boolean; + font: string; + maxLength: number; + durationMs: number; + backgroundColor: string; + borderColor: string; + textColor: string; + paddingX: number; + paddingY: number; + radiusPx: number; + offsetPx: number; +} + +export interface ResolvedGifEnhancedRenderingConfig { + enabled: boolean; + clickIndicators: ResolvedClickIndicatorConfig; + dragPaths: ResolvedDragPathConfig; + labels: ResolvedLabelsConfig; +} + +export interface ActionEvent { + action: ActionMetadata; + atMs: number; +} + +export interface CapturePlan { + frames: number; + intervalMs: number; + delayCs: number; +} + +export interface RenderGifEnhancedOverlaysParams { + ctx: OffscreenCanvasRenderingContext2D; + outputWidth: number; + outputHeight: number; + viewportWidth: number; + viewportHeight: number; + nowMs: number; + events: readonly ActionEvent[]; + config: ResolvedGifEnhancedRenderingConfig; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const CLICK_ACTIONS: readonly ActionType[] = [ + 'click', + 'double_click', + 'triple_click', + 'right_click', +]; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function normalizePositiveNumber( + value: unknown, + fallback: number, + min: number, + max: number, +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + return clamp(value, min, max); +} + +function normalizePositiveInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + return clamp(Math.floor(value), min, max); +} + +function normalizeDash(value: unknown, fallback: number[]): number[] { + if (!Array.isArray(value)) return fallback; + const nums = value.filter((n) => typeof n === 'number' && Number.isFinite(n) && n > 0); + return nums.length >= 2 ? (nums as number[]) : fallback; +} + +function easeOutCubic(t: number): number { + const x = clamp(t, 0, 1); + return 1 - Math.pow(1 - x, 3); +} + +function projectPoint( + point: Point, + viewportWidth: number, + viewportHeight: number, + outputWidth: number, + outputHeight: number, +): Point | null { + if ( + typeof point.x !== 'number' || + typeof point.y !== 'number' || + !Number.isFinite(point.x) || + !Number.isFinite(point.y) + ) { + return null; + } + + const vw = viewportWidth > 0 ? viewportWidth : outputWidth; + const vh = viewportHeight > 0 ? viewportHeight : outputHeight; + + return { + x: (point.x / vw) * outputWidth, + y: (point.y / vh) * outputHeight, + }; +} + +function buildRoundedRectPath( + ctx: OffscreenCanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +): void { + const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2)); + const x2 = x + width; + const y2 = y + height; + + ctx.moveTo(x + r, y); + ctx.arcTo(x2, y, x2, y2, r); + ctx.arcTo(x2, y2, x, y2, r); + ctx.arcTo(x, y2, x, y, r); + ctx.arcTo(x, y, x2, y, r); +} + +function truncate(text: string, maxLength: number): string { + const trimmed = text.trim(); + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, Math.max(0, maxLength - 1))}…`; +} + +// ============================================================================ +// Label Resolution +// ============================================================================ + +function resolveActionLabel(action: ActionMetadata, cfg: ResolvedLabelsConfig): string | null { + const explicit = typeof action.label === 'string' ? action.label.trim() : ''; + const isExplicit = explicit.length > 0; + + const mode = cfg.mode; + const canShowAction = mode === 'action' || mode === 'both'; + const canShowAnnotation = mode === 'annotation' || mode === 'both'; + + if ((action.type === 'annotation' || isExplicit) && canShowAnnotation) { + const labelText = explicit || (typeof action.text === 'string' ? action.text.trim() : ''); + return labelText.length > 0 ? truncate(labelText, cfg.maxLength) : null; + } + + if (!canShowAction) return null; + + switch (action.type) { + case 'click': + case 'double_click': + case 'triple_click': + case 'right_click': + if (!cfg.showForClicks) return null; + return action.type.replace('_', ' ').toUpperCase(); + case 'drag': + return 'DRAG'; + case 'scroll': + return 'SCROLL'; + case 'hover': + return 'HOVER'; + case 'navigate': { + if (!action.url) return 'NAVIGATE'; + try { + const host = new URL(action.url).hostname; + return host ? `→ ${host}` : 'NAVIGATE'; + } catch { + return 'NAVIGATE'; + } + } + case 'type': { + const content = typeof action.text === 'string' ? action.text : ''; + return content.trim().length > 0 ? `TYPE "${truncate(content, cfg.maxLength)}"` : 'TYPE'; + } + case 'key': { + const content = typeof action.text === 'string' ? action.text : ''; + return content.trim().length > 0 ? `KEY [${truncate(content, cfg.maxLength)}]` : 'KEY'; + } + case 'fill': { + const content = typeof action.text === 'string' ? action.text : ''; + return content.trim().length > 0 ? `FILL "${truncate(content, cfg.maxLength)}"` : 'FILL'; + } + default: + return null; + } +} + +function resolveAnchorPoint(action: ActionMetadata): Point | null { + if (action.type === 'drag') { + return action.endCoordinates || action.coordinates || action.startCoordinates || null; + } + return action.coordinates || action.endCoordinates || action.startCoordinates || null; +} + +// ============================================================================ +// Drawing Functions +// ============================================================================ + +function drawClickIndicator( + ctx: OffscreenCanvasRenderingContext2D, + x: number, + y: number, + progress: number, + type: ActionType, + cfg: ResolvedClickIndicatorConfig, +): void { + const t = clamp(progress, 0, 1); + const eased = easeOutCubic(t); + + const base = cfg.radiusPx; + const radius = base * (0.35 + 0.95 * eased); + const alpha = 1 - eased; + + ctx.save(); + ctx.globalAlpha = alpha; + + ctx.lineWidth = cfg.lineWidthPx; + ctx.strokeStyle = cfg.color; + ctx.fillStyle = cfg.fillColor; + + ctx.shadowColor = 'rgba(0, 0, 0, 0.25)'; + ctx.shadowBlur = 8; + + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.stroke(); + + ctx.shadowBlur = 0; + + if (type === 'double_click' || type === 'triple_click') { + ctx.globalAlpha = 1; + ctx.fillStyle = cfg.color; + ctx.font = `700 ${Math.max(10, Math.round(base * 0.6))}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(type === 'double_click' ? '2×' : '3×', x, y); + } else { + ctx.beginPath(); + ctx.arc(x, y, Math.max(2, base * 0.16), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +function drawArrowHead( + ctx: OffscreenCanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, + size: number, +): void { + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy); + if (!Number.isFinite(len) || len < 1) return; + + const ux = dx / len; + const uy = dy / len; + const px = -uy; + const py = ux; + + const headLen = size; + const headWidth = size * 0.65; + + const backX = x2 - ux * headLen; + const backY = y2 - uy * headLen; + + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(backX + px * headWidth, backY + py * headWidth); + ctx.lineTo(backX - px * headWidth, backY - py * headWidth); + ctx.closePath(); + ctx.fill(); +} + +function drawDragPath( + ctx: OffscreenCanvasRenderingContext2D, + start: Point, + end: Point, + progress: number, + cfg: ResolvedDragPathConfig, +): void { + const t = clamp(progress, 0, 1); + const alpha = 1 - easeOutCubic(t); + + ctx.save(); + ctx.globalAlpha = alpha; + + ctx.strokeStyle = cfg.color; + ctx.fillStyle = cfg.color; + ctx.lineWidth = cfg.lineWidthPx; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.setLineDash(cfg.dash); + + ctx.shadowColor = 'rgba(0, 0, 0, 0.2)'; + ctx.shadowBlur = 6; + + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + + ctx.setLineDash([]); + ctx.shadowBlur = 0; + + ctx.beginPath(); + ctx.arc(start.x, start.y, cfg.startDotRadiusPx, 0, Math.PI * 2); + ctx.fill(); + + ctx.beginPath(); + ctx.arc(end.x, end.y, cfg.endDotRadiusPx, 0, Math.PI * 2); + ctx.fill(); + + drawArrowHead(ctx, start.x, start.y, end.x, end.y, cfg.arrowSizePx); + + ctx.restore(); +} + +function drawLabelPill( + ctx: OffscreenCanvasRenderingContext2D, + text: string, + anchor: Point | null, + alpha: number, + cfg: ResolvedLabelsConfig, + outputWidth: number, + outputHeight: number, +): void { + ctx.save(); + ctx.globalAlpha = clamp(alpha, 0, 1); + + ctx.font = cfg.font; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + const metrics = ctx.measureText(text); + const ascent = Number.isFinite(metrics.actualBoundingBoxAscent) + ? metrics.actualBoundingBoxAscent + : 10; + const descent = Number.isFinite(metrics.actualBoundingBoxDescent) + ? metrics.actualBoundingBoxDescent + : 4; + const textHeight = ascent + descent; + const pillWidth = Math.ceil(metrics.width + cfg.paddingX * 2); + const pillHeight = Math.ceil(textHeight + cfg.paddingY * 2); + + const margin = 4; + const ax = anchor?.x ?? margin; + const ay = anchor?.y ?? margin; + + let x = ax + cfg.offsetPx; + let y = ay - pillHeight / 2; + + if (x + pillWidth > outputWidth - margin) x = ax - cfg.offsetPx - pillWidth; + if (y < margin) y = ay + cfg.offsetPx; + if (y + pillHeight > outputHeight - margin) y = outputHeight - margin - pillHeight; + + x = clamp(x, margin, Math.max(margin, outputWidth - margin - pillWidth)); + y = clamp(y, margin, Math.max(margin, outputHeight - margin - pillHeight)); + + ctx.fillStyle = cfg.backgroundColor; + ctx.strokeStyle = cfg.borderColor; + ctx.lineWidth = 1; + + ctx.beginPath(); + buildRoundedRectPath(ctx, x, y, pillWidth, pillHeight, cfg.radiusPx); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = cfg.textColor; + ctx.fillText(text, x + cfg.paddingX, y + pillHeight / 2); + + ctx.restore(); +} + +// ============================================================================ +// Schema Input Normalization +// ============================================================================ + +/** + * External schema input type that supports both shorthand (boolean) and full config. + * This maps to what users pass via the MCP tool schema. + */ +interface SchemaEnhancedRenderingInput { + // Global toggle (Schema allows `true` to enable all defaults) + enabled?: boolean; + + // Sub-configs can be boolean (enable/disable) or object (custom config) + clickIndicators?: + | boolean + | { + enabled?: boolean; + // Schema aliases (from tools.ts) + color?: string; + radius?: number; // alias for radiusPx + animationDurationMs?: number; // alias for durationMs + animationFrames?: number; + animationIntervalMs?: number; + }; + + dragPaths?: + | boolean + | { + enabled?: boolean; + color?: string; + lineWidth?: number; // alias for lineWidthPx + lineDash?: number[]; // alias for dash + arrowSize?: number; // alias for arrowSizePx + }; + + labels?: + | boolean + | { + enabled?: boolean; + font?: string; + textColor?: string; + bgColor?: string; // alias for backgroundColor + padding?: number; // alias for paddingX/paddingY + borderRadius?: number; // alias for radiusPx + offset?: { x?: number; y?: number } | number; // alias for offsetPx + }; + + durationMs?: number; // global fallback duration for all overlays +} + +function normalizeSchemaInput(raw: unknown): GifEnhancedRenderingConfig | undefined { + // Handle `true` shorthand - enable with all defaults + if (raw === true) { + return { enabled: true }; + } + + // Handle `false` or falsy + if (!raw || typeof raw !== 'object') { + return undefined; + } + + const input = raw as SchemaEnhancedRenderingInput; + const result: GifEnhancedRenderingConfig = {}; + + // Global enabled + result.enabled = input.enabled ?? true; // If object passed, default to enabled + + // Global duration fallback + const globalDuration = typeof input.durationMs === 'number' ? input.durationMs : undefined; + + // Normalize clickIndicators + if (input.clickIndicators === false) { + result.clickIndicators = { enabled: false }; + } else if (input.clickIndicators === true) { + result.clickIndicators = { enabled: true }; + } else if (typeof input.clickIndicators === 'object') { + const ci = input.clickIndicators; + result.clickIndicators = { + enabled: ci.enabled ?? true, + color: ci.color, + radiusPx: ci.radius, + durationMs: ci.animationDurationMs ?? globalDuration, + animationFrames: ci.animationFrames, + animationIntervalMs: ci.animationIntervalMs, + }; + } + + // Normalize dragPaths + if (input.dragPaths === false) { + result.dragPaths = { enabled: false }; + } else if (input.dragPaths === true) { + result.dragPaths = { enabled: true }; + } else if (typeof input.dragPaths === 'object') { + const dp = input.dragPaths; + result.dragPaths = { + enabled: dp.enabled ?? true, + color: dp.color, + lineWidthPx: dp.lineWidth, + dash: dp.lineDash, + arrowSizePx: dp.arrowSize, + durationMs: globalDuration, + }; + } + + // Normalize labels + if (input.labels === false) { + result.labels = { enabled: false }; + } else if (input.labels === true) { + result.labels = { enabled: true }; + } else if (typeof input.labels === 'object') { + const lb = input.labels; + const offset = lb.offset; + const offsetPx = + typeof offset === 'number' ? offset : typeof offset === 'object' ? offset.x : undefined; + result.labels = { + enabled: lb.enabled ?? true, + font: lb.font, + textColor: lb.textColor, + backgroundColor: lb.bgColor, + paddingX: typeof lb.padding === 'number' ? lb.padding : undefined, + paddingY: typeof lb.padding === 'number' ? lb.padding : undefined, + radiusPx: lb.borderRadius, + offsetPx, + durationMs: globalDuration, + }; + } + + return result; +} + +// ============================================================================ +// Config Resolution +// ============================================================================ + +export function resolveGifEnhancedRenderingConfig( + input?: GifEnhancedRenderingConfig | unknown, +): ResolvedGifEnhancedRenderingConfig { + // Normalize schema input (handles `true`, boolean sub-configs, field aliases) + const normalized = normalizeSchemaInput(input) ?? (input as GifEnhancedRenderingConfig); + const enabled = normalized?.enabled ?? false; + + const clickIntervalMs = normalizePositiveInt( + normalized?.clickIndicators?.animationIntervalMs, + 80, + 20, + 500, + ); + const clickDelayCsFallback = Math.max(1, Math.round(clickIntervalMs / 10)); + + return { + enabled, + clickIndicators: { + enabled: normalized?.clickIndicators?.enabled ?? true, + color: normalized?.clickIndicators?.color ?? '#FF6A00', + fillColor: normalized?.clickIndicators?.fillColor ?? 'rgba(255, 106, 0, 0.18)', + radiusPx: normalizePositiveNumber(normalized?.clickIndicators?.radiusPx, 18, 4, 96), + lineWidthPx: normalizePositiveNumber(normalized?.clickIndicators?.lineWidthPx, 3, 1, 16), + durationMs: normalizePositiveInt(normalized?.clickIndicators?.durationMs, 520, 120, 5000), + animationFrames: normalizePositiveInt(normalized?.clickIndicators?.animationFrames, 3, 1, 8), + animationIntervalMs: clickIntervalMs, + animationFrameDelayCs: normalizePositiveInt( + normalized?.clickIndicators?.animationFrameDelayCs, + clickDelayCsFallback, + 1, + 100, + ), + }, + dragPaths: { + enabled: normalized?.dragPaths?.enabled ?? true, + color: normalized?.dragPaths?.color ?? '#FF2D55', + lineWidthPx: normalizePositiveNumber(normalized?.dragPaths?.lineWidthPx, 4, 1, 20), + durationMs: normalizePositiveInt(normalized?.dragPaths?.durationMs, 1000, 120, 8000), + arrowSizePx: normalizePositiveNumber(normalized?.dragPaths?.arrowSizePx, 10, 4, 40), + dash: normalizeDash(normalized?.dragPaths?.dash, [10, 8]), + startDotRadiusPx: normalizePositiveNumber(normalized?.dragPaths?.startDotRadiusPx, 4, 2, 24), + endDotRadiusPx: normalizePositiveNumber(normalized?.dragPaths?.endDotRadiusPx, 5, 2, 24), + }, + labels: { + enabled: normalized?.labels?.enabled ?? false, + mode: normalized?.labels?.mode ?? 'both', + showForClicks: normalized?.labels?.showForClicks ?? false, + font: + normalized?.labels?.font ?? + '600 13px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif', + maxLength: normalizePositiveInt(normalized?.labels?.maxLength, 48, 8, 200), + durationMs: normalizePositiveInt(normalized?.labels?.durationMs, 1200, 120, 12000), + backgroundColor: normalized?.labels?.backgroundColor ?? 'rgba(0, 0, 0, 0.72)', + borderColor: normalized?.labels?.borderColor ?? 'rgba(255, 255, 255, 0.14)', + textColor: normalized?.labels?.textColor ?? '#FFFFFF', + paddingX: normalizePositiveNumber(normalized?.labels?.paddingX, 10, 2, 40), + paddingY: normalizePositiveNumber(normalized?.labels?.paddingY, 6, 2, 30), + radiusPx: normalizePositiveNumber(normalized?.labels?.radiusPx, 10, 0, 30), + offsetPx: normalizePositiveNumber(normalized?.labels?.offsetPx, 12, 0, 80), + }, + }; +} + +// ============================================================================ +// Capture Plan +// ============================================================================ + +export function resolveCapturePlanForAction( + config: ResolvedGifEnhancedRenderingConfig, + action: ActionMetadata | undefined, + defaultFrameDelayCs: number, +): CapturePlan { + const base: CapturePlan = { frames: 1, intervalMs: 0, delayCs: defaultFrameDelayCs }; + if (!config.enabled || !action) return base; + + if (config.clickIndicators.enabled && CLICK_ACTIONS.includes(action.type)) { + const frames = config.clickIndicators.animationFrames; + if (frames > 1) { + return { + frames, + intervalMs: config.clickIndicators.animationIntervalMs, + delayCs: config.clickIndicators.animationFrameDelayCs, + }; + } + } + + return base; +} + +// ============================================================================ +// Main Render Function +// ============================================================================ + +export function renderGifEnhancedOverlays(params: RenderGifEnhancedOverlaysParams): void { + const { ctx, outputWidth, outputHeight, viewportWidth, viewportHeight, nowMs, events, config } = + params; + + if (!config.enabled || events.length === 0) return; + + const clickCfg = config.clickIndicators; + const dragCfg = config.dragPaths; + const labelCfg = config.labels; + + for (const event of events) { + const ageMs = nowMs - event.atMs; + if (!Number.isFinite(ageMs) || ageMs < 0) continue; + + const action = event.action; + + if (clickCfg.enabled && CLICK_ACTIONS.includes(action.type)) { + const anchor = resolveAnchorPoint(action); + if (anchor) { + const p = projectPoint(anchor, viewportWidth, viewportHeight, outputWidth, outputHeight); + if (p) + drawClickIndicator(ctx, p.x, p.y, ageMs / clickCfg.durationMs, action.type, clickCfg); + } + } + + if (dragCfg.enabled && action.type === 'drag') { + const start = action.startCoordinates || null; + const end = action.endCoordinates || action.coordinates || null; + if (start && end) { + const p1 = projectPoint(start, viewportWidth, viewportHeight, outputWidth, outputHeight); + const p2 = projectPoint(end, viewportWidth, viewportHeight, outputWidth, outputHeight); + if (p1 && p2) drawDragPath(ctx, p1, p2, ageMs / dragCfg.durationMs, dragCfg); + } + } + + // Render labels: always show annotation actions, respect labelCfg.enabled for other actions + const isAnnotation = action.type === 'annotation' || typeof action.label === 'string'; + const shouldRenderLabel = labelCfg.enabled || isAnnotation; + + if (shouldRenderLabel) { + const text = resolveActionLabel(action, labelCfg); + if (text) { + const anchor = resolveAnchorPoint(action); + const p = anchor + ? projectPoint(anchor, viewportWidth, viewportHeight, outputWidth, outputHeight) + : null; + + const t = clamp(ageMs / labelCfg.durationMs, 0, 1); + const alpha = 1 - clamp((t - 0.75) / 0.25, 0, 1); + + drawLabelPill(ctx, text, p, alpha, labelCfg, outputWidth, outputHeight); + } + } + } +} + +// ============================================================================ +// Event Pruning +// ============================================================================ + +export function pruneActionEventsInPlace( + events: ActionEvent[], + nowMs: number, + config: ResolvedGifEnhancedRenderingConfig, +): void { + if (events.length === 0) return; + + // Check if any events have annotations (which are always rendered) + const hasAnnotations = events.some( + (e) => e.action.type === 'annotation' || typeof e.action.label === 'string', + ); + + let maxLifetimeMs = 0; + if (config.enabled) { + if (config.clickIndicators.enabled) + maxLifetimeMs = Math.max(maxLifetimeMs, config.clickIndicators.durationMs); + if (config.dragPaths.enabled) + maxLifetimeMs = Math.max(maxLifetimeMs, config.dragPaths.durationMs); + if (config.labels.enabled) maxLifetimeMs = Math.max(maxLifetimeMs, config.labels.durationMs); + } + + // Always account for label duration if there are annotations (they're always rendered) + if (hasAnnotations) { + maxLifetimeMs = Math.max(maxLifetimeMs, config.labels.durationMs); + } + + if (maxLifetimeMs <= 0) { + events.length = 0; + return; + } + + const cutoff = nowMs - maxLifetimeMs - 250; + let dropCount = 0; + while (dropCount < events.length && events[dropCount].atMs < cutoff) dropCount++; + if (dropCount > 0) events.splice(0, dropCount); +} diff --git a/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts b/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts new file mode 100644 index 0000000..594b4ca --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts @@ -0,0 +1,1241 @@ +/** + * GIF Recorder Tool + * + * Records browser tab activity as an animated GIF. + * + * Features: + * - Two recording modes: + * 1. Fixed FPS mode (start): Captures frames at regular intervals + * 2. Auto-capture mode (auto_start): Captures frames on tool actions + * - Configurable frame rate, duration, and dimensions + * - Quality/size optimization options + * - CDP-based screenshot capture for background recording + * - Offscreen document encoding via gifenc + */ + +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { + MessageTarget, + OFFSCREEN_MESSAGE_TYPES, + OffscreenMessageType, +} from '@/common/message-types'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { offscreenManager } from '@/utils/offscreen-manager'; +import { createImageBitmapFromUrl } from '@/utils/image-utils'; +import { + startAutoCapture, + stopAutoCapture, + isAutoCaptureActive, + getAutoCaptureStatus, + captureFrameOnAction, + captureInitialFrame, + type ActionMetadata, + type GifEnhancedRenderingConfig, +} from './gif-auto-capture'; + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_FPS = 5; +const DEFAULT_DURATION_MS = 5000; +const DEFAULT_MAX_FRAMES = 50; +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 600; +const DEFAULT_MAX_COLORS = 256; +const CDP_SESSION_KEY = 'gif-recorder'; + +// ============================================================================ +// Types +// ============================================================================ + +type GifRecorderAction = + | 'start' + | 'stop' + | 'status' + | 'auto_start' + | 'capture' + | 'clear' + | 'export'; + +interface GifRecorderParams { + action: GifRecorderAction; + tabId?: number; + fps?: number; + durationMs?: number; + maxFrames?: number; + width?: number; + height?: number; + maxColors?: number; + filename?: string; + // Auto-capture mode specific + captureDelayMs?: number; + frameDelayCs?: number; + enhancedRendering?: GifEnhancedRenderingConfig; + // Manual annotation for action="capture" + annotation?: string; + // Export action specific + download?: boolean; // true to download, false to upload via drag&drop + coordinates?: { x: number; y: number }; // target position for drag&drop upload + ref?: string; // element ref for drag&drop upload (alternative to coordinates) + selector?: string; // CSS selector for drag&drop upload (alternative to coordinates) +} + +interface RecordingState { + isRecording: boolean; + isStopping: boolean; + tabId: number; + width: number; + height: number; + fps: number; + durationMs: number; + frameIntervalMs: number; + frameDelayCs: number; + maxFrames: number; + maxColors: number; + frameCount: number; + startTime: number; + captureTimer: ReturnType | null; + captureInProgress: Promise | null; + canvas: OffscreenCanvas; + ctx: OffscreenCanvasRenderingContext2D; + filename?: string; +} + +interface GifResult { + success: boolean; + action: GifRecorderAction; + tabId?: number; + frameCount?: number; + durationMs?: number; + byteLength?: number; + downloadId?: number; + filename?: string; + fullPath?: string; + isRecording?: boolean; + mode?: 'fixed_fps' | 'auto_capture'; + actionsCount?: number; + error?: string; + // Clear action specific + clearedAutoCapture?: boolean; + clearedFixedFps?: boolean; + clearedCache?: boolean; + // Export action specific (drag&drop upload) + uploadTarget?: { + x: number; + y: number; + tagName?: string; + id?: string; + }; +} + +// ============================================================================ +// Recording State Management +// ============================================================================ + +let recordingState: RecordingState | null = null; +let stopPromise: Promise | null = null; + +// Auto-capture mode state +interface AutoCaptureMetadata { + tabId: number; + filename?: string; +} +let autoCaptureMetadata: AutoCaptureMetadata | null = null; + +// Last recorded GIF cache for export +interface ExportableGif { + gifData: Uint8Array; + width: number; + height: number; + frameCount: number; + durationMs: number; + tabId: number; + filename?: string; + actionsCount?: number; + mode: 'fixed_fps' | 'auto_capture'; + createdAt: number; +} +let lastRecordedGif: ExportableGif | null = null; + +// Maximum cache lifetime for exportable GIF (5 minutes) +const EXPORT_CACHE_LIFETIME_MS = 5 * 60 * 1000; + +// ============================================================================ +// Offscreen Document Communication +// ============================================================================ + +type OffscreenResponseBase = { success: boolean; error?: string }; + +async function sendToOffscreen( + type: OffscreenMessageType, + payload: Record = {}, +): Promise { + await offscreenManager.ensureOffscreenDocument(); + + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const response = (await chrome.runtime.sendMessage({ + target: MessageTarget.Offscreen, + type, + ...payload, + })) as TResponse | undefined; + + if (!response) { + throw new Error('No response received from offscreen document'); + } + if (!response.success) { + throw new Error(response.error || 'Unknown offscreen error'); + } + + return response; + } catch (error) { + lastError = error; + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); + continue; + } + throw error; + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +// ============================================================================ +// Frame Capture +// ============================================================================ + +async function captureFrame( + tabId: number, + width: number, + height: number, + ctx: OffscreenCanvasRenderingContext2D, +): Promise { + // Get viewport metrics + const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } = + await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {}); + + const viewportWidth = metrics.layoutViewport?.clientWidth || width; + const viewportHeight = metrics.layoutViewport?.clientHeight || height; + + // Capture screenshot + const screenshot: { data: string } = await cdpSessionManager.sendCommand( + tabId, + 'Page.captureScreenshot', + { + format: 'png', + clip: { + x: 0, + y: 0, + width: viewportWidth, + height: viewportHeight, + scale: 1, + }, + }, + ); + + const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`); + + // Scale image to target dimensions + ctx.clearRect(0, 0, width, height); + ctx.drawImage(imageBitmap, 0, 0, width, height); + imageBitmap.close(); + + const imageData = ctx.getImageData(0, 0, width, height); + return imageData.data; +} + +async function captureAndEncodeFrame(state: RecordingState): Promise { + const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx); + + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, { + imageData: Array.from(frameData), + width: state.width, + height: state.height, + delay: state.frameDelayCs, + maxColors: state.maxColors, + }); + + if (recordingState === state && state.isRecording && !state.isStopping) { + state.frameCount += 1; + } +} + +async function captureTick(state: RecordingState): Promise { + if (recordingState !== state || !state.isRecording || state.isStopping) { + return; + } + + const elapsed = Date.now() - state.startTime; + if (elapsed >= state.durationMs || state.frameCount >= state.maxFrames) { + await stopRecording(); + return; + } + + const startedAt = Date.now(); + state.captureInProgress = captureAndEncodeFrame(state); + + try { + await state.captureInProgress; + } catch (error) { + console.error('Frame capture error:', error); + } finally { + if (recordingState === state) { + state.captureInProgress = null; + } + } + + if (recordingState !== state || !state.isRecording || state.isStopping) { + return; + } + + const elapsedAfter = Date.now() - state.startTime; + if (elapsedAfter >= state.durationMs || state.frameCount >= state.maxFrames) { + await stopRecording(); + return; + } + + const delayMs = Math.max(0, state.frameIntervalMs - (Date.now() - startedAt)); + state.captureTimer = setTimeout(() => { + void captureTick(state).catch((error) => { + console.error('GIF recorder tick error:', error); + }); + }, delayMs); +} + +// ============================================================================ +// Recording Control +// ============================================================================ + +async function startRecording( + tabId: number, + fps: number, + durationMs: number, + maxFrames: number, + width: number, + height: number, + maxColors: number, + filename?: string, +): Promise { + if (stopPromise || recordingState?.isRecording || recordingState?.isStopping) { + return { + success: false, + action: 'start', + error: 'Recording already in progress', + }; + } + + try { + await cdpSessionManager.attach(tabId, CDP_SESSION_KEY); + } catch (error) { + return { + success: false, + action: 'start', + error: error instanceof Error ? error.message : String(error), + }; + } + + try { + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {}); + + if (typeof OffscreenCanvas === 'undefined') { + throw new Error('OffscreenCanvas not available in this context'); + } + + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + const frameIntervalMs = Math.round(1000 / fps); + const frameDelayCs = Math.max(1, Math.round(100 / fps)); + + const state: RecordingState = { + isRecording: true, + isStopping: false, + tabId, + width, + height, + fps, + durationMs, + frameIntervalMs, + frameDelayCs, + maxFrames, + maxColors, + frameCount: 0, + startTime: Date.now(), + captureTimer: null, + captureInProgress: null, + canvas, + ctx, + filename, + }; + + recordingState = state; + + // Capture first frame eagerly so start() fails fast if capture/encoding is broken + await captureAndEncodeFrame(state); + + state.captureTimer = setTimeout(() => { + void captureTick(state).catch((error) => { + console.error('GIF recorder tick error:', error); + }); + }, frameIntervalMs); + + return { + success: true, + action: 'start', + tabId, + isRecording: true, + }; + } catch (error) { + recordingState = null; + try { + await cdpSessionManager.detach(tabId, CDP_SESSION_KEY); + } catch { + // ignore + } + return { + success: false, + action: 'start', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function stopRecording(): Promise { + if (stopPromise) { + return stopPromise; + } + + if (!recordingState || (!recordingState.isRecording && !recordingState.isStopping)) { + return { + success: false, + action: 'stop', + error: 'No recording in progress', + }; + } + + stopPromise = (async () => { + const state = recordingState!; + const tabId = state.tabId; + + // Stop capture timer + if (state.captureTimer) { + clearTimeout(state.captureTimer); + state.captureTimer = null; + } + + state.isStopping = true; + state.isRecording = false; + + try { + await state.captureInProgress; + } catch { + // ignore + } + + // Best-effort final frame capture to preserve end state + try { + const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx); + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, { + imageData: Array.from(frameData), + width: state.width, + height: state.height, + delay: state.frameDelayCs, + maxColors: state.maxColors, + }); + state.frameCount += 1; + } catch (error) { + console.warn('GIF recorder: Final frame capture error (non-fatal):', error); + } + + const frameCount = state.frameCount; + const durationMs = Date.now() - state.startTime; + const filename = state.filename; + + try { + if (frameCount <= 0) { + try { + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {}); + } catch { + // ignore + } + return { + success: false, + action: 'stop' as const, + tabId, + frameCount, + durationMs, + error: 'No frames captured', + }; + } + + const response = await sendToOffscreen<{ + success: boolean; + gifData?: number[]; + byteLength?: number; + }>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {}); + + if (!response.gifData || response.gifData.length === 0) { + return { + success: false, + action: 'stop' as const, + tabId, + frameCount, + durationMs, + error: 'No frames captured', + }; + } + + // Convert to Uint8Array and create blob + const gifBytes = new Uint8Array(response.gifData); + + // Cache for later export + lastRecordedGif = { + gifData: gifBytes, + width: state.width, + height: state.height, + frameCount, + durationMs, + tabId, + filename, + mode: 'fixed_fps', + createdAt: Date.now(), + }; + + const blob = new Blob([gifBytes], { type: 'image/gif' }); + const dataUrl = await blobToDataUrl(blob); + + // Save GIF file + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`; + const fullFilename = outputFilename.endsWith('.gif') + ? outputFilename + : `${outputFilename}.gif`; + + const downloadId = await chrome.downloads.download({ + url: dataUrl, + filename: fullFilename, + saveAs: false, + }); + + // Wait briefly to get download info + await new Promise((resolve) => setTimeout(resolve, 100)); + + let fullPath: string | undefined; + try { + const [downloadItem] = await chrome.downloads.search({ id: downloadId }); + fullPath = downloadItem?.filename; + } catch { + // Ignore path lookup errors + } + + return { + success: true, + action: 'stop' as const, + tabId, + frameCount, + durationMs, + byteLength: response.byteLength ?? gifBytes.byteLength, + downloadId, + filename: fullFilename, + fullPath, + }; + } catch (error) { + return { + success: false, + action: 'stop' as const, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + try { + await cdpSessionManager.detach(tabId, CDP_SESSION_KEY); + } catch { + // ignore + } + recordingState = null; + } + })(); + + return await stopPromise.finally(() => { + stopPromise = null; + }); +} + +function getRecordingStatus(): GifResult { + if (!recordingState) { + return { + success: true, + action: 'status', + isRecording: false, + }; + } + + return { + success: true, + action: 'status', + isRecording: recordingState.isRecording, + tabId: recordingState.tabId, + frameCount: recordingState.frameCount, + durationMs: Date.now() - recordingState.startTime, + }; +} + +// ============================================================================ +// Utilities +// ============================================================================ + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Failed to read blob')); + reader.readAsDataURL(blob); + }); +} + +function normalizePositiveInt(value: unknown, fallback: number, max?: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + const result = Math.max(1, Math.floor(value)); + return max !== undefined ? Math.min(result, max) : result; +} + +// ============================================================================ +// Tool Implementation +// ============================================================================ + +class GifRecorderTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.GIF_RECORDER; + + async execute(args: GifRecorderParams): Promise { + const action = args.action; + const validActions = ['start', 'stop', 'status', 'auto_start', 'capture', 'clear', 'export']; + + if (!action || !validActions.includes(action)) { + return createErrorResponse( + `Parameter [action] is required and must be one of: ${validActions.join(', ')}`, + ); + } + + try { + switch (action) { + case 'start': { + // Fixed-FPS mode: captures frames at regular intervals + const tab = await this.resolveTargetTab(args.tabId); + if (!tab?.id) { + return createErrorResponse( + typeof args.tabId === 'number' + ? `Tab not found: ${args.tabId}` + : 'No active tab found', + ); + } + + if (this.isRestrictedUrl(tab.url)) { + return createErrorResponse( + 'Cannot record special browser pages or web store pages due to security restrictions.', + ); + } + + // Check if auto-capture is active + if (isAutoCaptureActive(tab.id)) { + return createErrorResponse( + 'Auto-capture mode is active for this tab. Use action="stop" to stop it first.', + ); + } + + const fps = normalizePositiveInt(args.fps, DEFAULT_FPS, 30); + const durationMs = normalizePositiveInt(args.durationMs, DEFAULT_DURATION_MS, 60000); + const maxFrames = normalizePositiveInt(args.maxFrames, DEFAULT_MAX_FRAMES, 300); + const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920); + const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080); + const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256); + + const result = await startRecording( + tab.id, + fps, + durationMs, + maxFrames, + width, + height, + maxColors, + args.filename, + ); + + if (result.success) { + result.mode = 'fixed_fps'; + } + + return this.buildResponse(result); + } + + case 'auto_start': { + // Auto-capture mode: captures frames when tools succeed + const tab = await this.resolveTargetTab(args.tabId); + if (!tab?.id) { + return createErrorResponse( + typeof args.tabId === 'number' + ? `Tab not found: ${args.tabId}` + : 'No active tab found', + ); + } + + if (this.isRestrictedUrl(tab.url)) { + return createErrorResponse( + 'Cannot record special browser pages or web store pages due to security restrictions.', + ); + } + + // Check if fixed-FPS recording is active + if (recordingState?.isRecording && recordingState.tabId === tab.id) { + return createErrorResponse( + 'Fixed-FPS recording is active for this tab. Use action="stop" to stop it first.', + ); + } + + // Check if auto-capture is already active + if (isAutoCaptureActive(tab.id)) { + return createErrorResponse('Auto-capture is already active for this tab.'); + } + + const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920); + const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080); + const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256); + const maxFrames = normalizePositiveInt(args.maxFrames, 100, 300); + const captureDelayMs = normalizePositiveInt(args.captureDelayMs, 150, 2000); + const frameDelayCs = normalizePositiveInt(args.frameDelayCs, 20, 100); + + const startResult = await startAutoCapture(tab.id, { + width, + height, + maxColors, + maxFrames, + captureDelayMs, + frameDelayCs, + enhancedRendering: args.enhancedRendering, + }); + + if (!startResult.success) { + return this.buildResponse({ + success: false, + action: 'auto_start', + tabId: tab.id, + error: startResult.error, + }); + } + + // Store metadata for stop + autoCaptureMetadata = { + tabId: tab.id, + filename: args.filename, + }; + + // Capture initial frame + await captureInitialFrame(tab.id); + + return this.buildResponse({ + success: true, + action: 'auto_start', + tabId: tab.id, + mode: 'auto_capture', + isRecording: true, + }); + } + + case 'capture': { + // Manual frame capture in auto mode + const tab = await this.resolveTargetTab(args.tabId); + if (!tab?.id) { + return createErrorResponse( + typeof args.tabId === 'number' + ? `Tab not found: ${args.tabId}` + : 'No active tab found', + ); + } + + if (!isAutoCaptureActive(tab.id)) { + return createErrorResponse( + 'Auto-capture is not active for this tab. Use action="auto_start" first.', + ); + } + + // Support optional annotation for manual captures + const annotation = + typeof args.annotation === 'string' && args.annotation.trim().length > 0 + ? args.annotation.trim() + : undefined; + + const action: ActionMetadata | undefined = annotation + ? { type: 'annotation', label: annotation } + : undefined; + + const captureResult = await captureFrameOnAction(tab.id, action, true); + + return this.buildResponse({ + success: captureResult.success, + action: 'capture', + tabId: tab.id, + frameCount: captureResult.frameNumber, + error: captureResult.error, + }); + } + + case 'stop': { + // Stop either mode + // Check auto-capture first + const autoTab = autoCaptureMetadata?.tabId; + if (autoTab !== undefined && isAutoCaptureActive(autoTab)) { + const stopResult = await stopAutoCapture(autoTab); + const filename = autoCaptureMetadata?.filename; + autoCaptureMetadata = null; + + if (!stopResult.success || !stopResult.gifData) { + return this.buildResponse({ + success: false, + action: 'stop', + tabId: autoTab, + mode: 'auto_capture', + frameCount: stopResult.frameCount, + durationMs: stopResult.durationMs, + actionsCount: stopResult.actions?.length, + error: stopResult.error || 'No GIF data generated', + }); + } + + // Cache for later export + lastRecordedGif = { + gifData: stopResult.gifData, + width: DEFAULT_WIDTH, // auto mode uses default dimensions + height: DEFAULT_HEIGHT, + frameCount: stopResult.frameCount ?? 0, + durationMs: stopResult.durationMs ?? 0, + tabId: autoTab, + filename, + actionsCount: stopResult.actions?.length, + mode: 'auto_capture', + createdAt: Date.now(), + }; + + // Save GIF file + const blob = new Blob([stopResult.gifData], { type: 'image/gif' }); + const dataUrl = await blobToDataUrl(blob); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const outputFilename = + filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`; + const fullFilename = outputFilename.endsWith('.gif') + ? outputFilename + : `${outputFilename}.gif`; + + const downloadId = await chrome.downloads.download({ + url: dataUrl, + filename: fullFilename, + saveAs: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + let fullPath: string | undefined; + try { + const [downloadItem] = await chrome.downloads.search({ id: downloadId }); + fullPath = downloadItem?.filename; + } catch { + // Ignore + } + + return this.buildResponse({ + success: true, + action: 'stop', + tabId: autoTab, + mode: 'auto_capture', + frameCount: stopResult.frameCount, + durationMs: stopResult.durationMs, + byteLength: stopResult.gifData.byteLength, + actionsCount: stopResult.actions?.length, + downloadId, + filename: fullFilename, + fullPath, + }); + } + + // Fall back to fixed-FPS stop + const result = await stopRecording(); + if (result.success) { + result.mode = 'fixed_fps'; + } + return this.buildResponse(result); + } + + case 'status': { + // Check auto-capture status first + const autoTab = autoCaptureMetadata?.tabId; + if (autoTab !== undefined && isAutoCaptureActive(autoTab)) { + const status = getAutoCaptureStatus(autoTab); + return this.buildResponse({ + success: true, + action: 'status', + tabId: autoTab, + isRecording: status.active, + mode: 'auto_capture', + frameCount: status.frameCount, + durationMs: status.durationMs, + actionsCount: status.actionsCount, + }); + } + + // Fall back to fixed-FPS status + const result = getRecordingStatus(); + if (result.isRecording) { + result.mode = 'fixed_fps'; + } + return this.buildResponse(result); + } + + case 'clear': { + // Clear all recording state and cached GIF + let clearedAuto = false; + let clearedFixedFps = false; + let clearedCache = false; + + // Stop auto-capture if active + const autoTab = autoCaptureMetadata?.tabId; + if (autoTab !== undefined && isAutoCaptureActive(autoTab)) { + await stopAutoCapture(autoTab); + autoCaptureMetadata = null; + clearedAuto = true; + } + + // Stop fixed-FPS recording if active or stopping + if (recordingState) { + // Cancel timer and cleanup without waiting for finish + if (recordingState.captureTimer) { + clearTimeout(recordingState.captureTimer); + recordingState.captureTimer = null; + } + try { + await recordingState.captureInProgress; + } catch { + // ignore + } + try { + await cdpSessionManager.detach(recordingState.tabId, CDP_SESSION_KEY); + } catch { + // ignore + } + const wasRecording = recordingState.isRecording || recordingState.isStopping; + recordingState = null; + stopPromise = null; // Clear any pending stop promise + if (wasRecording) { + clearedFixedFps = true; + } + } + + // Reset offscreen encoder + try { + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {}); + } catch { + // ignore + } + + // Clear cached GIF + if (lastRecordedGif) { + lastRecordedGif = null; + clearedCache = true; + } + + return this.buildResponse({ + success: true, + action: 'clear', + clearedAutoCapture: clearedAuto, + clearedFixedFps, + clearedCache, + } as GifResult); + } + + case 'export': { + // Export the last recorded GIF (download or drag&drop upload) + + // Check if cache is valid + if (!lastRecordedGif) { + return createErrorResponse( + 'No recorded GIF available for export. Use action="stop" to finish a recording first.', + ); + } + + // Check cache expiration + if (Date.now() - lastRecordedGif.createdAt > EXPORT_CACHE_LIFETIME_MS) { + lastRecordedGif = null; + return createErrorResponse('Cached GIF has expired. Please record a new GIF.'); + } + + const download = args.download !== false; // Default to download + + if (download) { + // Download mode + const blob = new Blob([lastRecordedGif.gifData], { type: 'image/gif' }); + const dataUrl = await blobToDataUrl(blob); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = args.filename ?? lastRecordedGif.filename; + const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `export_${timestamp}`; + const fullFilename = outputFilename.endsWith('.gif') + ? outputFilename + : `${outputFilename}.gif`; + + const downloadId = await chrome.downloads.download({ + url: dataUrl, + filename: fullFilename, + saveAs: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + let fullPath: string | undefined; + try { + const [downloadItem] = await chrome.downloads.search({ id: downloadId }); + fullPath = downloadItem?.filename; + } catch { + // Ignore + } + + return this.buildResponse({ + success: true, + action: 'export', + mode: lastRecordedGif.mode, + frameCount: lastRecordedGif.frameCount, + durationMs: lastRecordedGif.durationMs, + byteLength: lastRecordedGif.gifData.byteLength, + downloadId, + filename: fullFilename, + fullPath, + }); + } else { + // Drag&drop upload mode + const { coordinates, ref, selector } = args; + + if (!coordinates && !ref && !selector) { + return createErrorResponse( + 'For drag&drop upload, provide coordinates, ref, or selector to identify the drop target.', + ); + } + + // Resolve target tab + const tab = await this.resolveTargetTab(args.tabId); + if (!tab?.id) { + return createErrorResponse( + typeof args.tabId === 'number' + ? `Tab not found: ${args.tabId}` + : 'No active tab found', + ); + } + + // Security check + if (this.isRestrictedUrl(tab.url)) { + return createErrorResponse( + 'Cannot upload to special browser pages or web store pages.', + ); + } + + // Prepare GIF data as base64 + const gifBase64 = btoa( + Array.from(lastRecordedGif.gifData) + .map((b) => String.fromCharCode(b)) + .join(''), + ); + + // Resolve drop target coordinates + let targetX: number | undefined; + let targetY: number | undefined; + + if (ref) { + // Use the project's built-in ref resolution mechanism + try { + await this.injectContentScript(tab.id, [ + 'inject-scripts/accessibility-tree-helper.js', + ]); + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref, + }); + if (resolved?.success && resolved.center) { + targetX = resolved.center.x; + targetY = resolved.center.y; + } else { + return createErrorResponse(`Could not resolve ref: ${ref}`); + } + } catch (err) { + return createErrorResponse( + `Failed to resolve ref: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else if (selector) { + // Use executeScript to get element center coordinates by CSS selector + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (cssSelector: string) => { + const el = document.querySelector(cssSelector); + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + }, + args: [selector], + }); + + if (result?.result) { + targetX = result.result.x; + targetY = result.result.y; + } else { + return createErrorResponse(`Could not find element: ${selector}`); + } + } catch (err) { + return createErrorResponse( + `Failed to resolve selector: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else if (coordinates) { + targetX = coordinates.x; + targetY = coordinates.y; + } + + if (typeof targetX !== 'number' || typeof targetY !== 'number') { + return createErrorResponse('Invalid drop target coordinates.'); + } + + // Execute drag&drop upload + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = + args.filename ?? lastRecordedGif.filename ?? `recording_${timestamp}`; + const fullFilename = filename.endsWith('.gif') ? filename : `${filename}.gif`; + + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (base64Data: string, x: number, y: number, fname: string) => { + // Convert base64 to Blob + const byteChars = atob(base64Data); + const byteArray = new Uint8Array(byteChars.length); + for (let i = 0; i < byteChars.length; i++) { + byteArray[i] = byteChars.charCodeAt(i); + } + const blob = new Blob([byteArray], { type: 'image/gif' }); + const file = new File([blob], fname, { type: 'image/gif' }); + + // Find drop target element + const target = document.elementFromPoint(x, y); + if (!target) { + return { success: false, error: 'No element at drop coordinates' }; + } + + // Create DataTransfer with the file + const dt = new DataTransfer(); + dt.items.add(file); + + // Dispatch drag events + const events = ['dragenter', 'dragover', 'drop'] as const; + for (const eventType of events) { + const evt = new DragEvent(eventType, { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: x, + clientY: y, + }); + target.dispatchEvent(evt); + } + + return { + success: true, + targetTagName: target.tagName, + targetId: target.id || undefined, + }; + }, + args: [gifBase64, targetX, targetY, fullFilename], + }); + + if (!result?.result?.success) { + return createErrorResponse(result?.result?.error || 'Drag&drop upload failed'); + } + + return this.buildResponse({ + success: true, + action: 'export', + mode: lastRecordedGif.mode, + frameCount: lastRecordedGif.frameCount, + durationMs: lastRecordedGif.durationMs, + byteLength: lastRecordedGif.gifData.byteLength, + uploadTarget: { + x: targetX, + y: targetY, + tagName: result.result.targetTagName, + id: result.result.targetId, + }, + } as GifResult); + } catch (err) { + return createErrorResponse( + `Drag&drop upload failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + default: + return createErrorResponse(`Unknown action: ${action}`); + } + } catch (error) { + console.error('GifRecorderTool.execute error:', error); + return createErrorResponse( + `GIF recorder error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private isRestrictedUrl(url?: string): boolean { + if (!url) return false; + return ( + url.startsWith('chrome://') || + url.startsWith('edge://') || + url.startsWith('https://chrome.google.com/webstore') || + url.startsWith('https://microsoftedge.microsoft.com/') + ); + } + + private async resolveTargetTab(tabId?: number): Promise { + if (typeof tabId === 'number') { + return this.tryGetTab(tabId); + } + try { + return await this.getActiveTabOrThrow(); + } catch { + return null; + } + } + + private buildResponse(result: GifResult): ToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: !result.success, + }; + } +} + +export const gifRecorderTool = new GifRecorderTool(); + +// Re-export auto-capture utilities for use by other tools (e.g., chrome_computer, chrome_navigate) +export { + captureFrameOnAction, + isAutoCaptureActive, + type ActionMetadata, + type ActionType, +} from './gif-auto-capture'; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/history.ts b/app/chrome-extension/entrypoints/background/tools/browser/history.ts new file mode 100644 index 0000000..6461f70 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/history.ts @@ -0,0 +1,232 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { + parseISO, + subDays, + subWeeks, + subMonths, + subYears, + startOfToday, + startOfYesterday, + isValid, + format, +} from 'date-fns'; + +interface HistoryToolParams { + text?: string; + startTime?: string; + endTime?: string; + maxResults?: number; + excludeCurrentTabs?: boolean; +} + +interface HistoryItem { + id: string; + url?: string; + title?: string; + lastVisitTime?: number; // Timestamp in milliseconds + visitCount?: number; + typedCount?: number; +} + +interface HistoryResult { + items: HistoryItem[]; + totalCount: number; + timeRange: { + startTime: number; + endTime: number; + startTimeFormatted: string; + endTimeFormatted: string; + }; + query?: string; +} + +class HistoryTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.HISTORY; + private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; + + /** + * Parse a date string into milliseconds since epoch. + * Returns null if the date string is invalid. + * Supports: + * - ISO date strings (e.g., "2023-10-31", "2023-10-31T14:30:00.000Z") + * - Relative times: "1 day ago", "2 weeks ago", "3 months ago", "1 year ago" + * - Special keywords: "now", "today", "yesterday" + */ + private parseDateString(dateStr: string | undefined | null): number | null { + if (!dateStr) { + // If an empty or null string is passed, it might mean "no specific date", + // depending on how you want to treat it. Returning null is safer. + return null; + } + + const now = new Date(); + const lowerDateStr = dateStr.toLowerCase().trim(); + + if (lowerDateStr === 'now') return now.getTime(); + if (lowerDateStr === 'today') return startOfToday().getTime(); + if (lowerDateStr === 'yesterday') return startOfYesterday().getTime(); + + const relativeMatch = lowerDateStr.match( + /^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/, + ); + if (relativeMatch) { + const amount = parseInt(relativeMatch[1], 10); + const unit = relativeMatch[2]; + let resultDate: Date; + if (unit.startsWith('day')) resultDate = subDays(now, amount); + else if (unit.startsWith('week')) resultDate = subWeeks(now, amount); + else if (unit.startsWith('month')) resultDate = subMonths(now, amount); + else if (unit.startsWith('year')) resultDate = subYears(now, amount); + else return null; // Should not happen with the regex + return resultDate.getTime(); + } + + // Try parsing as ISO or other common date string formats + // Native Date constructor can be unreliable for non-standard formats. + // date-fns' parseISO is good for ISO 8601. + // For other formats, date-fns' parse function is more flexible. + let parsedDate = parseISO(dateStr); // Handles "2023-10-31" or "2023-10-31T10:00:00" + if (isValid(parsedDate)) { + return parsedDate.getTime(); + } + + // Fallback to new Date() for other potential formats, but with caution + parsedDate = new Date(dateStr); + if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) { + return parsedDate.getTime(); + } + + console.warn(`Could not parse date string: ${dateStr}`); + return null; + } + + /** + * Format a timestamp as a human-readable date string + */ + private formatDate(timestamp: number): string { + // Using date-fns for consistent and potentially localized formatting + return format(timestamp, 'yyyy-MM-dd HH:mm:ss'); + } + + async execute(args: HistoryToolParams): Promise { + try { + console.log('Executing HistoryTool with args:', args); + + const { + text = '', + maxResults = 100, // Default to 100 results + excludeCurrentTabs = false, + } = args; + + const now = Date.now(); + let startTimeMs: number; + let endTimeMs: number; + + // Parse startTime + if (args.startTime) { + const parsedStart = this.parseDateString(args.startTime); + if (parsedStart === null) { + return createErrorResponse( + `Invalid format for start time: "${args.startTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`, + ); + } + startTimeMs = parsedStart; + } else { + // Default to 24 hours ago if startTime is not provided + startTimeMs = now - HistoryTool.ONE_DAY_MS; + } + + // Parse endTime + if (args.endTime) { + const parsedEnd = this.parseDateString(args.endTime); + if (parsedEnd === null) { + return createErrorResponse( + `Invalid format for end time: "${args.endTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`, + ); + } + endTimeMs = parsedEnd; + } else { + // Default to current time if endTime is not provided + endTimeMs = now; + } + + // Validate time range + if (startTimeMs > endTimeMs) { + return createErrorResponse('Start time cannot be after end time.'); + } + + console.log( + `Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query "${text}"`, + ); + + const historyItems = await chrome.history.search({ + text, + startTime: startTimeMs, + endTime: endTimeMs, + maxResults, + }); + + console.log(`Found ${historyItems.length} history items before filtering current tabs.`); + + let filteredItems = historyItems; + if (excludeCurrentTabs && historyItems.length > 0) { + const currentTabs = await chrome.tabs.query({}); + const openUrls = new Set(); + + currentTabs.forEach((tab) => { + if (tab.url) { + openUrls.add(tab.url); + } + }); + + if (openUrls.size > 0) { + filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url))); + console.log( + `Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`, + ); + } + } + + const result: HistoryResult = { + items: filteredItems.map((item) => ({ + id: item.id, + url: item.url, + title: item.title, + lastVisitTime: item.lastVisitTime, + visitCount: item.visitCount, + typedCount: item.typedCount, + })), + totalCount: filteredItems.length, + timeRange: { + startTime: startTimeMs, + endTime: endTimeMs, + startTimeFormatted: this.formatDate(startTimeMs), + endTimeFormatted: this.formatDate(endTimeMs), + }, + }; + + if (text) { + result.query = text; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in HistoryTool.execute:', error); + return createErrorResponse( + `Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const historyTool = new HistoryTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/index.ts b/app/chrome-extension/entrypoints/background/tools/browser/index.ts new file mode 100644 index 0000000..629f74e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/index.ts @@ -0,0 +1,30 @@ +export { navigateTool, closeTabsTool, switchTabTool } from './common'; +export { windowTool } from './window'; +export { vectorSearchTabsContentTool as searchTabsContentTool } from './vector-search'; +export { screenshotTool } from './screenshot'; +export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher'; +export { clickTool, fillTool } from './interaction'; +export { elementPickerTool } from './element-picker'; +export { networkRequestTool } from './network-request'; +export { networkCaptureTool } from './network-capture'; +// Legacy exports (for internal use by networkCaptureTool) +export { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger'; +export { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request'; +export { keyboardTool } from './keyboard'; +export { historyTool } from './history'; +export { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark'; +export { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script'; +export { javascriptTool } from './javascript'; +export { consoleTool } from './console'; +export { fileUploadTool } from './file-upload'; +export { readPageTool } from './read-page'; +export { computerTool } from './computer'; +export { handleDialogTool } from './dialog'; +export { handleDownloadTool } from './download'; +export { userscriptTool } from './userscript'; +export { + performanceStartTraceTool, + performanceStopTraceTool, + performanceAnalyzeInsightTool, +} from './performance'; +export { gifRecorderTool } from './gif-recorder'; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts b/app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts new file mode 100644 index 0000000..b3e73f8 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts @@ -0,0 +1,244 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { ExecutionWorld } from '@/common/constants'; + +interface InjectScriptParam { + url?: string; + tabId?: number; + windowId?: number; + background?: boolean; +} +interface ScriptConfig { + type: ExecutionWorld; + jsScript: string; +} + +interface SendCommandToInjectScriptToolParam { + tabId?: number; + eventName: string; + payload?: string; +} + +const injectedTabs = new Map(); +class InjectScriptTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.INJECT_SCRIPT; + async execute(args: InjectScriptParam & ScriptConfig): Promise { + try { + const { url, type, jsScript, tabId, windowId, background } = args; + let tab: chrome.tabs.Tab | undefined; + + if (!type || !jsScript) { + return createErrorResponse('Param [type] and [jsScript] is required'); + } + + if (typeof tabId === 'number') { + tab = await chrome.tabs.get(tabId); + } else if (url) { + // If URL is provided, check if it's already open + console.log(`Checking if URL is already open: ${url}`); + const allTabs = await chrome.tabs.query({}); + + // Find tab with matching URL + const matchingTabs = allTabs.filter((t) => { + // Normalize URLs for comparison (remove trailing slashes) + const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url; + const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url; + return tabUrl === targetUrl; + }); + + if (matchingTabs.length > 0) { + // Use existing tab + tab = matchingTabs[0]; + console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`); + } else { + // Create new tab with the URL + console.log(`No existing tab found with URL: ${url}, creating new tab`); + tab = await chrome.tabs.create({ + url, + active: background === true ? false : true, + windowId, + }); + + // Wait for page to load + console.log('Waiting for page to load...'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } else { + // Use active tab (prefer the specified window) + const tabs = + typeof windowId === 'number' + ? await chrome.tabs.query({ active: true, windowId }) + : await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0]) { + return createErrorResponse('No active tab found'); + } + tab = tabs[0]; + } + + if (!tab.id) { + return createErrorResponse('Tab has no ID'); + } + + // Optionally bring tab/window to foreground based on background flag + if (background !== true) { + await chrome.tabs.update(tab.id, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + } + + const res = await handleInject(tab.id!, { ...args }); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(res), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in InjectScriptTool.execute:', error); + return createErrorResponse( + `Inject script error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +class SendCommandToInjectScriptTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT; + async execute(args: SendCommandToInjectScriptToolParam): Promise { + try { + const { tabId, eventName, payload } = args; + + if (!eventName) { + return createErrorResponse('Param [eventName] is required'); + } + + if (tabId) { + const tabExists = await isTabExists(tabId); + if (!tabExists) { + return createErrorResponse('The tab:[tabId] is not exists'); + } + } + + let finalTabId: number | undefined = tabId; + + if (finalTabId === undefined) { + // Use active tab + const tabs = await chrome.tabs.query({ active: true }); + if (!tabs[0]) { + return createErrorResponse('No active tab found'); + } + finalTabId = tabs[0].id; + } + + if (!finalTabId) { + return createErrorResponse('No active tab found'); + } + + if (!injectedTabs.has(finalTabId)) { + throw new Error('No script injected in this tab.'); + } + const result = await chrome.tabs.sendMessage(finalTabId, { + action: eventName, + payload, + targetWorld: injectedTabs.get(finalTabId).type, // The bridge uses this to decide whether to forward to MAIN world. + }); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in InjectScriptTool.execute:', error); + return createErrorResponse( + `Inject script error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +async function isTabExists(tabId: number) { + try { + await chrome.tabs.get(tabId); + return true; + } catch (error) { + // An error is thrown if the tab doesn't exist. + return false; + } +} + +/** + * @description Handles the injection of user scripts into a specific tab. + * @param {number} tabId - The ID of the target tab. + * @param {object} scriptConfig - The configuration object for the script. + */ +async function handleInject(tabId: number, scriptConfig: ScriptConfig) { + if (injectedTabs.has(tabId)) { + // If already injected, run cleanup first to ensure a clean state. + console.log(`Tab ${tabId} already has injections. Cleaning up first.`); + await handleCleanup(tabId); + } + const { type, jsScript } = scriptConfig; + const hasMain = type === ExecutionWorld.MAIN; + + if (hasMain) { + // The bridge is essential for MAIN world communication and cleanup. + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['inject-scripts/inject-bridge.js'], + world: ExecutionWorld.ISOLATED, + }); + await chrome.scripting.executeScript({ + target: { tabId }, + func: (code) => new Function(code)(), + args: [jsScript], + world: ExecutionWorld.MAIN, + }); + } else { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (code) => new Function(code)(), + args: [jsScript], + world: ExecutionWorld.ISOLATED, + }); + } + injectedTabs.set(tabId, scriptConfig); + console.log(`Scripts successfully injected into tab ${tabId}.`); + return { injected: true }; +} + +/** + * @description Triggers the cleanup process in a specific tab. + * @param {number} tabId - The ID of the target tab. + */ +async function handleCleanup(tabId: number) { + if (!injectedTabs.has(tabId)) return; + // Send cleanup signal. The bridge will forward it to the MAIN world. + chrome.tabs + .sendMessage(tabId, { type: 'chrome-mcp:cleanup' }) + .catch((err) => + console.warn(`Could not send cleanup message to tab ${tabId}. It might have been closed.`), + ); + + injectedTabs.delete(tabId); + console.log(`Cleanup signal sent to tab ${tabId}. State cleared.`); +} + +export const injectScriptTool = new InjectScriptTool(); +export const sendCommandToInjectScriptTool = new SendCommandToInjectScriptTool(); + +// --- Automatic Cleanup Listeners --- +chrome.tabs.onRemoved.addListener((tabId) => { + if (injectedTabs.has(tabId)) { + console.log(`Tab ${tabId} closed. Cleaning up state.`); + injectedTabs.delete(tabId); + } +}); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/interaction.ts b/app/chrome-extension/entrypoints/background/tools/browser/interaction.ts new file mode 100644 index 0000000..e977263 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/interaction.ts @@ -0,0 +1,269 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants'; + +interface Coordinates { + x: number; + y: number; +} + +interface ClickToolParams { + selector?: string; // CSS selector or XPath for the element to click + selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css') + ref?: string; // Element ref from accessibility tree (window.__claudeElementMap) + coordinates?: Coordinates; // Coordinates to click at (x, y relative to viewport) + waitForNavigation?: boolean; // Whether to wait for navigation to complete after click + timeout?: number; // Timeout in milliseconds for waiting for the element or navigation + frameId?: number; // Target frame for ref/selector resolution + double?: boolean; // Perform double click when true + button?: 'left' | 'right' | 'middle'; + bubbles?: boolean; + cancelable?: boolean; + modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }; + tabId?: number; // target existing tab id + windowId?: number; // when no tabId, pick active tab from this window +} + +/** + * Tool for clicking elements on web pages + */ +class ClickTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.CLICK; + + /** + * Execute click operation + */ + async execute(args: ClickToolParams): Promise { + const { + selector, + selectorType = 'css', + coordinates, + waitForNavigation = false, + timeout = TIMEOUTS.DEFAULT_WAIT * 5, + frameId, + button, + bubbles, + cancelable, + modifiers, + } = args; + + console.log(`Starting click operation with options:`, args); + + if (!selector && !coordinates && !args.ref) { + return createErrorResponse( + ERROR_MESSAGES.INVALID_PARAMETERS + ': Provide ref or selector or coordinates', + ); + } + + try { + // Resolve tab + const explicit = await this.tryGetTab(args.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId)); + if (!tab.id) { + return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID'); + } + + let finalRef = args.ref; + let finalSelector = selector; + + // If selector is XPath, convert to ref first + if (selector && selectorType === 'xpath') { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + try { + const resolved = await this.sendMessageToTab( + tab.id, + { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector, + isXPath: true, + }, + frameId, + ); + if (resolved && resolved.success && resolved.ref) { + finalRef = resolved.ref; + finalSelector = undefined; // Use ref instead of selector + } else { + return createErrorResponse( + `Failed to resolve XPath selector: ${resolved?.error || 'unknown error'}`, + ); + } + } catch (error) { + return createErrorResponse( + `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + await this.injectContentScript(tab.id, ['inject-scripts/click-helper.js']); + + // Send click message to content script + const result = await this.sendMessageToTab( + tab.id, + { + action: TOOL_MESSAGE_TYPES.CLICK_ELEMENT, + selector: finalSelector, + coordinates, + ref: finalRef, + waitForNavigation, + timeout, + double: args.double === true, + button, + bubbles, + cancelable, + modifiers, + }, + frameId, + ); + + // Determine actual click method used + let clickMethod: string; + if (coordinates) { + clickMethod = 'coordinates'; + } else if (finalRef) { + clickMethod = 'ref'; + } else if (finalSelector) { + clickMethod = 'selector'; + } else { + clickMethod = 'unknown'; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: result.message || 'Click operation successful', + elementInfo: result.elementInfo, + navigationOccurred: result.navigationOccurred, + clickMethod, + }), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in click operation:', error); + return createErrorResponse( + `Error performing click: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const clickTool = new ClickTool(); + +interface FillToolParams { + selector?: string; + selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css') + ref?: string; // Element ref from accessibility tree + // Accept string | number | boolean for broader form input coverage + value: string | number | boolean; + frameId?: number; + tabId?: number; // target existing tab id + windowId?: number; // when no tabId, pick active tab from this window +} + +/** + * Tool for filling form elements on web pages + */ +class FillTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.FILL; + + /** + * Execute fill operation + */ + async execute(args: FillToolParams): Promise { + const { selector, selectorType = 'css', ref, value, frameId } = args; + + console.log(`Starting fill operation with options:`, args); + + if (!selector && !ref) { + return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Provide ref or selector'); + } + + if (value === undefined || value === null) { + return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Value must be provided'); + } + + try { + const explicit = await this.tryGetTab(args.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId)); + if (!tab.id) { + return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID'); + } + + let finalRef = ref; + let finalSelector = selector; + + // If selector is XPath, convert to ref first + if (selector && selectorType === 'xpath') { + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + try { + const resolved = await this.sendMessageToTab( + tab.id, + { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector, + isXPath: true, + }, + frameId, + ); + if (resolved && resolved.success && resolved.ref) { + finalRef = resolved.ref; + finalSelector = undefined; // Use ref instead of selector + } else { + return createErrorResponse( + `Failed to resolve XPath selector: ${resolved?.error || 'unknown error'}`, + ); + } + } catch (error) { + return createErrorResponse( + `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + await this.injectContentScript(tab.id, ['inject-scripts/fill-helper.js']); + + // Send fill message to content script + const result = await this.sendMessageToTab( + tab.id, + { + action: TOOL_MESSAGE_TYPES.FILL_ELEMENT, + selector: finalSelector, + ref: finalRef, + value, + }, + frameId, + ); + + if (result && result.error) { + return createErrorResponse(result.error); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: result.message || 'Fill operation successful', + elementInfo: result.elementInfo, + }), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in fill operation:', error); + return createErrorResponse( + `Error filling element: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const fillTool = new FillTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/javascript.ts b/app/chrome-extension/entrypoints/background/tools/browser/javascript.ts new file mode 100644 index 0000000..ad342c1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/javascript.ts @@ -0,0 +1,525 @@ +/** + * JavaScript Tool - CDP Runtime.evaluate with fallback + * + * Execute JavaScript in the browser tab and return the result. + * - Primary: CDP Runtime.evaluate (supports awaitPromise + returnByValue) + * - Fallback: chrome.scripting.executeScript (when debugger is busy) + * + * Features: + * - Async code support (top-level await via async wrapper) + * - Output sanitization (sensitive data redaction) + * - Output truncation (configurable max bytes) + * - Timeout handling + * - Detailed error classification + */ + +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { + DEFAULT_MAX_OUTPUT_BYTES, + sanitizeAndLimitOutput, + sanitizeText, +} from '@/utils/output-sanitizer'; + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_TIMEOUT_MS = 15_000; +const CDP_SESSION_KEY = 'javascript'; + +// ============================================================================ +// Types +// ============================================================================ + +type ExecutionEngine = 'cdp' | 'scripting'; + +type ErrorKind = + | 'debugger_conflict' + | 'timeout' + | 'syntax_error' + | 'runtime_error' + | 'cdp_error' + | 'scripting_error'; + +interface JavaScriptToolParams { + code: string; + tabId?: number; + timeoutMs?: number; + maxOutputBytes?: number; +} + +interface ExecutionError { + kind: ErrorKind; + message: string; + details?: { + url?: string; + lineNumber?: number; + columnNumber?: number; + }; +} + +interface ExecutionMetrics { + elapsedMs: number; +} + +interface JavaScriptToolResult { + success: boolean; + tabId: number; + engine: ExecutionEngine; + result?: string; + truncated?: boolean; + redacted?: boolean; + warnings?: string[]; + error?: ExecutionError; + metrics?: ExecutionMetrics; +} + +interface ExecutionOptions { + timeoutMs: number; + maxOutputBytes: number; +} + +// Discriminated union for execution results +type ExecutionSuccess = { + ok: true; + engine: ExecutionEngine; + output: string; + truncated: boolean; + redacted: boolean; +}; + +type ExecutionFailure = { + ok: false; + engine: ExecutionEngine; + error: ExecutionError; +}; + +type ExecutionResult = ExecutionSuccess | ExecutionFailure; + +// ============================================================================ +// Timeout Error +// ============================================================================ + +class TimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Execution timed out after ${timeoutMs}ms`); + this.name = 'TimeoutError'; + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function normalizePositiveInt(value: unknown, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + return Math.max(1, Math.floor(value)); +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new TimeoutError(timeoutMs)); + }, timeoutMs); + + promise + .then(resolve) + .catch(reject) + .finally(() => clearTimeout(timer)); + }); +} + +function isTimeoutError(error: unknown): error is TimeoutError { + return error instanceof Error && error.name === 'TimeoutError'; +} + +function isDebuggerConflictError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /Debugger is already attached|Another debugger is already attached|Cannot attach to this target/i.test( + message, + ); +} + +/** + * Wrap user code in an async IIFE to support top-level await and return statements. + */ +function wrapUserCode(code: string): string { + return `(async () => {\n${code}\n})()`; +} + +// ============================================================================ +// CDP Execution +// ============================================================================ + +interface CDPRemoteObject { + type?: string; + subtype?: string; + value?: unknown; + unserializableValue?: string; + description?: string; +} + +interface CDPExceptionDetails { + text?: string; + url?: string; + lineNumber?: number; + columnNumber?: number; + exception?: { + className?: string; + description?: string; + value?: string; + }; +} + +interface CDPEvaluateResult { + result?: CDPRemoteObject; + exceptionDetails?: CDPExceptionDetails; +} + +function extractReturnValue(remoteObject?: CDPRemoteObject): unknown { + if (!remoteObject) return undefined; + + if ('value' in remoteObject) return remoteObject.value; + if ('unserializableValue' in remoteObject) return remoteObject.unserializableValue; + if (typeof remoteObject.description === 'string') return remoteObject.description; + + return undefined; +} + +function parseExceptionDetails(details: CDPExceptionDetails): ExecutionError { + const exceptionClassName = details.exception?.className ?? ''; + const exceptionDescription = details.exception?.description ?? ''; + const exceptionValue = details.exception?.value ?? ''; + const text = details.text ?? ''; + + // Determine the raw error message + const rawMessage = + exceptionDescription || exceptionValue || text || 'JavaScript execution failed'; + + // Sanitize the message + const message = sanitizeText(rawMessage).text; + + // Classify the error kind + const isSyntaxError = exceptionClassName === 'SyntaxError' || /SyntaxError/i.test(rawMessage); + + return { + kind: isSyntaxError ? 'syntax_error' : 'runtime_error', + message, + details: { + url: details.url, + lineNumber: details.lineNumber, + columnNumber: details.columnNumber, + }, + }; +} + +async function executeViaCdp( + tabId: number, + code: string, + options: ExecutionOptions, +): Promise { + try { + const expression = wrapUserCode(code); + + const response = await withTimeout( + cdpSessionManager.withSession(tabId, CDP_SESSION_KEY, async () => { + return (await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + // CDP 内置超时(毫秒),与外层 withTimeout 双重保障 + timeout: options.timeoutMs, + })) as CDPEvaluateResult; + }), + // 外层超时稍长,给 CDP 一点余量处理超时响应 + options.timeoutMs + 1000, + ); + + // Check for exception + if (response?.exceptionDetails) { + return { + ok: false, + engine: 'cdp', + error: parseExceptionDetails(response.exceptionDetails), + }; + } + + // Extract and sanitize the result + const value = extractReturnValue(response?.result); + const sanitized = sanitizeAndLimitOutput(value, { maxBytes: options.maxOutputBytes }); + + return { + ok: true, + engine: 'cdp', + output: sanitized.text, + truncated: sanitized.truncated, + redacted: sanitized.redacted, + }; + } catch (error) { + if (isTimeoutError(error)) { + return { + ok: false, + engine: 'cdp', + error: { kind: 'timeout', message: error.message }, + }; + } + + if (isDebuggerConflictError(error)) { + const message = sanitizeText(error instanceof Error ? error.message : String(error)).text; + return { + ok: false, + engine: 'cdp', + error: { kind: 'debugger_conflict', message }, + }; + } + + const message = sanitizeText(error instanceof Error ? error.message : String(error)).text; + return { + ok: false, + engine: 'cdp', + error: { kind: 'cdp_error', message }, + }; + } +} + +// ============================================================================ +// chrome.scripting.executeScript Fallback +// ============================================================================ + +interface ScriptingExecutionResult { + ok: boolean; + value?: unknown; + error?: { + name?: string; + message?: string; + stack?: string; + }; +} + +async function executeViaScripting( + tabId: number, + code: string, + options: ExecutionOptions, +): Promise { + const innerExecute = async (): Promise => { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'ISOLATED', + func: async (userCode: string): Promise => { + try { + // Use AsyncFunction constructor to support top-level await + + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const fn = new AsyncFunction(userCode); + const value = await fn(); + return { ok: true, value }; + } catch (err: unknown) { + const error = err as Error; + return { + ok: false, + error: { + name: error?.name ?? undefined, + message: error?.message ?? String(err), + stack: error?.stack ?? undefined, + }, + }; + } + }, + args: [code], + }); + + // Extract the first result + const firstFrame = results?.[0]; + const result = (firstFrame as { result?: ScriptingExecutionResult })?.result; + + if (!result || typeof result !== 'object') { + return { + ok: false, + engine: 'scripting', + error: { kind: 'scripting_error', message: 'No result returned from executeScript' }, + }; + } + + if (!result.ok) { + const rawMessage = result.error?.message ?? 'JavaScript execution failed'; + const rawStack = result.error?.stack; + + const message = sanitizeText(rawMessage).text; + const sanitizedStack = rawStack ? sanitizeText(rawStack).text : undefined; + + const isSyntaxError = result.error?.name === 'SyntaxError' || /SyntaxError/i.test(rawMessage); + + return { + ok: false, + engine: 'scripting', + error: { + kind: isSyntaxError ? 'syntax_error' : 'runtime_error', + message: sanitizedStack ? `${message}\n${sanitizedStack}` : message, + }, + }; + } + + // Sanitize the successful result + const sanitized = sanitizeAndLimitOutput(result.value, { maxBytes: options.maxOutputBytes }); + + return { + ok: true, + engine: 'scripting', + output: sanitized.text, + truncated: sanitized.truncated, + redacted: sanitized.redacted, + }; + }; + + try { + return await withTimeout(innerExecute(), options.timeoutMs); + } catch (error) { + if (isTimeoutError(error)) { + return { + ok: false, + engine: 'scripting', + error: { kind: 'timeout', message: error.message }, + }; + } + + const message = sanitizeText(error instanceof Error ? error.message : String(error)).text; + return { + ok: false, + engine: 'scripting', + error: { kind: 'scripting_error', message }, + }; + } +} + +// ============================================================================ +// Tool Implementation +// ============================================================================ + +class JavaScriptTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.JAVASCRIPT; + + async execute(args: JavaScriptToolParams): Promise { + const startTime = performance.now(); + + try { + // Validate required parameter + const code = typeof args?.code === 'string' ? args.code.trim() : ''; + if (!code) { + return createErrorResponse('Parameter [code] is required'); + } + + // Resolve target tab + const tab = await this.resolveTargetTab(args.tabId); + if (!tab) { + return createErrorResponse( + typeof args.tabId === 'number' ? `Tab not found: ${args.tabId}` : 'No active tab found', + ); + } + + if (!tab.id) { + return createErrorResponse('Tab has no ID'); + } + const tabId = tab.id; + + // Normalize options + const options: ExecutionOptions = { + timeoutMs: normalizePositiveInt(args.timeoutMs, DEFAULT_TIMEOUT_MS), + maxOutputBytes: normalizePositiveInt(args.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES), + }; + + const warnings: string[] = []; + + // Try CDP execution first + const cdpResult = await executeViaCdp(tabId, code, options); + + if (cdpResult.ok) { + return this.buildSuccessResponse(tabId, cdpResult, startTime); + } + + // If not a debugger conflict, return the CDP error + if (cdpResult.error.kind !== 'debugger_conflict') { + return this.buildErrorResponse(tabId, cdpResult, startTime); + } + + // Debugger conflict - fallback to scripting API + warnings.push( + 'Debugger is busy (DevTools or another extension attached). Falling back to chrome.scripting.executeScript (runs in ISOLATED world, not page context).', + ); + + const scriptingResult = await executeViaScripting(tabId, code, options); + + if (scriptingResult.ok) { + return this.buildSuccessResponse(tabId, scriptingResult, startTime, warnings); + } + + return this.buildErrorResponse(tabId, scriptingResult, startTime, warnings); + } catch (error) { + console.error('JavaScriptTool.execute error:', error); + return createErrorResponse( + `JavaScript tool error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async resolveTargetTab(tabId?: number): Promise { + if (typeof tabId === 'number') { + return this.tryGetTab(tabId); + } + try { + return await this.getActiveTabOrThrow(); + } catch { + return null; + } + } + + private buildSuccessResponse( + tabId: number, + result: ExecutionSuccess, + startTime: number, + warnings?: string[], + ): ToolResult { + const payload: JavaScriptToolResult = { + success: true, + tabId, + engine: result.engine, + result: result.output, + truncated: result.truncated || undefined, + redacted: result.redacted || undefined, + warnings: warnings?.length ? warnings : undefined, + metrics: { elapsedMs: Math.round(performance.now() - startTime) }, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + isError: false, + }; + } + + private buildErrorResponse( + tabId: number, + result: ExecutionFailure, + startTime: number, + warnings?: string[], + ): ToolResult { + const payload: JavaScriptToolResult = { + success: false, + tabId, + engine: result.engine, + error: result.error, + warnings: warnings?.length ? warnings : undefined, + metrics: { elapsedMs: Math.round(performance.now() - startTime) }, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + isError: true, + }; + } +} + +export const javascriptTool = new JavaScriptTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/keyboard.ts b/app/chrome-extension/entrypoints/background/tools/browser/keyboard.ts new file mode 100644 index 0000000..3204d01 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/keyboard.ts @@ -0,0 +1,146 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants'; + +interface KeyboardToolParams { + keys: string; // Required: string representing keys or key combinations to simulate (e.g., "Enter", "Ctrl+C") + selector?: string; // Optional: CSS selector or XPath for target element to send keyboard events to + selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css') + delay?: number; // Optional: delay between keystrokes in milliseconds + tabId?: number; // target existing tab id + windowId?: number; // when no tabId, pick active tab from this window + frameId?: number; // target frame id for iframe support +} + +/** + * Tool for simulating keyboard input on web pages + */ +class KeyboardTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.KEYBOARD; + + /** + * Execute keyboard operation + */ + async execute(args: KeyboardToolParams): Promise { + const { keys, selector, selectorType = 'css', delay = TIMEOUTS.KEYBOARD_DELAY } = args; + + console.log(`Starting keyboard operation with options:`, args); + + if (!keys) { + return createErrorResponse( + ERROR_MESSAGES.INVALID_PARAMETERS + ': Keys parameter must be provided', + ); + } + + try { + const explicit = await this.tryGetTab(args.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId)); + if (!tab.id) { + return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID'); + } + + let finalSelector = selector; + let refForFocus: string | undefined = undefined; + + // Ensure helper is loaded for XPath or potential focus operations + await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + + // If selector is XPath, convert to ref then try to get CSS selector + if (selector && selectorType === 'xpath') { + try { + // First convert XPath to ref + const ensured = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, + selector, + isXPath: true, + }); + if (!ensured || !ensured.success || !ensured.ref) { + return createErrorResponse( + `Failed to resolve XPath selector: ${ensured?.error || 'unknown error'}`, + ); + } + refForFocus = ensured.ref; + // Try to resolve ref to CSS selector + const resolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: ensured.ref, + }); + if (resolved && resolved.success && resolved.selector) { + finalSelector = resolved.selector; + refForFocus = undefined; // Prefer CSS selector if available + } + // If no CSS selector available, we'll use ref to focus below + } catch (error) { + return createErrorResponse( + `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // If we have a ref but no CSS selector, focus the element via helper + if (refForFocus) { + const focusResult = await this.sendMessageToTab(tab.id, { + action: 'focusByRef', + ref: refForFocus, + }); + if (focusResult && !focusResult.success) { + return createErrorResponse( + `Failed to focus element by ref: ${focusResult.error || 'unknown error'}`, + ); + } + // Clear selector so keyboard events go to the focused element + finalSelector = undefined; + } + + const frameIds = typeof args.frameId === 'number' ? [args.frameId] : undefined; + await this.injectContentScript( + tab.id, + ['inject-scripts/keyboard-helper.js'], + false, + 'ISOLATED', + false, + frameIds, + ); + + // Send keyboard simulation message to content script + const result = await this.sendMessageToTab( + tab.id, + { + action: TOOL_MESSAGE_TYPES.SIMULATE_KEYBOARD, + keys, + selector: finalSelector, + delay, + }, + args.frameId, + ); + + if (result.error) { + return createErrorResponse(result.error); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: result.message || 'Keyboard operation successful', + targetElement: result.targetElement, + results: result.results, + }), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in keyboard operation:', error); + return createErrorResponse( + `Error simulating keyboard events: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const keyboardTool = new KeyboardTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts new file mode 100644 index 0000000..49cb973 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts @@ -0,0 +1,1000 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { NETWORK_FILTERS } from '@/common/constants'; + +interface NetworkDebuggerStartToolParams { + url?: string; // URL to navigate to or focus. If not provided, uses active tab. + maxCaptureTime?: number; + inactivityTimeout?: number; // Inactivity timeout (milliseconds) + includeStatic?: boolean; // if include static resources +} + +// Network request object interface +interface NetworkRequestInfo { + requestId: string; + url: string; + method: string; + requestHeaders?: Record; // Will be removed after common headers extraction + responseHeaders?: Record; // Will be removed after common headers extraction + requestTime?: number; // Timestamp of the request + responseTime?: number; // Timestamp of the response + type: string; // Resource type (e.g., Document, XHR, Fetch, Script, Stylesheet) + status: string; // 'pending', 'complete', 'error' + statusCode?: number; + statusText?: string; + requestBody?: string; + responseBody?: string; + base64Encoded?: boolean; // For responseBody + encodedDataLength?: number; // Actual bytes received + errorText?: string; // If loading failed + canceled?: boolean; // If loading was canceled + mimeType?: string; + specificRequestHeaders?: Record; // Headers unique to this request + specificResponseHeaders?: Record; // Headers unique to this response + [key: string]: any; // Allow other properties from debugger events +} + +const DEBUGGER_PROTOCOL_VERSION = '1.3'; +const MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB +const DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes +const DEFAULT_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute + +/** + * Network capture start tool - uses Chrome Debugger API to start capturing network requests + */ +class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START; + private captureData: Map = new Map(); // tabId -> capture data + private captureTimers: Map = new Map(); // tabId -> max capture timer + private inactivityTimers: Map = new Map(); // tabId -> inactivity timer + private lastActivityTime: Map = new Map(); // tabId -> timestamp of last network activity + private pendingResponseBodies: Map> = new Map(); // requestId -> promise for getResponseBody + private requestCounters: Map = new Map(); // tabId -> count of captured requests (after filtering) + private static MAX_REQUESTS_PER_CAPTURE = 100; // Max requests to store to prevent memory issues + public static instance: NetworkDebuggerStartTool | null = null; + + constructor() { + super(); + if (NetworkDebuggerStartTool.instance) { + return NetworkDebuggerStartTool.instance; + } + NetworkDebuggerStartTool.instance = this; + + chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this)); + chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this)); + chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this)); + chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this)); + } + + private handleTabRemoved(tabId: number) { + if (this.captureData.has(tabId)) { + console.log(`NetworkDebuggerStartTool: Tab ${tabId} was closed, cleaning up resources.`); + this.cleanupCapture(tabId); + } + } + + /** + * Handle tab creation events + * If a new tab is opened from a tab that is currently capturing, automatically start capturing the new tab's requests + */ + private async handleTabCreated(tab: chrome.tabs.Tab) { + try { + // Check if there are any tabs currently capturing + if (this.captureData.size === 0) return; + + // Get the openerTabId of the new tab (ID of the tab that opened this tab) + const openerTabId = tab.openerTabId; + if (!openerTabId) return; + + // Check if the opener tab is currently capturing + if (!this.captureData.has(openerTabId)) return; + + // Get the new tab's ID + const newTabId = tab.id; + if (!newTabId) return; + + console.log( + `NetworkDebuggerStartTool: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`, + ); + + // Get the opener tab's capture settings + const openerCaptureInfo = this.captureData.get(openerTabId); + if (!openerCaptureInfo) return; + + // Wait a short time to ensure the tab is ready + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Start capturing requests for the new tab + await this.startCaptureForTab(newTabId, { + maxCaptureTime: openerCaptureInfo.maxCaptureTime, + inactivityTimeout: openerCaptureInfo.inactivityTimeout, + includeStatic: openerCaptureInfo.includeStatic, + }); + + console.log(`NetworkDebuggerStartTool: Successfully extended capture to new tab ${newTabId}`); + } catch (error) { + console.error(`NetworkDebuggerStartTool: Error extending capture to new tab:`, error); + } + } + + /** + * Start network request capture for specified tab + * @param tabId Tab ID + * @param options Capture options + */ + private async startCaptureForTab( + tabId: number, + options: { + maxCaptureTime: number; + inactivityTimeout: number; + includeStatic: boolean; + }, + ): Promise { + const { maxCaptureTime, inactivityTimeout, includeStatic } = options; + + // If already capturing, stop first + if (this.captureData.has(tabId)) { + console.log( + `NetworkDebuggerStartTool: Already capturing on tab ${tabId}. Stopping previous session.`, + ); + await this.stopCapture(tabId); + } + + try { + // Get tab information + const tab = await chrome.tabs.get(tabId); + + // Attach via shared manager (handles conflicts and refcount) + await cdpSessionManager.attach(tabId, 'network-capture'); + + // Enable network tracking + try { + await cdpSessionManager.sendCommand(tabId, 'Network.enable'); + } catch (error: any) { + await cdpSessionManager + .detach(tabId, 'network-capture') + .catch((e) => console.warn('Error detaching after failed enable:', e)); + throw error; + } + + // Initialize capture data + this.captureData.set(tabId, { + startTime: Date.now(), + tabUrl: tab.url, + tabTitle: tab.title, + maxCaptureTime, + inactivityTimeout, + includeStatic, + requests: {}, + limitReached: false, + }); + + // Initialize request counter + this.requestCounters.set(tabId, 0); + + // Update last activity time + this.updateLastActivityTime(tabId); + + console.log( + `NetworkDebuggerStartTool: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`, + ); + + // Set maximum capture time + if (maxCaptureTime > 0) { + this.captureTimers.set( + tabId, + setTimeout(async () => { + console.log( + `NetworkDebuggerStartTool: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`, + ); + await this.stopCapture(tabId, true); // Auto-stop due to max time + }, maxCaptureTime), + ); + } + } catch (error: any) { + console.error(`NetworkDebuggerStartTool: Error starting capture for tab ${tabId}:`, error); + + // Clean up resources + if (this.captureData.has(tabId)) { + await cdpSessionManager + .detach(tabId, 'network-capture') + .catch((e) => console.warn('Cleanup detach error:', e)); + this.cleanupCapture(tabId); + } + + throw error; + } + } + + private handleDebuggerEvent(source: chrome.debugger.Debuggee, method: string, params?: any) { + if (!source.tabId) return; + + const tabId = source.tabId; + const captureInfo = this.captureData.get(tabId); + + if (!captureInfo) return; // Not capturing for this tab + + // Update last activity time for any relevant network event + this.updateLastActivityTime(tabId); + + switch (method) { + case 'Network.requestWillBeSent': + this.handleRequestWillBeSent(tabId, params); + break; + case 'Network.responseReceived': + this.handleResponseReceived(tabId, params); + break; + case 'Network.loadingFinished': + this.handleLoadingFinished(tabId, params); + break; + case 'Network.loadingFailed': + this.handleLoadingFailed(tabId, params); + break; + } + } + + private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string) { + if (source.tabId && this.captureData.has(source.tabId)) { + console.log( + `NetworkDebuggerStartTool: Debugger detached from tab ${source.tabId}, reason: ${reason}. Cleaning up.`, + ); + // Potentially inform the user or log the result if the detachment was unexpected + this.cleanupCapture(source.tabId); // Ensure cleanup happens + } + } + + private updateLastActivityTime(tabId: number) { + this.lastActivityTime.set(tabId, Date.now()); + const captureInfo = this.captureData.get(tabId); + + if (captureInfo && captureInfo.inactivityTimeout > 0) { + if (this.inactivityTimers.has(tabId)) { + clearTimeout(this.inactivityTimers.get(tabId)!); + } + this.inactivityTimers.set( + tabId, + setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout), + ); + } + } + + private checkInactivity(tabId: number) { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime; // Use startTime if no activity yet + const now = Date.now(); + const inactiveTime = now - lastActivity; + + if (inactiveTime >= captureInfo.inactivityTimeout) { + console.log( + `NetworkDebuggerStartTool: No activity for ${inactiveTime}ms (threshold: ${captureInfo.inactivityTimeout}ms), stopping capture for tab ${tabId}`, + ); + this.stopCaptureByInactivity(tabId); + } else { + // Reschedule check for the remaining time, this handles system sleep or other interruptions + const remainingTime = Math.max(0, captureInfo.inactivityTimeout - inactiveTime); + this.inactivityTimers.set( + tabId, + setTimeout(() => this.checkInactivity(tabId), remainingTime), + ); + } + } + + private async stopCaptureByInactivity(tabId: number) { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + console.log(`NetworkDebuggerStartTool: Stopping capture due to inactivity for tab ${tabId}.`); + // Potentially, we might want to notify the client/user that this happened. + // For now, just stop and make the results available if StopTool is called. + await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop + } + + /** + * Check if URL should be filtered based on EXCLUDED_DOMAINS patterns. + * Uses full URL substring match to support patterns like 'facebook.com/tr'. + */ + private shouldFilterRequestByUrl(url: string): boolean { + const normalizedUrl = String(url || '').toLowerCase(); + if (!normalizedUrl) return false; + return NETWORK_FILTERS.EXCLUDED_DOMAINS.some((pattern) => normalizedUrl.includes(pattern)); + } + + private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean { + if (includeStatic) return false; + + try { + const urlObj = new URL(url); + const path = urlObj.pathname.toLowerCase(); + return NETWORK_FILTERS.STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext)); + } catch { + return false; + } + } + + private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean { + if (!mimeType) return false; + + // Never filter API MIME types + if (NETWORK_FILTERS.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) { + return false; + } + + // Filter static MIME types when not including static resources + if (!includeStatic) { + return NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => + mimeType.startsWith(staticMime), + ); + } + + return false; + } + + private handleRequestWillBeSent(tabId: number, params: any) { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + const { requestId, request, timestamp, type, loaderId, frameId } = params; + + // Initial filtering by URL (ads, analytics) and extension (if !includeStatic) + if ( + this.shouldFilterRequestByUrl(request.url) || + this.shouldFilterRequestByExtension(request.url, captureInfo.includeStatic) + ) { + return; + } + + const currentCount = this.requestCounters.get(tabId) || 0; + if (currentCount >= NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE) { + // console.log(`NetworkDebuggerStartTool: Request limit (${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${tabId}. Ignoring: ${request.url}`); + captureInfo.limitReached = true; // Mark that limit was hit + return; + } + + // Store initial request info + // Ensure we don't overwrite if a redirect (same requestId) occurred, though usually loaderId changes + if (!captureInfo.requests[requestId]) { + // Or check based on loaderId as well if needed + captureInfo.requests[requestId] = { + requestId, + url: request.url, + method: request.method, + requestHeaders: request.headers, // Temporary, will be processed + requestTime: timestamp * 1000, // Convert seconds to milliseconds + type: type || 'Other', + status: 'pending', // Initial status + loaderId, // Useful for tracking redirects + frameId, // Useful for context + }; + + if (request.postData) { + captureInfo.requests[requestId].requestBody = request.postData; + } + // console.log(`NetworkDebuggerStartTool: Captured request for tab ${tabId}: ${request.method} ${request.url}`); + } else { + // This could be a redirect. Update URL and other relevant fields. + // Chrome often issues a new `requestWillBeSent` for redirects with the same `requestId` but a new `loaderId`. + // console.log(`NetworkDebuggerStartTool: Request ${requestId} updated (likely redirect) for tab ${tabId} to URL: ${request.url}`); + const existingRequest = captureInfo.requests[requestId]; + existingRequest.url = request.url; // Update URL due to redirect + existingRequest.requestTime = timestamp * 1000; // Update time for the redirected request + if (request.headers) existingRequest.requestHeaders = request.headers; + if (request.postData) existingRequest.requestBody = request.postData; + else delete existingRequest.requestBody; + } + } + + private handleResponseReceived(tabId: number, params: any) { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + const { requestId, response, timestamp, type } = params; // type here is resource type + const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId]; + + if (!requestInfo) { + // console.warn(`NetworkDebuggerStartTool: Received response for unknown requestId ${requestId} on tab ${tabId}`); + return; + } + + // Secondary filtering based on MIME type, now that we have it + if (this.shouldFilterByMimeType(response.mimeType, captureInfo.includeStatic)) { + // console.log(`NetworkDebuggerStartTool: Filtering request by MIME type (${response.mimeType}): ${requestInfo.url}`); + delete captureInfo.requests[requestId]; // Remove from captured data + // Note: We don't decrement requestCounter here as it's meant to track how many *potential* requests were processed up to MAX_REQUESTS. + // Or, if MAX_REQUESTS is strictly for *stored* requests, then decrement. For now, let's assume it's for stored. + // const currentCount = this.requestCounters.get(tabId) || 0; + // if (currentCount > 0) this.requestCounters.set(tabId, currentCount -1); + return; + } + + // If not filtered by MIME, then increment actual stored request counter + const currentStoredCount = Object.keys(captureInfo.requests).length; // A bit inefficient but accurate + this.requestCounters.set(tabId, currentStoredCount); + + requestInfo.status = response.status === 0 ? 'pending' : 'complete'; // status 0 can mean pending or blocked + requestInfo.statusCode = response.status; + requestInfo.statusText = response.statusText; + requestInfo.responseHeaders = response.headers; // Temporary + requestInfo.mimeType = response.mimeType; + requestInfo.responseTime = timestamp * 1000; // Convert seconds to milliseconds + if (type) requestInfo.type = type; // Update resource type if provided by this event + + // console.log(`NetworkDebuggerStartTool: Received response for ${requestId} on tab ${tabId}: ${response.status}`); + } + + private async handleLoadingFinished(tabId: number, params: any) { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + const { requestId, encodedDataLength } = params; + const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId]; + + if (!requestInfo) { + // console.warn(`NetworkDebuggerStartTool: LoadingFinished for unknown requestId ${requestId} on tab ${tabId}`); + return; + } + + requestInfo.encodedDataLength = encodedDataLength; + if (requestInfo.status === 'pending') requestInfo.status = 'complete'; // Mark as complete if not already + // requestInfo.responseTime is usually set by responseReceived, but this timestamp is later. + // timestamp here is when the resource finished loading. Could be useful for duration calculation. + + if (this.shouldCaptureResponseBody(requestInfo)) { + try { + // console.log(`NetworkDebuggerStartTool: Attempting to get response body for ${requestId} (${requestInfo.url})`); + const responseBodyData = await this.getResponseBody(tabId, requestId); + if (responseBodyData) { + if ( + responseBodyData.body && + responseBodyData.body.length > MAX_RESPONSE_BODY_SIZE_BYTES + ) { + requestInfo.responseBody = + responseBodyData.body.substring(0, MAX_RESPONSE_BODY_SIZE_BYTES) + + `\n\n... [Response truncated, total size: ${responseBodyData.body.length} bytes] ...`; + } else { + requestInfo.responseBody = responseBodyData.body; + } + requestInfo.base64Encoded = responseBodyData.base64Encoded; + // console.log(`NetworkDebuggerStartTool: Successfully got response body for ${requestId}, size: ${requestInfo.responseBody?.length || 0} bytes`); + } + } catch (error) { + // console.warn(`NetworkDebuggerStartTool: Failed to get response body for ${requestId}:`, error); + requestInfo.errorText = + (requestInfo.errorText || '') + + ` Failed to get body: ${error instanceof Error ? error.message : String(error)}`; + } + } + } + + private shouldCaptureResponseBody(requestInfo: NetworkRequestInfo): boolean { + const mimeType = requestInfo.mimeType || ''; + + // Prioritize API MIME types for body capture + if (NETWORK_FILTERS.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) { + return true; + } + + // Heuristics for other potential API calls not perfectly matching MIME types + const url = requestInfo.url.toLowerCase(); + if ( + /\/(api|service|rest|graphql|query|data|rpc|v[0-9]+)\//i.test(url) || + url.includes('.json') || + url.includes('json=') || + url.includes('format=json') + ) { + // If it looks like an API call by URL structure, try to get body, + // unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path) + if ( + mimeType && + NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => + mimeType.startsWith(staticMime), + ) + ) { + return false; // e.g. a CSS file served from an /api/ path + } + return true; + } + + return false; + } + + private handleLoadingFailed(tabId: number, params: any) { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + const { requestId, errorText, canceled, type } = params; + const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId]; + + if (!requestInfo) { + // console.warn(`NetworkDebuggerStartTool: LoadingFailed for unknown requestId ${requestId} on tab ${tabId}`); + return; + } + + requestInfo.status = 'error'; + requestInfo.errorText = errorText; + requestInfo.canceled = canceled; + if (type) requestInfo.type = type; + // timestamp here is when loading failed. + // console.log(`NetworkDebuggerStartTool: Loading failed for ${requestId} on tab ${tabId}: ${errorText}`); + } + + private async getResponseBody( + tabId: number, + requestId: string, + ): Promise<{ body: string; base64Encoded: boolean } | null> { + const pendingKey = `${tabId}_${requestId}`; + if (this.pendingResponseBodies.has(pendingKey)) { + return this.pendingResponseBodies.get(pendingKey)!; // Return existing promise + } + + const responseBodyPromise = (async () => { + try { + // Will attach temporarily if needed + const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', { + requestId, + })) as { body: string; base64Encoded: boolean }; + return result; + } finally { + this.pendingResponseBodies.delete(pendingKey); // Clean up after promise resolves or rejects + } + })(); + + this.pendingResponseBodies.set(pendingKey, responseBodyPromise); + return responseBodyPromise; + } + + private cleanupCapture(tabId: number) { + if (this.captureTimers.has(tabId)) { + clearTimeout(this.captureTimers.get(tabId)!); + this.captureTimers.delete(tabId); + } + if (this.inactivityTimers.has(tabId)) { + clearTimeout(this.inactivityTimers.get(tabId)!); + this.inactivityTimers.delete(tabId); + } + + this.lastActivityTime.delete(tabId); + this.captureData.delete(tabId); + this.requestCounters.delete(tabId); + + // Abort pending getResponseBody calls for this tab + // Note: Promises themselves cannot be "aborted" externally in a standard way once created. + // We can delete them from the map, so new calls won't use them, + // and the original promise will eventually resolve or reject. + const keysToDelete: string[] = []; + this.pendingResponseBodies.forEach((_, key) => { + if (key.startsWith(`${tabId}_`)) { + keysToDelete.push(key); + } + }); + keysToDelete.forEach((key) => this.pendingResponseBodies.delete(key)); + + console.log(`NetworkDebuggerStartTool: Cleaned up resources for tab ${tabId}.`); + } + + // isAutoStop is true if stop was triggered by timeout, false if by user/explicit call + async stopCapture(tabId: number, isAutoStop: boolean = false): Promise { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) { + return { success: false, message: 'No capture in progress for this tab.' }; + } + + console.log( + `NetworkDebuggerStartTool: Stopping capture for tab ${tabId}. Auto-stop: ${isAutoStop}`, + ); + + try { + // Attempt to disable network and detach via manager; it will no-op if others own the session + try { + await cdpSessionManager.sendCommand(tabId, 'Network.disable'); + } catch (e) { + console.warn( + `NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`, + e, + ); + } + try { + await cdpSessionManager.detach(tabId, 'network-capture'); + } catch (e) { + console.warn( + `NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`, + e, + ); + } + } catch (error: any) { + // Catch errors from getTargets or general logic + console.error( + 'NetworkDebuggerStartTool: Error during debugger interaction in stopCapture:', + error, + ); + // Proceed to cleanup and data formatting + } + + // Process data even if detach/disable failed, as some data might have been captured. + const allRequests = Object.values(captureInfo.requests) as NetworkRequestInfo[]; + const commonRequestHeaders = this.analyzeCommonHeaders(allRequests, 'requestHeaders'); + const commonResponseHeaders = this.analyzeCommonHeaders(allRequests, 'responseHeaders'); + + const processedRequests = allRequests.map((req) => { + const finalReq: Partial & + Pick = { ...req }; + + if (finalReq.requestHeaders) { + finalReq.specificRequestHeaders = this.filterOutCommonHeaders( + finalReq.requestHeaders, + commonRequestHeaders, + ); + delete finalReq.requestHeaders; // Remove original full headers + } else { + finalReq.specificRequestHeaders = {}; + } + + if (finalReq.responseHeaders) { + finalReq.specificResponseHeaders = this.filterOutCommonHeaders( + finalReq.responseHeaders, + commonResponseHeaders, + ); + delete finalReq.responseHeaders; // Remove original full headers + } else { + finalReq.specificResponseHeaders = {}; + } + return finalReq as NetworkRequestInfo; // Cast back to full type + }); + + // Sort requests by requestTime + processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0)); + + const resultData = { + captureStartTime: captureInfo.startTime, + captureEndTime: Date.now(), + totalDurationMs: Date.now() - captureInfo.startTime, + commonRequestHeaders, + commonResponseHeaders, + requests: processedRequests, + requestCount: processedRequests.length, // Actual stored requests + totalRequestsReceivedBeforeLimit: captureInfo.limitReached + ? NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE + : processedRequests.length, + requestLimitReached: !!captureInfo.limitReached, + stoppedBy: isAutoStop + ? this.lastActivityTime.get(tabId) + ? 'inactivity_timeout' + : 'max_capture_time' + : 'user_request', + tabUrl: captureInfo.tabUrl, + tabTitle: captureInfo.tabTitle, + }; + + console.log( + `NetworkDebuggerStartTool: Capture stopped for tab ${tabId}. ${resultData.requestCount} requests processed. Limit reached: ${resultData.requestLimitReached}. Stopped by: ${resultData.stoppedBy}`, + ); + + this.cleanupCapture(tabId); // Final cleanup of all internal states for this tab + + return { + success: true, + message: `Capture stopped. ${resultData.requestCount} requests.`, + data: resultData, + }; + } + + private analyzeCommonHeaders( + requests: NetworkRequestInfo[], + headerTypeKey: 'requestHeaders' | 'responseHeaders', + ): Record { + if (!requests || requests.length === 0) return {}; + + const headerValueCounts = new Map>(); // headerName -> (headerValue -> count) + let requestsWithHeadersCount = 0; + + for (const req of requests) { + const headers = req[headerTypeKey] as Record | undefined; + if (headers && Object.keys(headers).length > 0) { + requestsWithHeadersCount++; + for (const name in headers) { + // Normalize header name to lowercase for consistent counting + const lowerName = name.toLowerCase(); + const value = headers[name]; + if (!headerValueCounts.has(lowerName)) { + headerValueCounts.set(lowerName, new Map()); + } + const values = headerValueCounts.get(lowerName)!; + values.set(value, (values.get(value) || 0) + 1); + } + } + } + + if (requestsWithHeadersCount === 0) return {}; + + const commonHeaders: Record = {}; + headerValueCounts.forEach((values, name) => { + values.forEach((count, value) => { + if (count === requestsWithHeadersCount) { + // This (name, value) pair is present in all requests that have this type of headers. + // We need to find the original casing for the header name. + // This is tricky as HTTP headers are case-insensitive. Let's pick the first encountered one. + // A more robust way would be to store original names, but lowercase comparison is standard. + // For simplicity, we'll use the lowercase name for commonHeaders keys. + // Or, find one original casing: + let originalName = name; + for (const req of requests) { + const hdrs = req[headerTypeKey] as Record | undefined; + if (hdrs) { + const foundName = Object.keys(hdrs).find((k) => k.toLowerCase() === name); + if (foundName) { + originalName = foundName; + break; + } + } + } + commonHeaders[originalName] = value; + } + }); + }); + return commonHeaders; + } + + private filterOutCommonHeaders( + headers: Record, + commonHeaders: Record, + ): Record { + if (!headers || typeof headers !== 'object') return {}; + + const specificHeaders: Record = {}; + const commonHeadersLower: Record = {}; + + // Use Object.keys to avoid ESLint no-prototype-builtins warning + Object.keys(commonHeaders).forEach((commonName) => { + commonHeadersLower[commonName.toLowerCase()] = commonHeaders[commonName]; + }); + + // Use Object.keys to avoid ESLint no-prototype-builtins warning + Object.keys(headers).forEach((name) => { + const lowerName = name.toLowerCase(); + // If the header (by name, case-insensitively) is not in commonHeaders OR + // if its value is different from the common one, then it's specific. + if (!(lowerName in commonHeadersLower) || headers[name] !== commonHeadersLower[lowerName]) { + specificHeaders[name] = headers[name]; + } + }); + + return specificHeaders; + } + + async execute(args: NetworkDebuggerStartToolParams): Promise { + const { + url: targetUrl, + maxCaptureTime = DEFAULT_MAX_CAPTURE_TIME_MS, + inactivityTimeout = DEFAULT_INACTIVITY_TIMEOUT_MS, + includeStatic = false, + } = args; + + console.log( + `NetworkDebuggerStartTool: Executing with args: url=${targetUrl}, maxTime=${maxCaptureTime}, inactivityTime=${inactivityTimeout}, includeStatic=${includeStatic}`, + ); + + let tabToOperateOn: chrome.tabs.Tab | undefined; + + try { + if (targetUrl) { + const existingTabs = await chrome.tabs.query({ + url: targetUrl.startsWith('http') ? targetUrl : `*://*/*${targetUrl}*`, + }); // More specific query + if (existingTabs.length > 0 && existingTabs[0]?.id) { + tabToOperateOn = existingTabs[0]; + // Ensure window gets focus and tab is truly activated + await chrome.windows.update(tabToOperateOn.windowId, { focused: true }); + await chrome.tabs.update(tabToOperateOn.id!, { active: true }); + } else { + tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true }); + // Wait for tab to be somewhat ready. A better way is to listen to tabs.onUpdated status='complete' + // but for debugger attachment, it just needs the tabId. + await new Promise((resolve) => setTimeout(resolve, 500)); // Short delay + } + } else { + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (activeTabs.length > 0 && activeTabs[0]?.id) { + tabToOperateOn = activeTabs[0]; + } else { + return createErrorResponse('No active tab found and no URL provided.'); + } + } + + if (!tabToOperateOn?.id) { + return createErrorResponse('Failed to identify or create a target tab.'); + } + const tabId = tabToOperateOn.id; + + // Use startCaptureForTab method to start capture + try { + await this.startCaptureForTab(tabId, { + maxCaptureTime, + inactivityTimeout, + includeStatic, + }); + } catch (error: any) { + return createErrorResponse( + `Failed to start capture for tab ${tabId}: ${error.message || String(error)}`, + ); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Network capture started on tab ${tabId}. Waiting for stop command or timeout.`, + tabId, + url: tabToOperateOn.url, + maxCaptureTime, + inactivityTimeout, + includeStatic, + maxRequests: NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE, + }), + }, + ], + isError: false, + }; + } catch (error: any) { + console.error('NetworkDebuggerStartTool: Critical error during execute:', error); + // If a tabId was involved and debugger might be attached, try to clean up. + const tabIdToClean = tabToOperateOn?.id; + if (tabIdToClean && this.captureData.has(tabIdToClean)) { + await cdpSessionManager + .detach(tabIdToClean, 'network-capture') + .catch((e) => console.warn('Cleanup detach error:', e)); + this.cleanupCapture(tabIdToClean); + } + return createErrorResponse( + `Error in NetworkDebuggerStartTool: ${error.message || String(error)}`, + ); + } + } +} + +/** + * Network capture stop tool - stops capture and returns results for the active tab + */ +class NetworkDebuggerStopTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP; + public static instance: NetworkDebuggerStopTool | null = null; + + constructor() { + super(); + if (NetworkDebuggerStopTool.instance) { + return NetworkDebuggerStopTool.instance; + } + NetworkDebuggerStopTool.instance = this; + } + + async execute(): Promise { + console.log(`NetworkDebuggerStopTool: Executing command.`); + + const startTool = NetworkDebuggerStartTool.instance; + if (!startTool) { + return createErrorResponse( + 'NetworkDebuggerStartTool instance not available. Cannot stop capture.', + ); + } + + // Get all tabs currently capturing + const ongoingCaptures = Array.from(startTool['captureData'].keys()); + console.log( + `NetworkDebuggerStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`, + ); + + if (ongoingCaptures.length === 0) { + return createErrorResponse('No active network captures found in any tab.'); + } + + // Get current active tab + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const activeTabId = activeTabs[0]?.id; + + // Determine the primary tab to stop + let primaryTabId: number; + + if (activeTabId && startTool['captureData'].has(activeTabId)) { + // If current active tab is capturing, prioritize stopping it + primaryTabId = activeTabId; + console.log( + `NetworkDebuggerStopTool: Active tab ${activeTabId} is capturing, will stop it first.`, + ); + } else if (ongoingCaptures.length === 1) { + // If only one tab is capturing, stop it + primaryTabId = ongoingCaptures[0]; + console.log( + `NetworkDebuggerStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`, + ); + } else { + // If multiple tabs are capturing but current active tab is not among them, stop the first one + primaryTabId = ongoingCaptures[0]; + console.log( + `NetworkDebuggerStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`, + ); + } + + // Stop capture for the primary tab + const result = await this.performStop(startTool, primaryTabId); + + // If multiple tabs are capturing, stop other tabs + if (ongoingCaptures.length > 1) { + const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId); + console.log( + `NetworkDebuggerStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`, + ); + + for (const tabId of otherTabIds) { + try { + await startTool.stopCapture(tabId); + } catch (error) { + console.error(`NetworkDebuggerStopTool: Error stopping capture on tab ${tabId}:`, error); + } + } + } + + return result; + } + + private async performStop( + startTool: NetworkDebuggerStartTool, + tabId: number, + ): Promise { + console.log(`NetworkDebuggerStopTool: Attempting to stop capture for tab ${tabId}.`); + const stopResult = await startTool.stopCapture(tabId); + + if (!stopResult?.success) { + return createErrorResponse( + stopResult?.message || + `Failed to stop network capture for tab ${tabId}. It might not have been capturing.`, + ); + } + + const resultData = stopResult.data || {}; + + // Get all tabs still capturing (there might be other tabs still capturing after stopping) + const remainingCaptures = Array.from(startTool['captureData'].keys()); + + // Sort requests by time + if (resultData.requests && Array.isArray(resultData.requests)) { + resultData.requests.sort( + (a: NetworkRequestInfo, b: NetworkRequestInfo) => + (a.requestTime || 0) - (b.requestTime || 0), + ); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Capture for tab ${tabId} (${resultData.tabUrl || 'N/A'}) stopped. ${resultData.requestCount || 0} requests captured.`, + tabId: tabId, + tabUrl: resultData.tabUrl || 'N/A', + tabTitle: resultData.tabTitle || 'Unknown Tab', + requestCount: resultData.requestCount || 0, + commonRequestHeaders: resultData.commonRequestHeaders || {}, + commonResponseHeaders: resultData.commonResponseHeaders || {}, + requests: resultData.requests || [], + captureStartTime: resultData.captureStartTime, + captureEndTime: resultData.captureEndTime, + totalDurationMs: resultData.totalDurationMs, + settingsUsed: resultData.settingsUsed || {}, + remainingCaptures: remainingCaptures, + totalRequestsReceived: resultData.totalRequestsReceived || resultData.requestCount || 0, + requestLimitReached: resultData.requestLimitReached || false, + }), + }, + ], + isError: false, + }; + } +} + +export const networkDebuggerStartTool = new NetworkDebuggerStartTool(); +export const networkDebuggerStopTool = new NetworkDebuggerStopTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts new file mode 100644 index 0000000..462808b --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts @@ -0,0 +1,993 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { LIMITS, NETWORK_FILTERS } from '@/common/constants'; + +// Static resource file extensions +const STATIC_RESOURCE_EXTENSIONS = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.svg', + '.webp', + '.ico', + '.bmp', // Images + '.css', + '.scss', + '.less', // Styles + '.js', + '.jsx', + '.ts', + '.tsx', // Scripts + '.woff', + '.woff2', + '.ttf', + '.eot', + '.otf', // Fonts + '.mp3', + '.mp4', + '.avi', + '.mov', + '.wmv', + '.flv', + '.ogg', + '.wav', // Media + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', // Documents +]; + +// Ad and analytics domain list +const AD_ANALYTICS_DOMAINS = NETWORK_FILTERS.EXCLUDED_DOMAINS; + +interface NetworkCaptureStartToolParams { + url?: string; // URL to navigate to or focus. If not provided, uses active tab. + maxCaptureTime?: number; // Maximum capture time (milliseconds) + inactivityTimeout?: number; // Inactivity timeout (milliseconds) + includeStatic?: boolean; // Whether to include static resources +} + +interface NetworkRequestInfo { + requestId: string; + url: string; + method: string; + type: string; + requestTime: number; + requestHeaders?: Record; + requestBody?: string; + responseHeaders?: Record; + responseTime?: number; + status?: number; + statusText?: string; + responseSize?: number; + responseType?: string; + responseBody?: string; + errorText?: string; + specificRequestHeaders?: Record; + specificResponseHeaders?: Record; + mimeType?: string; // Response MIME type +} + +interface CaptureInfo { + tabId: number; + tabUrl: string; + tabTitle: string; + startTime: number; + endTime?: number; + requests: Record; + maxCaptureTime: number; + inactivityTimeout: number; + includeStatic: boolean; + limitReached?: boolean; // Whether request count limit is reached +} + +/** + * Network Capture Start Tool V2 - Uses Chrome webRequest API to start capturing network requests + */ +class NetworkCaptureStartTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START; + public static instance: NetworkCaptureStartTool | null = null; + public captureData: Map = new Map(); // tabId -> capture data + private captureTimers: Map = new Map(); // tabId -> max capture timer + private inactivityTimers: Map = new Map(); // tabId -> inactivity timer + private lastActivityTime: Map = new Map(); // tabId -> timestamp of last activity + private requestCounters: Map = new Map(); // tabId -> count of captured requests + public static MAX_REQUESTS_PER_CAPTURE = LIMITS.MAX_NETWORK_REQUESTS; // Maximum capture request count + private listeners: { [key: string]: (details: any) => void } = {}; + + // Static resource MIME types list (for filtering) + private static STATIC_MIME_TYPES_TO_FILTER = [ + 'image/', // All image types + 'font/', // All font types + 'audio/', // All audio types + 'video/', // All video types + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/x-javascript', + 'application/pdf', + 'application/zip', + 'application/octet-stream', // Usually for downloads or generic binary data + ]; + + // API response MIME types list (these types are usually not filtered) + private static API_MIME_TYPES = [ + 'application/json', + 'application/xml', + 'text/xml', + 'application/x-www-form-urlencoded', + 'application/graphql', + 'application/grpc', + 'application/protobuf', + 'application/x-protobuf', + 'application/x-json', + 'application/ld+json', + 'application/problem+json', + 'application/problem+xml', + 'application/soap+xml', + 'application/vnd.api+json', + ]; + + constructor() { + super(); + if (NetworkCaptureStartTool.instance) { + return NetworkCaptureStartTool.instance; + } + NetworkCaptureStartTool.instance = this; + + // Listen for tab close events + chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this)); + // Listen for tab creation events + chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this)); + } + + /** + * Handle tab close events + */ + private handleTabRemoved(tabId: number) { + if (this.captureData.has(tabId)) { + console.log(`NetworkCaptureV2: Tab ${tabId} was closed, cleaning up resources.`); + this.cleanupCapture(tabId); + } + } + + /** + * Handle tab creation events + * If a new tab is opened from a tab being captured, automatically start capturing the new tab's requests + */ + private async handleTabCreated(tab: chrome.tabs.Tab) { + try { + // Check if there are any tabs currently capturing + if (this.captureData.size === 0) return; + + // Get the openerTabId of the new tab (ID of the tab that opened this tab) + const openerTabId = tab.openerTabId; + if (!openerTabId) return; + + // Check if the opener tab is currently capturing + if (!this.captureData.has(openerTabId)) return; + + // Get the new tab's ID + const newTabId = tab.id; + if (!newTabId) return; + + console.log( + `NetworkCaptureV2: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`, + ); + + // Get the opener tab's capture settings + const openerCaptureInfo = this.captureData.get(openerTabId); + if (!openerCaptureInfo) return; + + // Wait a short time to ensure the tab is ready + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Start capturing requests for the new tab + await this.startCaptureForTab(newTabId, { + maxCaptureTime: openerCaptureInfo.maxCaptureTime, + inactivityTimeout: openerCaptureInfo.inactivityTimeout, + includeStatic: openerCaptureInfo.includeStatic, + }); + + console.log(`NetworkCaptureV2: Successfully extended capture to new tab ${newTabId}`); + } catch (error) { + console.error(`NetworkCaptureV2: Error extending capture to new tab:`, error); + } + } + + /** + * Determine whether a request should be filtered (based on URL) + * Uses full URL substring match to support patterns like 'facebook.com/tr' + */ + private shouldFilterRequest(url: string, includeStatic: boolean): boolean { + const normalizedUrl = String(url || '').toLowerCase(); + if (!normalizedUrl) return false; + + // Check if it's an ad or analytics domain (full URL substring match) + if (AD_ANALYTICS_DOMAINS.some((pattern) => normalizedUrl.includes(pattern))) { + return true; + } + + // If not including static resources, check extensions + if (!includeStatic) { + try { + const urlObj = new URL(url); + const path = urlObj.pathname.toLowerCase(); + if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) { + return true; + } + } catch { + return false; + } + } + + return false; + } + + /** + * Filter based on MIME type + */ + private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean { + if (!mimeType) return false; + + // Always keep API response types + if (NetworkCaptureStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) { + return false; + } + + // If not including static resources, filter out static resource MIME types + if (!includeStatic) { + // Filter static resource MIME types + if ( + NetworkCaptureStartTool.STATIC_MIME_TYPES_TO_FILTER.some((type) => + mimeType.startsWith(type), + ) + ) { + console.log(`NetworkCaptureV2: Filtering static resource by MIME type: ${mimeType}`); + return true; + } + + // Filter all MIME types starting with text/ (except those already in API_MIME_TYPES) + if (mimeType.startsWith('text/')) { + console.log(`NetworkCaptureV2: Filtering text response: ${mimeType}`); + return true; + } + } + + return false; + } + + /** + * Update last activity time and reset inactivity timer + */ + private updateLastActivityTime(tabId: number): void { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + this.lastActivityTime.set(tabId, Date.now()); + + // Reset inactivity timer + if (this.inactivityTimers.has(tabId)) { + clearTimeout(this.inactivityTimers.get(tabId)!); + } + + if (captureInfo.inactivityTimeout > 0) { + this.inactivityTimers.set( + tabId, + setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout), + ); + } + } + + /** + * Check for inactivity + */ + private checkInactivity(tabId: number): void { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime; + const now = Date.now(); + const inactiveTime = now - lastActivity; + + if (inactiveTime >= captureInfo.inactivityTimeout) { + console.log( + `NetworkCaptureV2: No activity for ${inactiveTime}ms, stopping capture for tab ${tabId}`, + ); + this.stopCaptureByInactivity(tabId); + } else { + // If inactivity time hasn't been reached yet, continue checking + const remainingTime = captureInfo.inactivityTimeout - inactiveTime; + this.inactivityTimers.set( + tabId, + setTimeout(() => this.checkInactivity(tabId), remainingTime), + ); + } + } + + /** + * Stop capture due to inactivity + */ + private async stopCaptureByInactivity(tabId: number): Promise { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) return; + + console.log(`NetworkCaptureV2: Stopping capture due to inactivity for tab ${tabId}`); + await this.stopCapture(tabId); + } + + /** + * Clean up capture resources + */ + private cleanupCapture(tabId: number): void { + // Clear timers + if (this.captureTimers.has(tabId)) { + clearTimeout(this.captureTimers.get(tabId)!); + this.captureTimers.delete(tabId); + } + + if (this.inactivityTimers.has(tabId)) { + clearTimeout(this.inactivityTimers.get(tabId)!); + this.inactivityTimers.delete(tabId); + } + + // Remove data + this.lastActivityTime.delete(tabId); + this.captureData.delete(tabId); + this.requestCounters.delete(tabId); + + console.log(`NetworkCaptureV2: Cleaned up all resources for tab ${tabId}`); + } + + /** + * Set up request listeners (idempotent - won't add duplicate listeners) + */ + private setupListeners(): void { + // Skip if listeners are already set up + if (this.listeners.onBeforeRequest) { + return; + } + + // Before request is sent + this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => { + const captureInfo = this.captureData.get(details.tabId); + if (!captureInfo) return; + + if (this.shouldFilterRequest(details.url, captureInfo.includeStatic)) { + return; + } + + const currentCount = this.requestCounters.get(details.tabId) || 0; + if (currentCount >= NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE) { + console.log( + `NetworkCaptureV2: Request limit (${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${details.tabId}, ignoring new request: ${details.url}`, + ); + captureInfo.limitReached = true; + return; + } + + this.requestCounters.set(details.tabId, currentCount + 1); + this.updateLastActivityTime(details.tabId); + + if (!captureInfo.requests[details.requestId]) { + captureInfo.requests[details.requestId] = { + requestId: details.requestId, + url: details.url, + method: details.method, + type: details.type, + requestTime: details.timeStamp, + }; + + if (details.requestBody) { + const requestBody = this.processRequestBody(details.requestBody); + if (requestBody) { + captureInfo.requests[details.requestId].requestBody = requestBody; + } + } + + console.log( + `NetworkCaptureV2: Captured request ${currentCount + 1}/${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE} for tab ${details.tabId}: ${details.method} ${details.url}`, + ); + } + }; + + // Send request headers + this.listeners.onSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => { + const captureInfo = this.captureData.get(details.tabId); + if (!captureInfo || !captureInfo.requests[details.requestId]) return; + + if (details.requestHeaders) { + const headers: Record = {}; + details.requestHeaders.forEach((header) => { + headers[header.name] = header.value || ''; + }); + captureInfo.requests[details.requestId].requestHeaders = headers; + } + }; + + // Receive response headers + this.listeners.onHeadersReceived = (details: chrome.webRequest.WebResponseHeadersDetails) => { + const captureInfo = this.captureData.get(details.tabId); + if (!captureInfo || !captureInfo.requests[details.requestId]) return; + + const requestInfo = captureInfo.requests[details.requestId]; + + requestInfo.status = details.statusCode; + requestInfo.statusText = details.statusLine; + requestInfo.responseTime = details.timeStamp; + requestInfo.mimeType = details.responseHeaders?.find( + (h) => h.name.toLowerCase() === 'content-type', + )?.value; + + // Secondary filtering based on MIME type + if ( + requestInfo.mimeType && + this.shouldFilterByMimeType(requestInfo.mimeType, captureInfo.includeStatic) + ) { + delete captureInfo.requests[details.requestId]; + + const currentCount = this.requestCounters.get(details.tabId) || 0; + if (currentCount > 0) { + this.requestCounters.set(details.tabId, currentCount - 1); + } + + console.log( + `NetworkCaptureV2: Filtered request by MIME type (${requestInfo.mimeType}): ${requestInfo.url}`, + ); + return; + } + + if (details.responseHeaders) { + const headers: Record = {}; + details.responseHeaders.forEach((header) => { + headers[header.name] = header.value || ''; + }); + requestInfo.responseHeaders = headers; + } + + this.updateLastActivityTime(details.tabId); + }; + + // Request completed + this.listeners.onCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => { + const captureInfo = this.captureData.get(details.tabId); + if (!captureInfo || !captureInfo.requests[details.requestId]) return; + + const requestInfo = captureInfo.requests[details.requestId]; + if ('responseSize' in details) { + requestInfo.responseSize = details.fromCache ? 0 : (details as any).responseSize; + } + + this.updateLastActivityTime(details.tabId); + }; + + // Request failed + this.listeners.onErrorOccurred = (details: chrome.webRequest.WebResponseErrorDetails) => { + const captureInfo = this.captureData.get(details.tabId); + if (!captureInfo || !captureInfo.requests[details.requestId]) return; + + const requestInfo = captureInfo.requests[details.requestId]; + requestInfo.errorText = details.error; + + this.updateLastActivityTime(details.tabId); + }; + + // Register all listeners + chrome.webRequest.onBeforeRequest.addListener( + this.listeners.onBeforeRequest, + { urls: [''] }, + ['requestBody'], + ); + + chrome.webRequest.onSendHeaders.addListener( + this.listeners.onSendHeaders, + { urls: [''] }, + ['requestHeaders'], + ); + + chrome.webRequest.onHeadersReceived.addListener( + this.listeners.onHeadersReceived, + { urls: [''] }, + ['responseHeaders'], + ); + + chrome.webRequest.onCompleted.addListener(this.listeners.onCompleted, { urls: [''] }); + + chrome.webRequest.onErrorOccurred.addListener(this.listeners.onErrorOccurred, { + urls: [''], + }); + } + + /** + * Remove all request listeners + * Only remove listeners when all tab captures have stopped + */ + private removeListeners(): void { + // Don't remove listeners if there are still tabs being captured + if (this.captureData.size > 0) { + console.log( + `NetworkCaptureV2: Still capturing on ${this.captureData.size} tabs, not removing listeners.`, + ); + return; + } + + console.log(`NetworkCaptureV2: No more active captures, removing all listeners.`); + + if (this.listeners.onBeforeRequest) { + chrome.webRequest.onBeforeRequest.removeListener(this.listeners.onBeforeRequest); + } + + if (this.listeners.onSendHeaders) { + chrome.webRequest.onSendHeaders.removeListener(this.listeners.onSendHeaders); + } + + if (this.listeners.onHeadersReceived) { + chrome.webRequest.onHeadersReceived.removeListener(this.listeners.onHeadersReceived); + } + + if (this.listeners.onCompleted) { + chrome.webRequest.onCompleted.removeListener(this.listeners.onCompleted); + } + + if (this.listeners.onErrorOccurred) { + chrome.webRequest.onErrorOccurred.removeListener(this.listeners.onErrorOccurred); + } + + // Clear listener object + this.listeners = {}; + } + + /** + * Process request body data + */ + private processRequestBody(requestBody: chrome.webRequest.WebRequestBody): string | undefined { + if (requestBody.raw && requestBody.raw.length > 0) { + return '[Binary data]'; + } else if (requestBody.formData) { + return JSON.stringify(requestBody.formData); + } + return undefined; + } + + /** + * Start network request capture for specified tab + * @param tabId Tab ID + * @param options Capture options + */ + private async startCaptureForTab( + tabId: number, + options: { + maxCaptureTime: number; + inactivityTimeout: number; + includeStatic: boolean; + }, + ): Promise { + const { maxCaptureTime, inactivityTimeout, includeStatic } = options; + + // If already capturing, stop first + if (this.captureData.has(tabId)) { + console.log( + `NetworkCaptureV2: Already capturing on tab ${tabId}. Stopping previous session.`, + ); + await this.stopCapture(tabId); + } + + try { + // Get tab information + const tab = await chrome.tabs.get(tabId); + + // Initialize capture data + this.captureData.set(tabId, { + tabId: tabId, + tabUrl: tab.url || '', + tabTitle: tab.title || '', + startTime: Date.now(), + requests: {}, + maxCaptureTime, + inactivityTimeout, + includeStatic, + limitReached: false, + }); + + // Initialize request counter + this.requestCounters.set(tabId, 0); + + // Set up listeners + this.setupListeners(); + + // Update last activity time + this.updateLastActivityTime(tabId); + + console.log( + `NetworkCaptureV2: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`, + ); + + // Set maximum capture time + if (maxCaptureTime > 0) { + this.captureTimers.set( + tabId, + setTimeout(async () => { + console.log( + `NetworkCaptureV2: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`, + ); + await this.stopCapture(tabId); + }, maxCaptureTime), + ); + } + } catch (error: any) { + console.error(`NetworkCaptureV2: Error starting capture for tab ${tabId}:`, error); + + // Clean up resources + if (this.captureData.has(tabId)) { + this.cleanupCapture(tabId); + } + + throw error; + } + } + + /** + * Stop capture + * @param tabId Tab ID + */ + public async stopCapture( + tabId: number, + ): Promise<{ success: boolean; message?: string; data?: any }> { + const captureInfo = this.captureData.get(tabId); + if (!captureInfo) { + console.log(`NetworkCaptureV2: No capture in progress for tab ${tabId}`); + return { success: false, message: `No capture in progress for tab ${tabId}` }; + } + + try { + // Record end time + captureInfo.endTime = Date.now(); + + // Extract common request and response headers + const requestsArray = Object.values(captureInfo.requests); + const commonRequestHeaders = this.analyzeCommonHeaders(requestsArray, 'requestHeaders'); + const commonResponseHeaders = this.analyzeCommonHeaders(requestsArray, 'responseHeaders'); + + // Process request data, remove common headers + const processedRequests = requestsArray.map((req) => { + const finalReq: NetworkRequestInfo = { ...req }; + + if (finalReq.requestHeaders) { + finalReq.specificRequestHeaders = this.filterOutCommonHeaders( + finalReq.requestHeaders, + commonRequestHeaders, + ); + delete finalReq.requestHeaders; + } else { + finalReq.specificRequestHeaders = {}; + } + + if (finalReq.responseHeaders) { + finalReq.specificResponseHeaders = this.filterOutCommonHeaders( + finalReq.responseHeaders, + commonResponseHeaders, + ); + delete finalReq.responseHeaders; + } else { + finalReq.specificResponseHeaders = {}; + } + + return finalReq; + }); + + // Sort by time + processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0)); + + // Remove listeners + this.removeListeners(); + + // Prepare result data + const resultData = { + captureStartTime: captureInfo.startTime, + captureEndTime: captureInfo.endTime, + totalDurationMs: captureInfo.endTime - captureInfo.startTime, + settingsUsed: { + maxCaptureTime: captureInfo.maxCaptureTime, + inactivityTimeout: captureInfo.inactivityTimeout, + includeStatic: captureInfo.includeStatic, + maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE, + }, + commonRequestHeaders, + commonResponseHeaders, + requests: processedRequests, + requestCount: processedRequests.length, + totalRequestsReceived: this.requestCounters.get(tabId) || 0, + requestLimitReached: captureInfo.limitReached || false, + tabUrl: captureInfo.tabUrl, + tabTitle: captureInfo.tabTitle, + }; + + // Clean up resources + this.cleanupCapture(tabId); + + return { + success: true, + data: resultData, + }; + } catch (error: any) { + console.error(`NetworkCaptureV2: Error stopping capture for tab ${tabId}:`, error); + + // Ensure resources are cleaned up + this.cleanupCapture(tabId); + + return { + success: false, + message: `Error stopping capture: ${error.message || String(error)}`, + }; + } + } + + /** + * Analyze common request or response headers + */ + private analyzeCommonHeaders( + requests: NetworkRequestInfo[], + headerType: 'requestHeaders' | 'responseHeaders', + ): Record { + if (!requests || requests.length === 0) return {}; + + // Find headers that are included in all requests + const commonHeaders: Record = {}; + const firstRequestWithHeaders = requests.find( + (req) => req[headerType] && Object.keys(req[headerType] || {}).length > 0, + ); + + if (!firstRequestWithHeaders || !firstRequestWithHeaders[headerType]) { + return {}; + } + + // Get all headers from the first request + const headers = firstRequestWithHeaders[headerType] as Record; + const headerNames = Object.keys(headers); + + // Check if each header exists in all requests with the same value + for (const name of headerNames) { + const value = headers[name]; + const isCommon = requests.every((req) => { + const reqHeaders = req[headerType] as Record; + return reqHeaders && reqHeaders[name] === value; + }); + + if (isCommon) { + commonHeaders[name] = value; + } + } + + return commonHeaders; + } + + /** + * Filter out common headers + */ + private filterOutCommonHeaders( + headers: Record, + commonHeaders: Record, + ): Record { + if (!headers || typeof headers !== 'object') return {}; + + const specificHeaders: Record = {}; + // Use Object.keys to avoid ESLint no-prototype-builtins warning + Object.keys(headers).forEach((name) => { + if (!(name in commonHeaders) || headers[name] !== commonHeaders[name]) { + specificHeaders[name] = headers[name]; + } + }); + + return specificHeaders; + } + + async execute(args: NetworkCaptureStartToolParams): Promise { + const { + url: targetUrl, + maxCaptureTime = 3 * 60 * 1000, // Default 3 minutes + inactivityTimeout = 60 * 1000, // Default 1 minute of inactivity before auto-stop + includeStatic = false, // Default: don't include static resources + } = args; + + console.log(`NetworkCaptureStartTool: Executing with args:`, args); + + try { + // Get current tab or create new tab + let tabToOperateOn: chrome.tabs.Tab; + + if (targetUrl) { + // Find tabs matching the URL + const matchingTabs = await chrome.tabs.query({ url: targetUrl }); + + if (matchingTabs.length > 0) { + // Use existing tab + tabToOperateOn = matchingTabs[0]; + console.log(`NetworkCaptureV2: Found existing tab with URL: ${targetUrl}`); + } else { + // Create new tab + console.log(`NetworkCaptureV2: Creating new tab with URL: ${targetUrl}`); + tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true }); + + // Wait for page to load + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } else { + // Use current active tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0]) { + return createErrorResponse('No active tab found'); + } + tabToOperateOn = tabs[0]; + } + + if (!tabToOperateOn?.id) { + return createErrorResponse('Failed to identify or create a tab'); + } + + // Use startCaptureForTab method to start capture + try { + await this.startCaptureForTab(tabToOperateOn.id, { + maxCaptureTime, + inactivityTimeout, + includeStatic, + }); + } catch (error: any) { + return createErrorResponse( + `Failed to start capture for tab ${tabToOperateOn.id}: ${error.message || String(error)}`, + ); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Network capture V2 started successfully, waiting for stop command.', + tabId: tabToOperateOn.id, + url: tabToOperateOn.url, + maxCaptureTime, + inactivityTimeout, + includeStatic, + maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE, + }), + }, + ], + isError: false, + }; + } catch (error: any) { + console.error('NetworkCaptureStartTool: Critical error:', error); + return createErrorResponse( + `Error in NetworkCaptureStartTool: ${error.message || String(error)}`, + ); + } + } +} + +/** + * Network capture stop tool V2 - Stop webRequest API capture and return results + */ +class NetworkCaptureStopTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP; + public static instance: NetworkCaptureStopTool | null = null; + + constructor() { + super(); + if (NetworkCaptureStopTool.instance) { + return NetworkCaptureStopTool.instance; + } + NetworkCaptureStopTool.instance = this; + } + + async execute(): Promise { + console.log(`NetworkCaptureStopTool: Executing`); + + try { + const startTool = NetworkCaptureStartTool.instance; + + if (!startTool) { + return createErrorResponse('Network capture V2 start tool instance not found'); + } + + // Get all tabs currently capturing + const ongoingCaptures = Array.from(startTool.captureData.keys()); + console.log( + `NetworkCaptureStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`, + ); + + if (ongoingCaptures.length === 0) { + return createErrorResponse('No active network captures found in any tab.'); + } + + // Get current active tab + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const activeTabId = activeTabs[0]?.id; + + // Determine the primary tab to stop + let primaryTabId: number; + + if (activeTabId && startTool.captureData.has(activeTabId)) { + // If current active tab is capturing, prioritize stopping it + primaryTabId = activeTabId; + console.log( + `NetworkCaptureStopTool: Active tab ${activeTabId} is capturing, will stop it first.`, + ); + } else if (ongoingCaptures.length === 1) { + // If only one tab is capturing, stop it + primaryTabId = ongoingCaptures[0]; + console.log( + `NetworkCaptureStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`, + ); + } else { + // If multiple tabs are capturing but current active tab is not among them, stop the first one + primaryTabId = ongoingCaptures[0]; + console.log( + `NetworkCaptureStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`, + ); + } + + const stopResult = await startTool.stopCapture(primaryTabId); + + if (!stopResult.success) { + return createErrorResponse( + stopResult.message || `Failed to stop network capture for tab ${primaryTabId}`, + ); + } + + // If multiple tabs are capturing, stop other tabs + if (ongoingCaptures.length > 1) { + const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId); + console.log( + `NetworkCaptureStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`, + ); + + for (const tabId of otherTabIds) { + try { + await startTool.stopCapture(tabId); + } catch (error) { + console.error(`NetworkCaptureStopTool: Error stopping capture on tab ${tabId}:`, error); + } + } + } + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Capture complete. ${stopResult.data?.requestCount || 0} requests captured.`, + tabId: primaryTabId, + tabUrl: stopResult.data?.tabUrl || 'N/A', + tabTitle: stopResult.data?.tabTitle || 'Unknown Tab', + requestCount: stopResult.data?.requestCount || 0, + commonRequestHeaders: stopResult.data?.commonRequestHeaders || {}, + commonResponseHeaders: stopResult.data?.commonResponseHeaders || {}, + requests: stopResult.data?.requests || [], + captureStartTime: stopResult.data?.captureStartTime, + captureEndTime: stopResult.data?.captureEndTime, + totalDurationMs: stopResult.data?.totalDurationMs, + settingsUsed: stopResult.data?.settingsUsed || {}, + totalRequestsReceived: stopResult.data?.totalRequestsReceived || 0, + requestLimitReached: stopResult.data?.requestLimitReached || false, + remainingCaptures: Array.from(startTool.captureData.keys()), + }), + }, + ], + isError: false, + }; + } catch (error: any) { + console.error('NetworkCaptureStopTool: Critical error:', error); + return createErrorResponse( + `Error in NetworkCaptureStopTool: ${error.message || String(error)}`, + ); + } + } +} + +export const networkCaptureStartTool = new NetworkCaptureStartTool(); +export const networkCaptureStopTool = new NetworkCaptureStopTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts new file mode 100644 index 0000000..aebfd9f --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts @@ -0,0 +1,158 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request'; +import { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger'; + +type NetworkCaptureBackend = 'webRequest' | 'debugger'; + +interface NetworkCaptureToolParams { + action: 'start' | 'stop'; + needResponseBody?: boolean; + url?: string; + maxCaptureTime?: number; + inactivityTimeout?: number; + includeStatic?: boolean; +} + +/** + * Extract text content from ToolResult + */ +function getFirstText(result: ToolResult): string | undefined { + const first = result.content?.[0]; + return first && first.type === 'text' ? first.text : undefined; +} + +/** + * Decorate JSON result with additional fields + */ +function decorateJsonResult(result: ToolResult, extra: Record): ToolResult { + const text = getFirstText(result); + if (typeof text !== 'string') return result; + + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + ...result, + content: [{ type: 'text', text: JSON.stringify({ ...parsed, ...extra }) }], + }; + } + } catch { + // If the underlying tool didn't return JSON, keep it as-is + } + return result; +} + +/** + * Check if debugger-based capture is active + */ +function isDebuggerCaptureActive(): boolean { + const captureData = ( + networkDebuggerStartTool as unknown as { captureData?: Map } + ).captureData; + return captureData instanceof Map && captureData.size > 0; +} + +/** + * Check if webRequest-based capture is active + */ +function isWebRequestCaptureActive(): boolean { + return networkCaptureStartTool.captureData.size > 0; +} + +/** + * Unified Network Capture Tool + * + * Provides a single entry point for network capture, automatically selecting + * the appropriate backend based on the `needResponseBody` parameter: + * - needResponseBody=false (default): uses webRequest API (lightweight, no debugger conflict) + * - needResponseBody=true: uses Debugger API (captures response body, may conflict with DevTools) + */ +class NetworkCaptureTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE; + + async execute(args: NetworkCaptureToolParams): Promise { + const action = args?.action; + if (action !== 'start' && action !== 'stop') { + return createErrorResponse('Parameter [action] is required and must be one of: start, stop'); + } + + const wantBody = args?.needResponseBody === true; + const debuggerActive = isDebuggerCaptureActive(); + const webActive = isWebRequestCaptureActive(); + + if (action === 'start') { + return this.handleStart(args, wantBody, debuggerActive, webActive); + } + + return this.handleStop(args, debuggerActive, webActive); + } + + private async handleStart( + args: NetworkCaptureToolParams, + wantBody: boolean, + debuggerActive: boolean, + webActive: boolean, + ): Promise { + // Prevent any capture conflict (cross-mode or same-mode) + if (debuggerActive || webActive) { + const activeMode = debuggerActive ? 'debugger' : 'webRequest'; + return createErrorResponse( + `Network capture is already active in ${activeMode} mode. Stop it before starting a new capture.`, + ); + } + + const delegate = wantBody ? networkDebuggerStartTool : networkCaptureStartTool; + const backend: NetworkCaptureBackend = wantBody ? 'debugger' : 'webRequest'; + + const result = await delegate.execute({ + url: args.url, + maxCaptureTime: args.maxCaptureTime, + inactivityTimeout: args.inactivityTimeout, + includeStatic: args.includeStatic, + }); + + return decorateJsonResult(result, { backend, needResponseBody: wantBody }); + } + + private async handleStop( + args: NetworkCaptureToolParams, + debuggerActive: boolean, + webActive: boolean, + ): Promise { + // Determine which backend to stop + let backendToStop: NetworkCaptureBackend | null = null; + + // If user explicitly specified needResponseBody, try to stop that specific backend + if (args?.needResponseBody === true) { + backendToStop = debuggerActive ? 'debugger' : null; + } else if (args?.needResponseBody === false) { + backendToStop = webActive ? 'webRequest' : null; + } + + // If no explicit preference or the specified backend isn't active, auto-detect + if (!backendToStop) { + if (debuggerActive) { + backendToStop = 'debugger'; + } else if (webActive) { + backendToStop = 'webRequest'; + } + } + + if (!backendToStop) { + return createErrorResponse('No active network captures found in any tab.'); + } + + const delegateStop = + backendToStop === 'debugger' ? networkDebuggerStopTool : networkCaptureStopTool; + const result = await delegateStop.execute(); + + return decorateJsonResult(result, { + backend: backendToStop, + needResponseBody: backendToStop === 'debugger', + }); + } +} + +export const networkCaptureTool = new NetworkCaptureTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts new file mode 100644 index 0000000..93b58cd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-request.ts @@ -0,0 +1,85 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; + +const DEFAULT_NETWORK_REQUEST_TIMEOUT = 30000; // For sending a single request via content script + +interface NetworkRequestToolParams { + url: string; // URL is always required + method?: string; // Defaults to GET + headers?: Record; // User-provided headers + body?: any; // User-provided body + timeout?: number; // Timeout for the network request itself + // Optional multipart/form-data descriptor. When provided, overrides body and lets the helper build FormData. + // Shape: { fields?: Record, files?: Array<{ name: string, fileUrl?: string, filePath?: string, base64Data?: string, filename?: string, contentType?: string }> } + // Or a compact array: [ [name, fileSpec, filename?], ... ] where fileSpec can be 'url:...', 'file:/abs/path', 'base64:...' + formData?: any; +} + +/** + * NetworkRequestTool - Sends network requests based on provided parameters. + */ +class NetworkRequestTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_REQUEST; + + async execute(args: NetworkRequestToolParams): Promise { + const { + url, + method = 'GET', + headers = {}, + body, + timeout = DEFAULT_NETWORK_REQUEST_TIMEOUT, + } = args; + + console.log(`NetworkRequestTool: Executing with options:`, args); + + if (!url) { + return createErrorResponse('URL parameter is required.'); + } + + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0]?.id) { + return createErrorResponse('No active tab found or tab has no ID.'); + } + const activeTabId = tabs[0].id; + + // Ensure content script is available in the target tab + await this.injectContentScript(activeTabId, ['inject-scripts/network-helper.js']); + + console.log( + `NetworkRequestTool: Sending to content script: URL=${url}, Method=${method}, Headers=${Object.keys(headers).join(',')}, BodyType=${typeof body}`, + ); + + const resultFromContentScript = await this.sendMessageToTab(activeTabId, { + action: TOOL_MESSAGE_TYPES.NETWORK_SEND_REQUEST, + url: url, + method: method, + headers: headers, + body: body, + formData: args.formData || null, + timeout: timeout, + }); + + console.log(`NetworkRequestTool: Response from content script:`, resultFromContentScript); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(resultFromContentScript), + }, + ], + isError: !resultFromContentScript?.success, + }; + } catch (error: any) { + console.error('NetworkRequestTool: Error sending network request:', error); + return createErrorResponse( + `Error sending network request: ${error.message || String(error)}`, + ); + } + } +} + +export const networkRequestTool = new NetworkRequestTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/performance.ts b/app/chrome-extension/entrypoints/background/tools/browser/performance.ts new file mode 100644 index 0000000..8fcb257 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/performance.ts @@ -0,0 +1,545 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; + +type OwnerTag = 'performance'; + +interface StartTraceParams { + reload?: boolean; // whether to reload the page after starting trace + autoStop?: boolean; // whether to auto stop after a short duration + durationMs?: number; // custom duration when autoStop is true (default 5000) +} + +interface StopTraceParams { + saveToDownloads?: boolean; // save trace to Downloads as JSON (default true) + filenamePrefix?: string; // filename prefix (default 'performance_trace') +} + +interface AnalyzeInsightParams { + insightName?: string; // placeholder for future deep insights +} + +type DebuggeeEvent = (source: chrome.debugger.Debuggee, method: string, params?: any) => void; + +interface TraceSessionState { + recording: boolean; + events: any[]; + startedAt: number; + pageUrl?: string; + listener: DebuggeeEvent; + stopResolver?: (value: { completed: boolean }) => void; + stopPromise?: Promise<{ completed: boolean }>; +} + +const sessions = new Map(); +const LAST_RESULTS = new Map< + number, + { + events: any[]; + startedAt: number; + endedAt: number; + tabUrl: string; + saved?: { downloadId?: number; filename?: string; fullPath?: string }; + metrics?: Record; + } +>(); + +function tracingCategories(): string[] { + // Keep broadly consistent with other project + return [ + '-*', + 'blink.console', + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.screenshot', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.invalidationTracking', + 'disabled-by-default-devtools.timeline.frame', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + 'disabled-by-default-v8.cpu_profiler.hires', + 'latencyInfo', + 'loading', + 'disabled-by-default-lighthouse', + 'v8.execute', + 'v8', + ]; +} + +async function enablePerformanceMetrics(tabId: number): Promise> { + try { + await cdpSessionManager.sendCommand(tabId, 'Performance.enable'); + const result = (await cdpSessionManager.sendCommand(tabId, 'Performance.getMetrics')) as { + metrics: Array<{ name: string; value: number }>; + }; + await cdpSessionManager.sendCommand(tabId, 'Performance.disable'); + const map: Record = {}; + for (const m of result.metrics || []) map[m.name] = m.value; + return map; + } catch (e) { + return {}; + } +} + +async function saveTraceToDownloads( + json: string, + filenamePrefix = 'performance_trace', +): Promise<{ downloadId?: number; filename?: string; fullPath?: string }> { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${filenamePrefix}_${timestamp}.json`; + const dataUrl = `data:application/json;base64,${btoa(unescape(encodeURIComponent(json)))}`; + const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false }); + // Attempt to resolve full path + try { + await new Promise((r) => setTimeout(r, 120)); + const [item] = await chrome.downloads.search({ id: downloadId }); + return { downloadId, filename, fullPath: item?.filename }; + } catch { + return { downloadId, filename }; + } + } catch { + return {}; + } +} + +async function saveTraceToNativeTemp( + json: string, + filenamePrefix = 'performance_trace', +): Promise<{ filename?: string; fullPath?: string } | undefined> { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${filenamePrefix}_${timestamp}.json`; + const base64 = btoa(unescape(encodeURIComponent(json))); + + const requestId = `trace-temp-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = 30000; + const resp = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + reject(new Error('Native temp save timed out')); + }, timeoutMs); + const listener = (message: any) => { + if ( + message && + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(message.payload); + } + }; + chrome.runtime.onMessage.addListener(listener); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { + action: 'prepareFile', + base64Data: base64, + fileName: filename, + }, + }, + }) + .catch((err) => { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + reject(err); + }); + }); + + if (resp && resp.success && resp.filePath) { + return { filename, fullPath: resp.filePath }; + } + } catch { + // ignore, fallback will apply + } + return undefined; +} + +async function cleanupNativeTempFile(filePath: string): Promise { + if (!filePath) return; + try { + const requestId = `trace-clean-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = 10000; + await new Promise((resolve) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + resolve(); // best-effort + }, timeoutMs); + const listener = (message: any) => { + if ( + message && + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(); + } + }; + chrome.runtime.onMessage.addListener(listener); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { + action: 'cleanupFile', + filePath, + }, + }, + }) + .catch(() => { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(); + }); + }); + } catch { + // ignore + } +} + +function getOrCreateStopPromise(session: TraceSessionState): Promise<{ completed: boolean }> { + if (session.stopPromise) return session.stopPromise; + session.stopPromise = new Promise((resolve) => { + session.stopResolver = resolve; + }); + return session.stopPromise; +} + +/** + * Start performance trace + */ +class PerformanceStartTraceTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE; + + async execute(args: StartTraceParams): Promise { + const { reload = false, autoStop = false, durationMs = 5000 } = args || {}; + + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) { + return createErrorResponse('No active tab found'); + } + const tabId = activeTab.id; + const existed = sessions.get(tabId); + if (existed?.recording) { + return { + content: [{ type: 'text', text: 'Error: a performance trace is already running.' }], + isError: false, + }; + } + + await cdpSessionManager.attach(tabId, 'performance'); + + const state: TraceSessionState = { + recording: true, + events: [], + startedAt: Date.now(), + pageUrl: activeTab.url || '', + listener: (source, method, params) => { + if (source.tabId !== tabId) return; + if (method === 'Tracing.dataCollected' && params?.value) { + try { + state.events.push(...(params.value as any[])); + } catch { + // ignore + } + } else if (method === 'Tracing.tracingComplete') { + state.recording = false; + state.stopResolver?.({ completed: true }); + } + }, + }; + chrome.debugger.onEvent.addListener(state.listener); + sessions.set(tabId, state); + + // Start tracing with categories + const cats = tracingCategories().join(','); + await cdpSessionManager.sendCommand(tabId, 'Tracing.start', { + categories: cats, + options: 'record-as-much-as-possible', + transferMode: 'ReportEvents', + }); + + if (reload) { + try { + await cdpSessionManager.sendCommand(tabId, 'Page.reload', { ignoreCache: true }); + } catch { + // best effort; ignore if fails + } + } + + if (autoStop) { + setTimeout( + async () => { + try { + await cdpSessionManager.sendCommand(tabId, 'Tracing.end'); + } catch { + // ignore + } + }, + Math.max(1000, Math.min(durationMs, 60000)), + ); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Performance trace is recording. Use performance_stop_trace to stop it.', + reload, + autoStop, + }), + }, + ], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Failed to start performance trace: ${e?.message || e}`); + } + } +} + +/** + * Stop performance trace + */ +class PerformanceStopTraceTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE; + + async execute(args: StopTraceParams): Promise { + const { saveToDownloads = true, filenamePrefix } = args || {}; + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) return createErrorResponse('No active tab found'); + const tabId = activeTab.id; + const session = sessions.get(tabId); + if (!session) { + return { + content: [ + { type: 'text', text: 'No performance trace session found for the current tab.' }, + ], + isError: false, + }; + } + + let stopResult: { completed: boolean } = { completed: false }; + if (session.recording) { + // End tracing and wait for completion signal + await cdpSessionManager.sendCommand(tabId, 'Tracing.end'); + await getOrCreateStopPromise(session); + stopResult = await session.stopPromise!; + } else { + // Already auto-stopped; proceed to finalize without waiting + stopResult = { completed: true }; + } + // Fetch metrics before detach + const metrics = await enablePerformanceMetrics(tabId); + + // Cleanup event listener and detach + try { + chrome.debugger.onEvent.removeListener(session.listener); + } catch { + // ignore + } + try { + await cdpSessionManager.detach(tabId, 'performance'); + } catch { + // ignore + } + + const endedAt = Date.now(); + const trace = { traceEvents: session.events }; + const json = JSON.stringify(trace); + + let saved: { downloadId?: number; filename?: string; fullPath?: string } | undefined; + if (saveToDownloads) { + saved = await saveTraceToDownloads(json, filenamePrefix || 'performance_trace'); + } else { + // Persist to native temp directory so that analysis can run without Downloads permission + const tempSaved = await saveTraceToNativeTemp(json, filenamePrefix || 'performance_trace'); + if (tempSaved) { + saved = { ...tempSaved } as any; + } + } + + LAST_RESULTS.set(tabId, { + events: session.events, + startedAt: session.startedAt, + endedAt, + tabUrl: session.pageUrl || '', + saved, + metrics, + }); + + sessions.delete(tabId); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'The performance trace has been stopped.', + eventCount: session.events.length, + saved, + metrics, + startedAt: session.startedAt, + endedAt, + durationMs: endedAt - session.startedAt, + url: session.pageUrl || '', + tracingCompleted: stopResult?.completed === true, + }), + }, + ], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Failed to stop performance trace: ${e?.message || e}`); + } + } +} + +/** + * Analyze last trace (lightweight) + * Note: Deep insights require DevTools front-end trace engine on the native side; this is a + * pragmatic first step returning basic metrics and a quick event histogram. + */ +class PerformanceAnalyzeInsightTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT; + + async execute(args: AnalyzeInsightParams & { timeoutMs?: number }): Promise { + const { insightName } = args || {}; + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) return createErrorResponse('No active tab found'); + const tabId = activeTab.id; + const result = LAST_RESULTS.get(tabId); + if (!result) { + return { + content: [ + { + type: 'text', + text: 'No recorded traces found. Start and stop a performance trace first.', + }, + ], + isError: false, + }; + } + + // Prefer native-side deep analysis when we have a saved file path + const fullPath = (result.saved && (result.saved as any).fullPath) || undefined; + if (fullPath) { + try { + const requestId = `trace-analyze-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = Math.max(10000, Math.min((args as any)?.timeoutMs ?? 60000, 300000)); + const resp = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + reject(new Error('Native trace analysis timed out')); + }, timeoutMs); + const listener = (message: any) => { + if ( + message && + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(message.payload); + } + }; + chrome.runtime.onMessage.addListener(listener); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { action: 'analyzeTrace', traceFilePath: fullPath, insightName }, + }, + }) + .catch((err) => { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + reject(err); + }); + }); + if (resp && resp.success) { + // Best-effort cleanup for temp files (Downloads paths are ignored by native cleaner) + await cleanupNativeTempFile(fullPath); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + url: result.tabUrl, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.endedAt - result.startedAt, + metrics: result.metrics || {}, + saved: result.saved, + summary: resp.summary, + insight: resp.insight, + }), + }, + ], + isError: false, + }; + } + // If native returned error, fall through to lightweight analysis + } catch (e) { + // Fallback to lightweight analysis below + } + } + + // Lightweight fallback (when no saved file path) + const counts = new Map(); + for (const ev of result.events.slice(0, 100000)) { + const n = typeof (ev as any)?.name === 'string' ? (ev as any).name : 'unknown'; + counts.set(n, (counts.get(n) || 0) + 1); + } + const top = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([name, count]) => ({ name, count })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + info: 'Lightweight analysis (no saved file path). Native-side deep analysis unavailable.', + requestedInsight: insightName || null, + url: result.tabUrl, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.endedAt - result.startedAt, + metrics: result.metrics || {}, + topEventNames: top, + saved: result.saved, + }), + }, + ], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Failed to analyze trace: ${e?.message || e}`); + } + } +} + +export const performanceStartTraceTool = new PerformanceStartTraceTool(); +export const performanceStopTraceTool = new PerformanceStopTraceTool(); +export const performanceAnalyzeInsightTool = new PerformanceAnalyzeInsightTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/read-page.ts b/app/chrome-extension/entrypoints/background/tools/browser/read-page.ts new file mode 100644 index 0000000..9895f74 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/read-page.ts @@ -0,0 +1,229 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { ERROR_MESSAGES } from '@/common/constants'; +import { listMarkersForUrl } from '@/entrypoints/background/element-marker/element-marker-storage'; + +interface ReadPageStats { + processed: number; + included: number; + durationMs: number; +} + +interface ReadPageParams { + filter?: 'interactive'; // when omitted, return all visible elements + depth?: number; // maximum DOM depth to traverse (0 = root only) + refId?: string; // focus on subtree rooted at this refId + tabId?: number; // target existing tab id + windowId?: number; // when no tabId, pick active tab from this window +} + +class ReadPageTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.READ_PAGE; + + // Execute read page + async execute(args: ReadPageParams): Promise { + const { filter, depth, refId } = args || {}; + + // Validate refId parameter + const focusRefId = typeof refId === 'string' ? refId.trim() : ''; + if (refId !== undefined && !focusRefId) { + return createErrorResponse( + `${ERROR_MESSAGES.INVALID_PARAMETERS}: refId must be a non-empty string`, + ); + } + + // Validate depth parameter + const requestedDepth = depth === undefined ? undefined : Number(depth); + if (requestedDepth !== undefined && (!Number.isInteger(requestedDepth) || requestedDepth < 0)) { + return createErrorResponse( + `${ERROR_MESSAGES.INVALID_PARAMETERS}: depth must be a non-negative integer`, + ); + } + + // Track if user explicitly controlled the output (skip sparse heuristics) + const userControlled = requestedDepth !== undefined || !!focusRefId; + + try { + // Tip text returned to callers to guide next action + const standardTips = + "If the specific element you need is missing from the returned data, use the 'screenshot' tool to capture the current viewport and confirm the element's on-screen coordinates. Also note: 'markedElements' are user-marked elements and have the highest priority when choosing targets."; + + const explicit = await this.tryGetTab(args?.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args?.windowId)); + if (!tab.id) + return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID'); + + // Load any user-marked elements for this URL (priority hints) + const currentUrl = String(tab.url || ''); + const userMarkers = currentUrl ? await listMarkersForUrl(currentUrl) : []; + + // Inject helper in ISOLATED world to enable chrome.runtime messaging + // Inject into all frames to support same-origin iframe operations + await this.injectContentScript( + tab.id, + ['inject-scripts/accessibility-tree-helper.js'], + false, + 'ISOLATED', + true, + ); + + // Ask content script to generate accessibility tree + const resp = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.GENERATE_ACCESSIBILITY_TREE, + filter: filter || null, + depth: requestedDepth, + refId: focusRefId || undefined, + }); + + // Evaluate tree result and decide whether to fallback + const treeOk = resp && resp.success === true; + const pageContent: string = + resp && typeof resp.pageContent === 'string' ? resp.pageContent : ''; + + // Extract stats from response + const stats: ReadPageStats | null = + treeOk && resp?.stats + ? { + processed: resp.stats.processed ?? 0, + included: resp.stats.included ?? 0, + durationMs: resp.stats.durationMs ?? 0, + } + : null; + + const lines = pageContent + ? pageContent.split('\n').filter((l: string) => l.trim().length > 0).length + : 0; + const refCount = Array.isArray(resp?.refMap) ? resp.refMap.length : 0; + + // Skip sparse heuristics when user explicitly controls output + const isSparse = !userControlled && lines < 10 && refCount < 3; + + // Build user-marked elements for inclusion + const markedElements = userMarkers.map((m) => ({ + name: m.name, + selector: m.selector, + selectorType: m.selectorType || 'css', + urlMatch: { type: m.matchType, origin: m.origin, path: m.path }, + source: 'marker', + priority: 'highest', + })); + + // Helper to convert elements array to pageContent format + const formatElementsAsPageContent = (elements: any[]): string => { + const out: string[] = []; + for (const e of elements || []) { + const type = typeof e?.type === 'string' && e.type ? e.type : 'element'; + const rawText = typeof e?.text === 'string' ? e.text.trim() : ''; + const text = + rawText.length > 0 + ? ` "${rawText.replace(/\s+/g, ' ').slice(0, 100).replace(/"/g, '\\"')}"` + : ''; + const selector = + typeof e?.selector === 'string' && e.selector ? ` selector="${e.selector}"` : ''; + const coords = + e?.coordinates && Number.isFinite(e.coordinates.x) && Number.isFinite(e.coordinates.y) + ? ` (x=${Math.round(e.coordinates.x)},y=${Math.round(e.coordinates.y)})` + : ''; + out.push(`- ${type}${text}${selector}${coords}`); + if (out.length >= 150) break; + } + return out.join('\n'); + }; + + // Unified base payload structure - consistent keys for stable contract + const basePayload: Record = { + success: true, + filter: filter || 'all', + pageContent, + tips: standardTips, + viewport: treeOk ? resp.viewport : { width: null, height: null, dpr: null }, + stats: stats || { processed: 0, included: 0, durationMs: 0 }, + refMapCount: refCount, + sparse: treeOk ? isSparse : false, + depth: requestedDepth ?? null, + focus: focusRefId ? { refId: focusRefId, found: treeOk } : null, + markedElements, + elements: [], + count: 0, + fallbackUsed: false, + fallbackSource: null, + reason: null, + }; + + // Normal path: return tree + if (treeOk && !isSparse) { + return { + content: [{ type: 'text', text: JSON.stringify(basePayload) }], + isError: false, + }; + } + + // When refId is explicitly provided, do not fallback (refs are frame-local and may expire) + if (focusRefId) { + return createErrorResponse(resp?.error || `refId "${focusRefId}" not found or expired`); + } + + // When user explicitly controls depth, do not override with fallback heuristics + if (requestedDepth !== undefined) { + return createErrorResponse(resp?.error || 'Failed to generate accessibility tree'); + } + + // Fallback path: try get_interactive_elements once + try { + await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']); + const fallback = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS, + includeCoordinates: true, + }); + + if (fallback && fallback.success && Array.isArray(fallback.elements)) { + const limited = fallback.elements.slice(0, 150); + // Merge user markers at the front, de-duplicated by selector + const markerEls = userMarkers.map((m) => ({ + type: 'marker', + selector: m.selector, + text: m.name, + selectorType: m.selectorType || 'css', + isInteractive: true, + source: 'marker', + priority: 'highest', + })); + const seen = new Set(markerEls.map((e) => e.selector)); + const merged = [...markerEls, ...limited.filter((e: any) => !seen.has(e.selector))]; + + basePayload.fallbackUsed = true; + basePayload.fallbackSource = 'get_interactive_elements'; + basePayload.reason = treeOk ? 'sparse_tree' : resp?.error || 'tree_failed'; + basePayload.elements = merged; + basePayload.count = fallback.elements.length; + if (!basePayload.pageContent) { + basePayload.pageContent = formatElementsAsPageContent(merged); + } + + return { + content: [{ type: 'text', text: JSON.stringify(basePayload) }], + isError: false, + }; + } + } catch (fallbackErr) { + console.warn('read_page fallback failed:', fallbackErr); + } + + // If we reach here, both tree (usable) and fallback failed + return createErrorResponse( + treeOk + ? 'Accessibility tree is too sparse and fallback failed' + : resp?.error || 'Failed to generate accessibility tree and fallback failed', + ); + } catch (error) { + console.error('Error in read page tool:', error); + return createErrorResponse( + `Error generating accessibility tree: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const readPageTool = new ReadPageTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts b/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts new file mode 100644 index 0000000..bb49265 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts @@ -0,0 +1,567 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { + canvasToDataURL, + createImageBitmapFromUrl, + cropAndResizeImage, + stitchImages, + compressImage, +} from '../../../../utils/image-utils'; +import { screenshotContextManager } from '@/utils/screenshot-context'; + +// Screenshot-specific constants +const SCREENSHOT_CONSTANTS = { + SCROLL_DELAY_MS: 350, // Time to wait after scroll for rendering and lazy loading + CAPTURE_STITCH_DELAY_MS: 50, // Small delay between captures in a scroll sequence + MAX_CAPTURE_PARTS: 50, // Maximum number of parts to capture (for infinite scroll pages) + MAX_CAPTURE_HEIGHT_PX: 50000, // Maximum height in pixels to capture + PIXEL_TOLERANCE: 1, + SCRIPT_INIT_DELAY: 100, // Delay for script initialization +} as { + readonly SCROLL_DELAY_MS: number; + CAPTURE_STITCH_DELAY_MS: number; // This one is mutable + readonly MAX_CAPTURE_PARTS: number; + readonly MAX_CAPTURE_HEIGHT_PX: number; + readonly PIXEL_TOLERANCE: number; + readonly SCRIPT_INIT_DELAY: number; +}; + +// Adjust CAPTURE_STITCH_DELAY_MS to respect Chrome's capture rate if available in runtime +// Some TS typings don't expose MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND; use a safe cast with a sane fallback. +const __MAX_CAP_RATE: number | undefined = (chrome.tabs as any) + ?.MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND; +if (typeof __MAX_CAP_RATE === 'number' && __MAX_CAP_RATE > 0) { + // Minimum interval between consecutive captureVisibleTab calls (ms) + const minIntervalMs = Math.ceil(1000 / __MAX_CAP_RATE); + // Our capture loop already waits SCROLL_DELAY_MS between scroll and capture; add any extra delay needed + const requiredExtraDelay = Math.max(0, minIntervalMs - SCREENSHOT_CONSTANTS.SCROLL_DELAY_MS); + SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS = Math.max( + requiredExtraDelay, + SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS, + ); +} + +interface ScreenshotToolParams { + name: string; + selector?: string; + tabId?: number; + background?: boolean; + windowId?: number; + width?: number; + height?: number; + storeBase64?: boolean; + fullPage?: boolean; + savePng?: boolean; + maxHeight?: number; // Maximum height to capture in pixels (for infinite scroll pages) +} + +/** Page details returned by screenshot-helper content script */ +interface ScreenshotPageDetails { + totalWidth: number; + totalHeight: number; + viewportWidth: number; + viewportHeight: number; + devicePixelRatio: number; + currentScrollX: number; + currentScrollY: number; +} + +const PAGE_DETAILS_REQUIRED_FIELDS: Array = [ + 'totalWidth', + 'totalHeight', + 'viewportWidth', + 'viewportHeight', + 'devicePixelRatio', + 'currentScrollX', + 'currentScrollY', +]; + +/** + * Validates and asserts that the response from content script contains valid page details + */ +function assertValidPageDetails(details: unknown): ScreenshotPageDetails { + if (!details || typeof details !== 'object') { + throw new Error( + 'Screenshot helper did not respond. The content script may not be injected or cannot run on this page.', + ); + } + + const candidate = details as Partial; + const invalidFields = PAGE_DETAILS_REQUIRED_FIELDS.filter( + (field) => typeof candidate[field] !== 'number' || !Number.isFinite(candidate[field]), + ); + + if (invalidFields.length > 0) { + throw new Error( + `Screenshot helper returned invalid page details (missing/invalid: ${invalidFields.join(', ')}).`, + ); + } + + return candidate as ScreenshotPageDetails; +} + +/** + * Tool for capturing screenshots of web pages + */ +class ScreenshotTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.SCREENSHOT; + + /** + * Execute screenshot operation + */ + async execute(args: ScreenshotToolParams): Promise { + const { + name = 'screenshot', + selector, + storeBase64 = false, + fullPage = false, + savePng = true, + } = args; + + console.log(`Starting screenshot with options:`, args); + + // Resolve target tab (explicit or active) + const explicit = await this.tryGetTab(args.tabId); + const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId)); + + // Check URL restrictions + if ( + tab.url?.startsWith('chrome://') || + tab.url?.startsWith('edge://') || + tab.url?.startsWith('https://chrome.google.com/webstore') || + tab.url?.startsWith('https://microsoftedge.microsoft.com/') + ) { + return createErrorResponse( + 'Cannot capture special browser pages or web store pages due to security restrictions.', + ); + } + + let finalImageDataUrl: string | undefined; + let finalImageWidthCss: number | undefined; + let finalImageHeightCss: number | undefined; + const results: any = { base64: null, fileSaved: false }; + let originalScroll: { x: number; y: number } | null = null; + let didPreparePage = false; + let pageDetails: ScreenshotPageDetails | undefined; + + try { + const background = args.background === true; + // CDP path: background=true with simple viewport capture (no fullPage, no selector) + const canUseCdpCapture = background && !fullPage && !selector; + + // === Path 1: CDP viewport capture (no content script needed) === + if (canUseCdpCapture) { + try { + const tabId = tab.id!; + const { cdpSessionManager } = await import('@/utils/cdp-session-manager'); + await cdpSessionManager.withSession(tabId, 'screenshot', async () => { + const metrics: any = await cdpSessionManager.sendCommand( + tabId, + 'Page.getLayoutMetrics', + {}, + ); + const viewport = metrics?.layoutViewport || + metrics?.visualViewport || { + clientWidth: 800, + clientHeight: 600, + pageX: 0, + pageY: 0, + }; + const shot: any = await cdpSessionManager.sendCommand(tabId, 'Page.captureScreenshot', { + format: 'png', + }); + const base64Data = typeof shot?.data === 'string' ? shot.data : ''; + if (!base64Data) { + throw new Error('CDP Page.captureScreenshot returned empty data'); + } + finalImageDataUrl = `data:image/png;base64,${base64Data}`; + finalImageWidthCss = Math.round(viewport.clientWidth || 800); + finalImageHeightCss = Math.round(viewport.clientHeight || 600); + }); + } catch (e) { + console.warn('CDP viewport capture failed, falling back to helper path:', e); + } + } + + // === Path 2: Helper-assisted capture (requires content script) === + if (!finalImageDataUrl) { + // Always inject helper when we need pageDetails + await this.injectContentScript(tab.id!, ['inject-scripts/screenshot-helper.js']); + await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY)); + + // Prepare page (hide scrollbars, handle fixed elements) + const prepareResp = await this.sendMessageToTab(tab.id!, { + action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE, + options: { fullPage }, + }); + if (!prepareResp || prepareResp.success !== true) { + throw new Error( + 'Screenshot helper did not acknowledge page preparation. The content script may not be injected or cannot run on this page.', + ); + } + didPreparePage = true; + + // Get page details with validation + const rawPageDetails = await this.sendMessageToTab(tab.id!, { + action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS, + }); + pageDetails = assertValidPageDetails(rawPageDetails); + originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY }; + + if (fullPage) { + this.logInfo('Capturing full page...'); + finalImageDataUrl = await this._captureFullPage(tab.id!, args, pageDetails); + // Compute final CSS size + if (args.width && args.height) { + finalImageWidthCss = args.width; + finalImageHeightCss = args.height; + } else if (args.width && !args.height) { + finalImageWidthCss = args.width; + const ratio = pageDetails.totalHeight / pageDetails.totalWidth; + finalImageHeightCss = Math.round(args.width * ratio); + } else if (!args.width && args.height) { + finalImageHeightCss = args.height; + const ratio = pageDetails.totalWidth / pageDetails.totalHeight; + finalImageWidthCss = Math.round(args.height * ratio); + } else { + finalImageWidthCss = pageDetails.totalWidth; + finalImageHeightCss = pageDetails.totalHeight; + } + } else if (selector) { + this.logInfo(`Capturing element: ${selector}`); + finalImageDataUrl = await this._captureElement( + tab.id!, + args, + pageDetails.devicePixelRatio, + ); + if (args.width && args.height) { + finalImageWidthCss = args.width; + finalImageHeightCss = args.height; + } else { + finalImageWidthCss = pageDetails.viewportWidth; + finalImageHeightCss = pageDetails.viewportHeight; + } + } else { + // Visible area only + this.logInfo('Capturing visible area...'); + finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' }); + finalImageWidthCss = pageDetails.viewportWidth; + finalImageHeightCss = pageDetails.viewportHeight; + } + } + + if (!finalImageDataUrl) { + throw new Error('Failed to capture image data'); + } + + // 2. Process output + // Update screenshot context for coordinate scaling by tools like chrome_computer + try { + if (typeof finalImageWidthCss === 'number' && typeof finalImageHeightCss === 'number') { + let hostname = ''; + try { + hostname = tab.url ? new URL(tab.url).hostname : ''; + } catch { + // ignore + } + // Use pageDetails if available, otherwise fall back to final image dimensions + const viewportWidth = pageDetails?.viewportWidth ?? finalImageWidthCss; + const viewportHeight = pageDetails?.viewportHeight ?? finalImageHeightCss; + screenshotContextManager.setContext(tab.id!, { + screenshotWidth: finalImageWidthCss, + screenshotHeight: finalImageHeightCss, + viewportWidth, + viewportHeight, + devicePixelRatio: pageDetails?.devicePixelRatio, + hostname, + }); + } + } catch (e) { + console.warn('Failed to set screenshot context:', e); + } + if (storeBase64 === true) { + // Compress image for base64 output to reduce size + const compressed = await compressImage(finalImageDataUrl, { + scale: 0.7, // Reduce dimensions by 30% + quality: 0.8, // 80% quality for good balance + format: 'image/jpeg', // JPEG for better compression + }); + + // Include base64 data in response (without prefix) + const base64Data = compressed.dataUrl.replace(/^data:image\/[^;]+;base64,/, ''); + results.base64 = base64Data; + return { + content: [ + { + type: 'text', + text: JSON.stringify({ base64Data, mimeType: compressed.mimeType }), + }, + ], + isError: false, + }; + } + + if (savePng === true) { + // Save PNG file to downloads + this.logInfo('Saving PNG...'); + try { + // Generate filename + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${name.replace(/[^a-z0-9_-]/gi, '_') || 'screenshot'}_${timestamp}.png`; + + // Use Chrome's download API to save the file + const downloadId = await chrome.downloads.download({ + url: finalImageDataUrl, + filename: filename, + saveAs: false, + }); + + results.downloadId = downloadId; + results.filename = filename; + results.fileSaved = true; + + // Try to get the full file path + try { + // Wait a moment to ensure download info is updated + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Search for download item to get full path + const [downloadItem] = await chrome.downloads.search({ id: downloadId }); + if (downloadItem && downloadItem.filename) { + // Add full path to response + results.fullPath = downloadItem.filename; + } + } catch (pathError) { + console.warn('Could not get full file path:', pathError); + } + } catch (error) { + console.error('Error saving PNG file:', error); + results.saveError = String(error instanceof Error ? error.message : error); + } + } + } catch (error) { + console.error('Error during screenshot execution:', error); + return createErrorResponse( + `Screenshot error: ${error instanceof Error ? error.message : JSON.stringify(error)}`, + ); + } finally { + // 3. Reset page only if we prepared it + if (didPreparePage) { + try { + // Only include scroll position if we successfully captured it + const resetMessage: Record = { + action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE, + }; + if (originalScroll) { + resetMessage.scrollX = originalScroll.x; + resetMessage.scrollY = originalScroll.y; + } + await this.sendMessageToTab(tab.id!, resetMessage); + } catch (err) { + console.warn('Failed to reset page, tab might have closed:', err); + } + } + } + + this.logInfo('Screenshot completed!'); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Screenshot [${name}] captured successfully`, + tabId: tab.id, + url: tab.url, + name: name, + ...results, + }), + }, + ], + isError: false, + }; + } + + /** + * Log information + */ + private logInfo(message: string) { + console.log(`[Screenshot Tool] ${message}`); + } + + /** + * Capture specific element + */ + async _captureElement( + tabId: number, + options: ScreenshotToolParams, + pageDpr: number, + ): Promise { + const elementDetails = await this.sendMessageToTab(tabId, { + action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_ELEMENT_DETAILS, + selector: options.selector, + }); + + const dpr = elementDetails.devicePixelRatio || pageDpr || 1; + + // Element rect is viewport-relative, in CSS pixels + // captureVisibleTab captures in physical pixels + const cropRectPx = { + x: elementDetails.rect.x * dpr, + y: elementDetails.rect.y * dpr, + width: elementDetails.rect.width * dpr, + height: elementDetails.rect.height * dpr, + }; + + // Small delay to ensure element is fully rendered after scrollIntoView + await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY)); + + const visibleCaptureDataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' }); + if (!visibleCaptureDataUrl) { + throw new Error('Failed to capture visible tab for element cropping'); + } + + const croppedCanvas = await cropAndResizeImage( + visibleCaptureDataUrl, + cropRectPx, + dpr, + options.width, // Target output width in CSS pixels + options.height, // Target output height in CSS pixels + ); + return canvasToDataURL(croppedCanvas); + } + + /** + * Capture full page + */ + async _captureFullPage( + tabId: number, + options: ScreenshotToolParams, + initialPageDetails: any, + ): Promise { + const dpr = initialPageDetails.devicePixelRatio; + const totalWidthCss = options.width || initialPageDetails.totalWidth; // Use option width if provided + const totalHeightCss = initialPageDetails.totalHeight; // Full page always uses actual height + + // Apply maximum height limit for infinite scroll pages + const maxHeightPx = options.maxHeight || SCREENSHOT_CONSTANTS.MAX_CAPTURE_HEIGHT_PX; + const limitedHeightCss = Math.min(totalHeightCss, maxHeightPx / dpr); + + const totalWidthPx = totalWidthCss * dpr; + const totalHeightPx = limitedHeightCss * dpr; + + // Viewport dimensions (CSS pixels) - logged for debugging + this.logInfo( + `Viewport size: ${initialPageDetails.viewportWidth}x${initialPageDetails.viewportHeight} CSS pixels`, + ); + this.logInfo( + `Page dimensions: ${totalWidthCss}x${totalHeightCss} CSS pixels (limited to ${limitedHeightCss} height)`, + ); + + const viewportHeightCss = initialPageDetails.viewportHeight; + + const capturedParts = []; + let currentScrollYCss = 0; + let capturedHeightPx = 0; + let partIndex = 0; + + while (capturedHeightPx < totalHeightPx && partIndex < SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) { + this.logInfo( + `Capturing part ${partIndex + 1}... (${Math.round((capturedHeightPx / totalHeightPx) * 100)}%)`, + ); + + if (currentScrollYCss > 0) { + // Don't scroll for the first part if already at top + const scrollResp = await this.sendMessageToTab(tabId, { + action: TOOL_MESSAGE_TYPES.SCREENSHOT_SCROLL_PAGE, + x: 0, + y: currentScrollYCss, + scrollDelay: SCREENSHOT_CONSTANTS.SCROLL_DELAY_MS, + }); + // Update currentScrollYCss based on actual scroll achieved + currentScrollYCss = scrollResp.newScrollY; + } + + // Ensure rendering after scroll + await new Promise((resolve) => + setTimeout(resolve, SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS), + ); + + const dataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' }); + if (!dataUrl) throw new Error('captureVisibleTab returned empty during full page capture'); + + const yOffsetPx = currentScrollYCss * dpr; + capturedParts.push({ dataUrl, y: yOffsetPx }); + + const imgForHeight = await createImageBitmapFromUrl(dataUrl); // To get actual captured height + const lastPartEffectiveHeightPx = Math.min(imgForHeight.height, totalHeightPx - yOffsetPx); + + capturedHeightPx = yOffsetPx + lastPartEffectiveHeightPx; + + if (capturedHeightPx >= totalHeightPx - SCREENSHOT_CONSTANTS.PIXEL_TOLERANCE) break; + + currentScrollYCss += viewportHeightCss; + // Prevent overscrolling past the document height for the next scroll command + if ( + currentScrollYCss > totalHeightCss - viewportHeightCss && + currentScrollYCss < totalHeightCss + ) { + currentScrollYCss = totalHeightCss - viewportHeightCss; + } + partIndex++; + } + + // Check if we hit any limits + if (partIndex >= SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) { + this.logInfo( + `Reached maximum number of capture parts (${SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS}). This may be an infinite scroll page.`, + ); + } + if (totalHeightCss > limitedHeightCss) { + this.logInfo( + `Page height (${totalHeightCss}px) exceeds maximum capture height (${maxHeightPx / dpr}px). Capturing limited portion.`, + ); + } + + this.logInfo('Stitching image...'); + const finalCanvas = await stitchImages(capturedParts, totalWidthPx, totalHeightPx); + + // If user specified width but not height (or vice versa for full page), resize maintaining aspect ratio + let outputCanvas = finalCanvas; + if (options.width && !options.height) { + const targetWidthPx = options.width * dpr; + const aspectRatio = finalCanvas.height / finalCanvas.width; + const targetHeightPx = targetWidthPx * aspectRatio; + outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx); + const ctx = outputCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx); + } + } else if (options.height && !options.width) { + const targetHeightPx = options.height * dpr; + const aspectRatio = finalCanvas.width / finalCanvas.height; + const targetWidthPx = targetHeightPx * aspectRatio; + outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx); + const ctx = outputCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx); + } + } else if (options.width && options.height) { + // Both specified, direct resize + const targetWidthPx = options.width * dpr; + const targetHeightPx = options.height * dpr; + outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx); + const ctx = outputCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx); + } + } + + return canvasToDataURL(outputCanvas); + } +} + +export const screenshotTool = new ScreenshotTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/userscript.ts b/app/chrome-extension/entrypoints/background/tools/browser/userscript.ts new file mode 100644 index 0000000..dc89d55 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/userscript.ts @@ -0,0 +1,758 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { ExecutionWorld, STORAGE_KEYS } from '@/common/constants'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; + +type UserscriptAction = + | 'create' + | 'list' + | 'get' + | 'enable' + | 'disable' + | 'update' + | 'remove' + | 'send_command' + | 'export'; + +interface UserscriptArgsBase { + action: UserscriptAction; + args?: any; +} + +interface CreateArgs { + script: string; + name?: string; + description?: string; + matches?: string[]; + excludes?: string[]; + persist?: boolean; // default true + runAt?: 'document_start' | 'document_end' | 'document_idle' | 'auto'; // default auto(document_idle) + world?: 'auto' | 'ISOLATED' | 'MAIN'; // default auto(ISOLATED) + allFrames?: boolean; // default true + mode?: 'auto' | 'css' | 'persistent' | 'once'; // default auto + dnrFallback?: boolean; // default true + tags?: string[]; +} + +type UpdateArgs = Partial> & { id: string; script?: string }; + +interface UserscriptRecord { + id: string; + name?: string; + description?: string; + script: string; + sourceType: 'JS' | 'CSS' | 'TM'; + matches: string[]; + excludes: string[]; + runAt: 'document_start' | 'document_end' | 'document_idle'; + world: 'ISOLATED' | 'MAIN'; + allFrames: boolean; + persist: boolean; + dnrFallback: boolean; + tags?: string[]; + enabled: boolean; + createdAt: number; + updatedAt: number; + installedBy?: string; + lastError?: string; + applyCount?: number; + lastAppliedAt?: number; + sha256?: string; + cspBlocked?: boolean; +} + +// In-memory tracking of active injections per tab +type ActiveInjection = { kind: 'css' | 'js'; world?: 'ISOLATED' | 'MAIN' }; +const activeInjections: Map> = new Map(); + +async function loadAllRecords(): Promise> { + const res = await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS]); + return (res[STORAGE_KEYS.USERSCRIPTS] as Record) || {}; +} + +async function saveAllRecords(records: Record): Promise { + await chrome.storage.local.set({ [STORAGE_KEYS.USERSCRIPTS]: records }); +} + +// Simple FNV-1a hash for deterministic IDs +function fnv1a(str: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); + } + // Force to unsigned and hex + return (h >>> 0).toString(16); +} + +function now(): number { + return Date.now(); +} + +async function computeSHA256(input: string): Promise { + const enc = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest('SHA-256', enc); + const bytes = Array.from(new Uint8Array(digest)); + return bytes.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +async function probeUnsafeEvalInMain(tabId: number): Promise { + try { + const res = await chrome.scripting.executeScript({ + target: { tabId, allFrames: false }, + world: ExecutionWorld.MAIN, + func: () => { + try { + // If page CSP blocks unsafe-eval, this will throw + return !!new Function('return 1')(); + } catch { + return false; + } + }, + }); + return Array.isArray(res) && res[0] && (res[0] as any).result === true; + } catch { + return false; + } +} + +// Basic TM header parser (subset) +function parseUserscriptMeta(source: string): { + meta: Record; + isTM: boolean; +} { + const meta: Record = {}; + const start = source.indexOf('==UserScript=='); + const end = source.indexOf('==/UserScript=='); + if (start !== -1 && end !== -1 && end > start) { + const block = source.slice(start, end).split(/\r?\n/); + for (const line of block) { + const m = line.match(/@([\w-]+)\s+(.+)/); + if (m) { + const k = m[1].trim(); + const v = m[2].trim(); + if (!meta[k]) meta[k] = []; + meta[k].push(v); + } + } + return { meta, isTM: true }; + } + return { meta: {}, isTM: false }; +} + +function pick(arr: T[] | undefined): T | undefined { + return arr && arr.length > 0 ? arr[0] : undefined; +} + +function deriveName(meta: Record, fallback?: string): string | undefined { + return pick(meta['name']) || fallback; +} + +function toBoolean(val: any, d: boolean): boolean { + return typeof val === 'boolean' ? val : d; +} + +// Very light CSS heuristic +function isLikelyCSS(source: string): boolean { + const trimmed = source.trim(); + if (trimmed.startsWith('/*') && trimmed.includes('==UserStyle')) return true; + if (/^[.#\w\-\s*,:>+~\n\r{}();'"%!@/]+$/.test(trimmed)) { + // no obvious JS keywords + if ( + !/(function|=>|var\s|let\s|const\s|document\.|window\.|\beval\b|new\s+Function)/.test(trimmed) + ) { + // has CSS braces and colons + const colon = (trimmed.match(/:/g) || []).length; + const brace = (trimmed.match(/[{}]/g) || []).length; + return colon > 0 && brace >= 2; + } + } + return false; +} + +function normalizeMatches(matches?: string[], currentUrl?: string): string[] { + if (matches && matches.length > 0) return matches; + if (!currentUrl) return ['']; + try { + const u = new URL(currentUrl); + const host = u.hostname; + const base = host.startsWith('www.') ? host.slice(4) : host; + return [`${u.protocol}//*.${base}/*`, `${u.protocol}//${host}/*`]; + } catch { + return ['']; + } +} + +// Simple URL match using chrome match patterns subset +function matchUrl(patterns: string[], url?: string): boolean { + if (!url) return false; + try { + const u = new URL(url); + for (const p of patterns) { + if (p === '') return true; + const m = p.match(/^(\*|https?:)\/\/([^/]+)\/(.*)$/); + if (!m) continue; + const proto = m[1]; + const host = m[2]; + const path = m[3]; + if (proto !== '*' && proto !== u.protocol.replace(':', '')) continue; + // host wildcard + const hostRegex = new RegExp( + '^' + + host + .split('.') + .map((h) => (h === '*' ? '[^.]+' : h.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'))) + .join('\\.') + + '$', + ); + if (!hostRegex.test(u.hostname)) continue; + // path wildcard + const pathRegex = new RegExp( + '^' + path.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', + ); + const testPath = (u.pathname + (u.search || '') + (u.hash || '')).replace(/^\//, ''); + if (pathRegex.test(testPath)) return true; + } + } catch { + return false; + } + return false; +} + +async function getActiveTab(): Promise { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + return tabs[0] || null; +} + +async function insertCssToTab(tabId: number, css: string, allFrames: boolean) { + await chrome.scripting.insertCSS({ target: { tabId, allFrames }, css }); +} + +async function removeCssFromTab(tabId: number, css: string, allFrames: boolean) { + try { + await chrome.scripting.removeCSS({ target: { tabId, allFrames }, css }); + } catch (e) { + // ignore if not present + } +} + +async function injectJsPersistent( + tabId: number, + code: string, + world: 'ISOLATED' | 'MAIN', + allFrames: boolean, +) { + if (world === ExecutionWorld.MAIN) { + // Ensure bridge is present in ISOLATED + await chrome.scripting.executeScript({ + target: { tabId, allFrames }, + files: ['inject-scripts/inject-bridge.js'], + world: ExecutionWorld.ISOLATED, + }); + // MAIN world code with command handler wrapper + const wrapped = `(() => { + try { + // Optional command API: window.__userscript_onCommand(action, payload) + window.addEventListener('chrome-mcp:execute', (ev) => { + const { action, payload, requestId } = ev.detail || {}; + try { + let result; + const handler = (window as any).__userscript_onCommand; + if (typeof handler === 'function') { + result = handler(action, payload); + } + window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, data: result } })); + } catch (err) { + window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, error: String(err && (err as any).message || err) } })); + } + }); + (new Function(${JSON.stringify(code)}))(); + } catch (e) { + console.warn('Userscript MAIN injection error:', e); + } + })();`; + await chrome.scripting.executeScript({ + target: { tabId, allFrames }, + func: (src) => { + try { + // Using Function constructor intentionally to evaluate user-provided script + new Function(src)(); + } catch (e) { + console.warn('Userscript MAIN wrapper execution error:', e); + } + }, + args: [wrapped], + world: ExecutionWorld.MAIN, + }); + } else { + // ISOLATED world code with message handler + await chrome.scripting.executeScript({ + target: { tabId, allFrames }, + func: (userCode) => { + try { + const handlerName = '__userscript_onCommand__'; + (chrome.runtime.onMessage as any).addListener( + (req: any, _sender: any, sendResponse: any) => { + if (!req || req.type !== 'userscript:command') return; + const { action, payload, scriptId } = req; + try { + const handler = (globalThis as any)[handlerName]; + let result; + if (typeof handler === 'function') { + result = handler(action, payload, scriptId); + } + sendResponse({ data: result }); + } catch (err) { + sendResponse({ error: String((err && (err as any).message) || err) }); + } + return true; + }, + ); + // Using Function constructor intentionally to evaluate user-provided script + new Function(userCode)(); + } catch (e) { + console.warn('Userscript ISOLATED injection error:', e); + } + }, + args: [code], + world: ExecutionWorld.ISOLATED, + }); + } +} + +function setActiveInjection(tabId: number, id: string, inj: ActiveInjection) { + let m = activeInjections.get(tabId); + if (!m) { + m = new Map(); + activeInjections.set(tabId, m); + } + m.set(id, inj); +} + +function clearActiveInjection(tabId: number, id: string) { + const m = activeInjections.get(tabId); + if (m) m.delete(id); +} + +async function reinjectForTab(tabId: number, url?: string) { + // Emergency global switch + const flag = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[ + STORAGE_KEYS.USERSCRIPTS_DISABLED + ]; + if (flag) return; + const all = await loadAllRecords(); + for (const rec of Object.values(all)) { + if (!rec.enabled || !rec.persist) continue; + if (!matchUrl(rec.matches, url)) continue; + try { + if (rec.sourceType === 'CSS') { + await insertCssToTab(tabId, rec.script, rec.allFrames); + setActiveInjection(tabId, rec.id, { kind: 'css' }); + } else { + // Probe CSP when targeting MAIN + if (rec.world === 'MAIN') { + const ok = await probeUnsafeEvalInMain(tabId); + if (!ok) { + rec.cspBlocked = true; + await injectJsPersistent(tabId, rec.script, 'ISOLATED', rec.allFrames); + setActiveInjection(tabId, rec.id, { kind: 'js', world: 'ISOLATED' }); + continue; + } + } + await injectJsPersistent(tabId, rec.script, rec.world, rec.allFrames); + setActiveInjection(tabId, rec.id, { kind: 'js', world: rec.world }); + } + } catch (e) { + console.warn('Reinject failed for tab', tabId, rec.id, e); + } + } +} + +// Tab update listener: re-apply enabled persistent scripts +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete') { + reinjectForTab(tabId, tab.url).catch(() => {}); + } +}); + +// webNavigation based runAt mapping +chrome.webNavigation.onCommitted.addListener(async (details) => { + if (details.frameId !== 0) return; + const tab = await chrome.tabs.get(details.tabId).catch(() => null); + if (!tab) return; + const disabled = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[ + STORAGE_KEYS.USERSCRIPTS_DISABLED + ]; + if (disabled) return; + const all = await loadAllRecords(); + for (const rec of Object.values(all)) { + if (!rec.enabled || !rec.persist || rec.runAt !== 'document_start') continue; + if (!matchUrl(rec.matches, tab.url)) continue; + try { + if (rec.sourceType === 'CSS') await insertCssToTab(details.tabId, rec.script, rec.allFrames); + else await injectJsPersistent(details.tabId, rec.script, rec.world, rec.allFrames); + } catch { + // noop + } + } +}); + +chrome.webNavigation.onDOMContentLoaded.addListener(async (details) => { + if (details.frameId !== 0) return; + const tab = await chrome.tabs.get(details.tabId).catch(() => null); + if (!tab) return; + const disabled = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[ + STORAGE_KEYS.USERSCRIPTS_DISABLED + ]; + if (disabled) return; + const all = await loadAllRecords(); + for (const rec of Object.values(all)) { + if (!rec.enabled || !rec.persist || rec.runAt !== 'document_end') continue; + if (!matchUrl(rec.matches, tab.url)) continue; + try { + if (rec.sourceType === 'CSS') await insertCssToTab(details.tabId, rec.script, rec.allFrames); + else await injectJsPersistent(details.tabId, rec.script, rec.world, rec.allFrames); + } catch { + // noop + } + } +}); + +class UserscriptTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.USERSCRIPT; + + async execute(params: UserscriptArgsBase): Promise { + try { + const { action } = params; + const args = params.args || {}; + + switch (action) { + case 'create': + return await this.create(args as CreateArgs); + case 'list': + return await this.list(args); + case 'get': + return await this.get(args); + case 'enable': + return await this.enable(args, true); + case 'disable': + return await this.enable(args, false); + case 'update': + return await this.update(args as UpdateArgs); + case 'remove': + return await this.remove(args); + case 'send_command': + return await this.sendCommand(args); + case 'export': + return await this.exportAll(); + default: + return createErrorResponse(`Unknown action: ${String(action)}`); + } + } catch (error) { + console.error('Userscript tool error:', error); + return createErrorResponse( + `Userscript error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async create(args: CreateArgs): Promise { + const active = await getActiveTab(); + if (!active || !active.id) return createErrorResponse('No active tab found'); + const currentUrl = active.url; + + const emergency = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[ + STORAGE_KEYS.USERSCRIPTS_DISABLED + ]; + + const { meta, isTM } = parseUserscriptMeta(args.script); + const name = args.name || deriveName(meta, undefined); + const description = args.description || pick(meta['description']); + const matches = normalizeMatches(args.matches || meta['match'] || meta['include'], currentUrl); + const excludes = args.excludes || meta['exclude'] || []; + + const runAt: UserscriptRecord['runAt'] = + (args.runAt && args.runAt !== 'auto' ? args.runAt : (pick(meta['run-at']) as any)) || + 'document_idle'; + const requestedWorld = + (args.world && args.world !== 'auto' ? args.world : (pick(meta['inject-into']) as any)) || + 'ISOLATED'; + const allFrames = toBoolean(args.allFrames, true); + const persist = toBoolean(args.persist, true); + const dnrFallback = toBoolean(args.dnrFallback, true); + const mode = args.mode || 'auto'; + + const sourceType: UserscriptRecord['sourceType'] = isTM + ? 'TM' + : mode === 'css' || isLikelyCSS(args.script) + ? 'CSS' + : 'JS'; + + const sha256 = await computeSHA256(args.script).catch(() => undefined); + const id = `us_${fnv1a((name || '') + '|' + args.script)}`; + + const record: UserscriptRecord = { + id, + name, + description, + script: args.script, + sourceType, + matches, + excludes, + runAt, + world: requestedWorld === 'MAIN' ? 'MAIN' : 'ISOLATED', + allFrames, + persist, + dnrFallback, + tags: args.tags, + enabled: true, + createdAt: now(), + updatedAt: now(), + applyCount: 0, + sha256, + }; + + const all = await loadAllRecords(); + if (record.persist) { + all[id] = record; + await saveAllRecords(all); + } + + // Apply to current tab immediately if matches + let applied = false; + const fallbacks: string[] = []; + let cspBlocked = false; + const t0 = performance.now(); + try { + if (mode === 'once') { + // Once: CDP evaluate in page + await cdpSessionManager.withSession(active.id!, 'userscript_once', async () => { + const expression = `(function(){try{return (function(){${record.script}\n})()}catch(e){return {__error:String(e&&e.message||e)}}})()`; + const result: any = await cdpSessionManager.sendCommand(active.id!, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result?.result?.value?.__error) { + throw new Error(result.result.value.__error); + } + }); + applied = true; + } else if (sourceType === 'CSS') { + await insertCssToTab(active.id!, record.script, record.allFrames); + setActiveInjection(active.id!, id, { kind: 'css' }); + applied = true; + } else { + // Probe CSP preflight when target MAIN + if (record.world === 'MAIN') { + const ok = await probeUnsafeEvalInMain(active.id!); + if (!ok) { + cspBlocked = true; + fallbacks.push('MAIN->ISOLATED'); + await injectJsPersistent(active.id!, record.script, 'ISOLATED', record.allFrames); + setActiveInjection(active.id!, id, { kind: 'js', world: 'ISOLATED' }); + applied = true; + } + } + if (!applied) { + await injectJsPersistent(active.id!, record.script, record.world, record.allFrames); + setActiveInjection(active.id!, id, { kind: 'js', world: record.world }); + applied = true; + } + } + } catch (e) { + if (record.persist) { + all[id].lastError = e instanceof Error ? e.message : String(e); + all[id].cspBlocked = cspBlocked; + await saveAllRecords(all); + } + } + + const result = { + id, + status: record.persist && all[id]?.lastError ? 'queued' : applied ? 'applied' : 'queued', + strategy: { + kind: + mode === 'once' + ? 'once_cdp' + : sourceType === 'CSS' + ? 'insertCSS' + : `persistent_${(record.persist ? all[id]?.world || record.world : record.world).toLowerCase()}`, + runAt: record.persist ? all[id]?.runAt || record.runAt : record.runAt, + world: record.persist ? all[id]?.world || record.world : record.world, + allFrames: record.persist ? (all[id]?.allFrames ?? record.allFrames) : record.allFrames, + fallbacksTried: fallbacks, + cspBlocked, + }, + warnings: emergency ? ['USERSCRIPTS_DISABLED is ON, injection skipped'] : [], + metrics: { injectMs: Math.round(performance.now() - t0) }, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: false, + }; + } + + private async list(args: any): Promise { + const all = await loadAllRecords(); + const q = (args && args.query ? String(args.query).toLowerCase() : '').trim(); + const status = args && args.status ? String(args.status) : ''; + const domain = args && args.domain ? String(args.domain) : ''; + const items = Object.values(all) + .filter((r) => (status ? (status === 'enabled' ? r.enabled : !r.enabled) : true)) + .filter((r) => (domain ? matchUrl(r.matches, `https://${domain}/`) : true)) + .filter((r) => + q + ? (r.name || '').toLowerCase().includes(q) || + (r.description || '').toLowerCase().includes(q) + : true, + ) + .map((r) => ({ + id: r.id, + name: r.name, + status: r.enabled ? 'enabled' : 'disabled', + sourceType: r.sourceType, + matches: r.matches, + world: r.world, + runAt: r.runAt, + tags: r.tags || [], + lastError: r.lastError, + updatedAt: r.updatedAt, + applyCount: r.applyCount || 0, + lastAppliedAt: r.lastAppliedAt || null, + })); + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, items }) }], + isError: false, + }; + } + + private async get(args: any): Promise { + const { id } = args || {}; + if (!id) return createErrorResponse('id is required'); + const all = await loadAllRecords(); + const rec = all[id]; + if (!rec) return createErrorResponse('userscript not found'); + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, record: rec }) }], + isError: false, + }; + } + + private async enable(args: any, enabled: boolean): Promise { + const { id } = args || {}; + if (!id) return createErrorResponse('id is required'); + const all = await loadAllRecords(); + const rec = all[id]; + if (!rec) return createErrorResponse('userscript not found'); + rec.enabled = enabled; + rec.updatedAt = now(); + await saveAllRecords(all); + return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false }; + } + + private async update(args: UpdateArgs): Promise { + const { id, ...rest } = args; + if (!id) return createErrorResponse('id is required'); + const all = await loadAllRecords(); + const rec = all[id]; + if (!rec) return createErrorResponse('userscript not found'); + + if (rest.name !== undefined) rec.name = rest.name; + if (rest.description !== undefined) rec.description = rest.description; + if (rest.matches) rec.matches = rest.matches; + if (rest.excludes) rec.excludes = rest.excludes; + if (rest.runAt && rest.runAt !== 'auto') rec.runAt = rest.runAt; + if (rest.world && rest.world !== 'auto') rec.world = rest.world as any; + if (typeof rest.allFrames === 'boolean') rec.allFrames = rest.allFrames; + if (typeof rest.persist === 'boolean') rec.persist = rest.persist; + if (typeof rest.dnrFallback === 'boolean') rec.dnrFallback = rest.dnrFallback; + if (rest.tags) rec.tags = rest.tags; + if (typeof rest.script === 'string') rec.script = rest.script; + rec.updatedAt = now(); + await saveAllRecords(all); + return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false }; + } + + private async remove(args: any): Promise { + const { id } = args || {}; + if (!id) return createErrorResponse('id is required'); + const all = await loadAllRecords(); + const rec = all[id]; + if (!rec) return createErrorResponse('userscript not found'); + delete all[id]; + await saveAllRecords(all); + + // Attempt cleanup on active tab + const active = await getActiveTab(); + if (active && active.id) { + try { + if (rec.sourceType === 'CSS') { + await removeCssFromTab(active.id, rec.script, rec.allFrames); + } else { + // Send cleanup signal via bridge (MAIN) or ignore if isolated + chrome.tabs.sendMessage(active.id, { type: 'chrome-mcp:cleanup' }).catch(() => {}); + } + clearActiveInjection(active.id, rec.id); + } catch (err) { + console.warn('Userscript cleanup failed:', err); + } + } + + return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false }; + } + + private async sendCommand(args: any): Promise { + const { id, payload, tabId } = args || {}; + if (!id) return createErrorResponse('id is required'); + const tab = tabId ? await chrome.tabs.get(tabId).catch(() => null) : await getActiveTab(); + if (!tab || !tab.id) return createErrorResponse('No active tab found'); + + const all = await loadAllRecords(); + const rec = all[id]; + if (!rec) return createErrorResponse('userscript not found'); + + try { + if (rec.world === 'MAIN') { + // Use bridge + const result = await chrome.tabs.sendMessage(tab.id, { + action: 'userscript:command', + payload, + targetWorld: 'MAIN', + }); + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }], + isError: false, + }; + } else { + // ISOLATED handler + const result = await chrome.tabs.sendMessage(tab.id, { + type: 'userscript:command', + action: 'userscript:command', + payload, + scriptId: id, + }); + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }], + isError: false, + }; + } + } catch (e) { + return createErrorResponse( + `send_command failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + private async exportAll(): Promise { + const all = await loadAllRecords(); + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, data: all }) }], + isError: false, + }; + } +} + +export const userscriptTool = new UserscriptTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts b/app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts new file mode 100644 index 0000000..6e997ba --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts @@ -0,0 +1,308 @@ +/** + * Vectorized tab content search tool + * Uses vector database for efficient semantic search + */ + +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { ContentIndexer } from '@/utils/content-indexer'; +import { LIMITS, ERROR_MESSAGES } from '@/common/constants'; +import type { SearchResult } from '@/utils/vector-database'; + +interface VectorSearchResult { + tabId: number; + url: string; + title: string; + semanticScore: number; + matchedSnippet: string; + chunkSource: string; + timestamp: number; +} + +/** + * Tool for vectorized search of tab content using semantic similarity + */ +class VectorSearchTabsContentTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT; + private contentIndexer: ContentIndexer; + private isInitialized = false; + + constructor() { + super(); + this.contentIndexer = new ContentIndexer({ + autoIndex: true, + maxChunksPerPage: LIMITS.MAX_SEARCH_RESULTS, + skipDuplicates: true, + }); + } + + private async initializeIndexer(): Promise { + try { + await this.contentIndexer.initialize(); + this.isInitialized = true; + console.log('VectorSearchTabsContentTool: Content indexer initialized successfully'); + } catch (error) { + console.error('VectorSearchTabsContentTool: Failed to initialize content indexer:', error); + this.isInitialized = false; + } + } + + async execute(args: { query: string }): Promise { + try { + const { query } = args; + + if (!query || query.trim().length === 0) { + return createErrorResponse( + ERROR_MESSAGES.INVALID_PARAMETERS + ': Query parameter is required and cannot be empty', + ); + } + + console.log(`VectorSearchTabsContentTool: Starting vector search with query: "${query}"`); + + // Check semantic engine status + if (!this.contentIndexer.isSemanticEngineReady()) { + if (this.contentIndexer.isSemanticEngineInitializing()) { + return createErrorResponse( + 'Vector search engine is still initializing (model downloading). Please wait a moment and try again.', + ); + } else { + // Try to initialize + console.log('VectorSearchTabsContentTool: Initializing content indexer...'); + await this.initializeIndexer(); + + // Check semantic engine status again + if (!this.contentIndexer.isSemanticEngineReady()) { + return createErrorResponse('Failed to initialize vector search engine'); + } + } + } + + // Execute vector search, get more results for deduplication + const searchResults = await this.contentIndexer.searchContent(query, 50); + + // Convert search results format + const vectorSearchResults = this.convertSearchResults(searchResults); + + // Deduplicate by tab, keep only the highest similarity fragment per tab + const deduplicatedResults = this.deduplicateByTab(vectorSearchResults); + + // Sort by similarity and get top 10 results + const topResults = deduplicatedResults + .sort((a, b) => b.semanticScore - a.semanticScore) + .slice(0, 10); + + // Get index statistics + const stats = this.contentIndexer.getStats(); + + const result = { + success: true, + totalTabsSearched: stats.totalTabs, + matchedTabsCount: topResults.length, + vectorSearchEnabled: true, + indexStats: { + totalDocuments: stats.totalDocuments, + totalTabs: stats.totalTabs, + indexedPages: stats.indexedPages, + semanticEngineReady: stats.semanticEngineReady, + semanticEngineInitializing: stats.semanticEngineInitializing, + }, + matchedTabs: topResults.map((result) => ({ + tabId: result.tabId, + url: result.url, + title: result.title, + semanticScore: result.semanticScore, + matchedSnippets: [result.matchedSnippet], + chunkSource: result.chunkSource, + timestamp: result.timestamp, + })), + }; + + console.log( + `VectorSearchTabsContentTool: Found ${topResults.length} results with vector search`, + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + console.error('VectorSearchTabsContentTool: Search failed:', error); + return createErrorResponse( + `Vector search failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Ensure all tabs are indexed + */ + private async ensureTabsIndexed(tabs: chrome.tabs.Tab[]): Promise { + const indexPromises = tabs + .filter((tab) => tab.id) + .map(async (tab) => { + try { + await this.contentIndexer.indexTabContent(tab.id!); + } catch (error) { + console.warn(`VectorSearchTabsContentTool: Failed to index tab ${tab.id}:`, error); + } + }); + + await Promise.allSettled(indexPromises); + } + + /** + * Convert search results format + */ + private convertSearchResults(searchResults: SearchResult[]): VectorSearchResult[] { + return searchResults.map((result) => ({ + tabId: result.document.tabId, + url: result.document.url, + title: result.document.title, + semanticScore: result.similarity, + matchedSnippet: this.extractSnippet(result.document.chunk.text), + chunkSource: result.document.chunk.source, + timestamp: result.document.timestamp, + })); + } + + /** + * Deduplicate by tab, keep only the highest similarity fragment per tab + */ + private deduplicateByTab(results: VectorSearchResult[]): VectorSearchResult[] { + const tabMap = new Map(); + + for (const result of results) { + const existingResult = tabMap.get(result.tabId); + + // If this tab has no result yet, or current result has higher similarity, update it + if (!existingResult || result.semanticScore > existingResult.semanticScore) { + tabMap.set(result.tabId, result); + } + } + + return Array.from(tabMap.values()); + } + + /** + * Extract text snippet for display + */ + private extractSnippet(text: string, maxLength: number = 200): string { + if (text.length <= maxLength) { + return text; + } + + // Try to truncate at sentence boundary + const truncated = text.substring(0, maxLength); + const lastSentenceEnd = Math.max( + truncated.lastIndexOf('.'), + truncated.lastIndexOf('!'), + truncated.lastIndexOf('?'), + truncated.lastIndexOf('。'), + truncated.lastIndexOf('!'), + truncated.lastIndexOf('?'), + ); + + if (lastSentenceEnd > maxLength * 0.7) { + return truncated.substring(0, lastSentenceEnd + 1); + } + + // If no suitable sentence boundary found, truncate at word boundary + const lastSpaceIndex = truncated.lastIndexOf(' '); + if (lastSpaceIndex > maxLength * 0.8) { + return truncated.substring(0, lastSpaceIndex) + '...'; + } + + return truncated + '...'; + } + + /** + * Get index statistics + */ + public async getIndexStats() { + if (!this.isInitialized) { + // Don't automatically initialize - just return basic stats + return { + totalDocuments: 0, + totalTabs: 0, + indexSize: 0, + indexedPages: 0, + isInitialized: false, + semanticEngineReady: false, + semanticEngineInitializing: false, + }; + } + return this.contentIndexer.getStats(); + } + + /** + * Manually rebuild index + */ + public async rebuildIndex(): Promise { + if (!this.isInitialized) { + await this.initializeIndexer(); + } + + try { + // Clear existing indexes + await this.contentIndexer.clearAllIndexes(); + + // Get all tabs and reindex + const windows = await chrome.windows.getAll({ populate: true }); + const allTabs: chrome.tabs.Tab[] = []; + + for (const window of windows) { + if (window.tabs) { + allTabs.push(...window.tabs); + } + } + + const validTabs = allTabs.filter( + (tab) => + tab.id && + tab.url && + !tab.url.startsWith('chrome://') && + !tab.url.startsWith('chrome-extension://') && + !tab.url.startsWith('edge://') && + !tab.url.startsWith('about:'), + ); + + await this.ensureTabsIndexed(validTabs); + + console.log(`VectorSearchTabsContentTool: Rebuilt index for ${validTabs.length} tabs`); + } catch (error) { + console.error('VectorSearchTabsContentTool: Failed to rebuild index:', error); + throw error; + } + } + + /** + * Manually index specified tab + */ + public async indexTab(tabId: number): Promise { + if (!this.isInitialized) { + await this.initializeIndexer(); + } + + await this.contentIndexer.indexTabContent(tabId); + } + + /** + * Remove index for specified tab + */ + public async removeTabIndex(tabId: number): Promise { + if (!this.isInitialized) { + return; + } + + await this.contentIndexer.removeTabIndex(tabId); + } +} + +// Export tool instance +export const vectorSearchTabsContentTool = new VectorSearchTabsContentTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts b/app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts new file mode 100644 index 0000000..88e3f6e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts @@ -0,0 +1,243 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; + +interface WebFetcherToolParams { + htmlContent?: boolean; // get the visible HTML content of the current page. default: false + textContent?: boolean; // get the visible text content of the current page. default: true + url?: string; // optional URL to fetch content from (if not provided, uses active tab) + selector?: string; // optional CSS selector to get content from a specific element + tabId?: number; // target existing tab id + background?: boolean; // do not activate/focus + windowId?: number; // target window id to pick active tab or create tab +} + +class WebFetcherTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.WEB_FETCHER; + + /** + * Execute web fetcher operation + */ + async execute(args: WebFetcherToolParams): Promise { + // Handle mutually exclusive parameters: if htmlContent is true, textContent is forced to false + const htmlContent = args.htmlContent === true; + const textContent = htmlContent ? false : args.textContent !== false; // Default is true, unless htmlContent is true or textContent is explicitly set to false + const url = args.url; + const selector = args.selector; + const explicitTabId = args.tabId; + const background = args.background === true; + const windowId = args.windowId; + + console.log(`Starting web fetcher with options:`, { + htmlContent, + textContent, + url, + selector, + }); + + try { + // Get tab to fetch content from + let tab; + + if (typeof explicitTabId === 'number') { + tab = await chrome.tabs.get(explicitTabId); + } else if (url) { + // If URL is provided, check if it's already open + console.log(`Checking if URL is already open: ${url}`); + const allTabs = await chrome.tabs.query({}); + + // Find tab with matching URL + const matchingTabs = allTabs.filter((t) => { + // Normalize URLs for comparison (remove trailing slashes) + const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url; + const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url; + return tabUrl === targetUrl; + }); + + if (matchingTabs.length > 0) { + // Use existing tab + tab = matchingTabs[0]; + console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`); + } else { + // Create new tab with the URL + console.log(`No existing tab found with URL: ${url}, creating new tab`); + tab = await chrome.tabs.create({ url, active: background ? false : true }); + + // Wait for page to load + console.log('Waiting for page to load...'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } else { + // Use active tab (prefer specified window) + const tabs = + typeof windowId === 'number' + ? await chrome.tabs.query({ active: true, windowId }) + : await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0]) { + return createErrorResponse('No active tab found'); + } + tab = tabs[0]; + } + + if (!tab.id) { + return createErrorResponse('Tab has no ID'); + } + + // Optionally bring tab/window to foreground + if (!background) { + await chrome.tabs.update(tab.id, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + } + + // Prepare result object + const result: any = { + success: true, + url: tab.url, + title: tab.title, + }; + + await this.injectContentScript(tab.id, ['inject-scripts/web-fetcher-helper.js']); + + // Get HTML content if requested + if (htmlContent) { + const htmlResponse = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_HTML_CONTENT, + selector: selector, + }); + + if (htmlResponse.success) { + result.htmlContent = htmlResponse.htmlContent; + } else { + console.error('Failed to get HTML content:', htmlResponse.error); + result.htmlContentError = htmlResponse.error; + } + } + + // Get text content if requested (and htmlContent is not true) + if (textContent) { + const textResponse = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT, + selector: selector, + }); + + if (textResponse.success) { + result.textContent = textResponse.textContent; + + // Include article metadata if available + if (textResponse.article) { + result.article = { + title: textResponse.article.title, + byline: textResponse.article.byline, + siteName: textResponse.article.siteName, + excerpt: textResponse.article.excerpt, + lang: textResponse.article.lang, + }; + } + + // Include page metadata if available + if (textResponse.metadata) { + result.metadata = textResponse.metadata; + } + } else { + console.error('Failed to get text content:', textResponse.error); + result.textContentError = textResponse.error; + } + } + + // Interactive elements feature has been removed + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in web fetcher:', error); + return createErrorResponse( + `Error fetching web content: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const webFetcherTool = new WebFetcherTool(); + +interface GetInteractiveElementsToolParams { + textQuery?: string; // Text to search for within interactive elements (fuzzy search) + selector?: string; // CSS selector to filter interactive elements + includeCoordinates?: boolean; // Include element coordinates in the response (default: true) + types?: string[]; // Types of interactive elements to include (default: all types) +} + +class GetInteractiveElementsTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS; + + /** + * Execute get interactive elements operation + */ + async execute(args: GetInteractiveElementsToolParams): Promise { + const { textQuery, selector, includeCoordinates = true, types } = args; + + console.log(`Starting get interactive elements with options:`, args); + + try { + // Get current tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0]) { + return createErrorResponse('No active tab found'); + } + + const tab = tabs[0]; + if (!tab.id) { + return createErrorResponse('Active tab has no ID'); + } + + // Ensure content script is injected + await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']); + + // Send message to content script + const result = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS, + textQuery, + selector, + includeCoordinates, + types, + }); + + if (!result.success) { + return createErrorResponse(result.error || 'Failed to get interactive elements'); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + elements: result.elements, + count: result.elements.length, + query: { + textQuery, + selector, + types: types || 'all', + }, + }), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in get interactive elements operation:', error); + return createErrorResponse( + `Error getting interactive elements: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const getInteractiveElementsTool = new GetInteractiveElementsTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/window.ts b/app/chrome-extension/entrypoints/background/tools/browser/window.ts new file mode 100644 index 0000000..c541bbc --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/window.ts @@ -0,0 +1,54 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; + +class WindowTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS; + async execute(): Promise { + try { + const windows = await chrome.windows.getAll({ populate: true }); + let tabCount = 0; + + const structuredWindows = windows.map((window) => { + const tabs = + window.tabs?.map((tab) => { + tabCount++; + return { + tabId: tab.id || 0, + url: tab.url || '', + title: tab.title || '', + active: tab.active || false, + }; + }) || []; + + return { + windowId: window.id || 0, + tabs: tabs, + }; + }); + + const result = { + windowCount: windows.length, + tabCount: tabCount, + windows: structuredWindows, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result), + }, + ], + isError: false, + }; + } catch (error) { + console.error('Error in WindowTool.execute:', error); + return createErrorResponse( + `Error getting windows and tabs information: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export const windowTool = new WindowTool(); diff --git a/app/chrome-extension/entrypoints/background/tools/index.ts b/app/chrome-extension/entrypoints/background/tools/index.ts new file mode 100644 index 0000000..2625338 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/index.ts @@ -0,0 +1,34 @@ +import { createErrorResponse } from '@/common/tool-handler'; +import { ERROR_MESSAGES } from '@/common/constants'; +import * as browserTools from './browser'; +import { flowRunTool, listPublishedFlowsTool } from './record-replay'; + +const tools = { ...browserTools, flowRunTool, listPublishedFlowsTool } as any; +const toolsMap = new Map(Object.values(tools).map((tool: any) => [tool.name, tool])); + +/** + * Tool call parameter interface + */ +export interface ToolCallParam { + name: string; + args: any; +} + +/** + * Handle tool execution + */ +export const handleCallTool = async (param: ToolCallParam) => { + const tool = toolsMap.get(param.name); + if (!tool) { + return createErrorResponse(`Tool ${param.name} not found`); + } + + try { + return await tool.execute(param.args); + } catch (error) { + console.error(`Tool execution failed for ${param.name}:`, error); + return createErrorResponse( + error instanceof Error ? error.message : ERROR_MESSAGES.TOOL_EXECUTION_FAILED, + ); + } +}; diff --git a/app/chrome-extension/entrypoints/background/tools/record-replay.ts b/app/chrome-extension/entrypoints/background/tools/record-replay.ts new file mode 100644 index 0000000..9f26286 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/record-replay.ts @@ -0,0 +1,61 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { listPublished } from '../record-replay/flow-store'; +import { getFlow } from '../record-replay/flow-store'; +import { runFlow } from '../record-replay/flow-runner'; + +class FlowRunTool { + name = TOOL_NAMES.RECORD_REPLAY.FLOW_RUN; + async execute(args: any): Promise { + const { + flowId, + args: vars, + tabTarget, + refresh, + captureNetwork, + returnLogs, + timeoutMs, + startUrl, + } = args || {}; + if (!flowId) return createErrorResponse('flowId is required'); + const flow = await getFlow(flowId); + if (!flow) return createErrorResponse(`Flow not found: ${flowId}`); + const result = await runFlow(flow, { + tabTarget, + refresh, + captureNetwork, + returnLogs, + timeoutMs, + startUrl, + args: vars, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result), + }, + ], + isError: false, + }; + } +} + +class ListPublishedTool { + name = TOOL_NAMES.RECORD_REPLAY.LIST_PUBLISHED; + async execute(): Promise { + const list = await listPublished(); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, published: list }), + }, + ], + isError: false, + }; + } +} + +export const flowRunTool = new FlowRunTool(); +export const listPublishedFlowsTool = new ListPublishedTool(); diff --git a/app/chrome-extension/entrypoints/background/utils/sidepanel.ts b/app/chrome-extension/entrypoints/background/utils/sidepanel.ts new file mode 100644 index 0000000..aa03abd --- /dev/null +++ b/app/chrome-extension/entrypoints/background/utils/sidepanel.ts @@ -0,0 +1,59 @@ +/** + * Sidepanel Utilities + * + * Shared helpers for opening and managing the Chrome sidepanel from background modules. + * Used by web-editor, quick-panel, and other modules that need to trigger sidepanel navigation. + */ + +/** + * Best-effort open the sidepanel with AgentChat tab selected. + * + * @param tabId - Tab ID to associate with sidepanel + * @param windowId - Optional window ID for fallback when tab-level open fails + * @param sessionId - Optional session ID to navigate directly to chat view (deep-link) + * + * @remarks + * This function is intentionally resilient - it will not throw on failures. + * Sidepanel availability varies across Chrome versions and contexts. + */ +export async function openAgentChatSidepanel( + tabId: number, + windowId?: number, + sessionId?: string, +): Promise { + try { + // Build deep-link path with optional session navigation + let path = 'sidepanel.html?tab=agent-chat'; + if (sessionId) { + path += `&view=chat&sessionId=${encodeURIComponent(sessionId)}`; + } + + // Configure sidepanel options for this tab + + const sidePanel = chrome.sidePanel as any; + + if (sidePanel?.setOptions) { + await sidePanel.setOptions({ + tabId, + path, + enabled: true, + }); + } + + // Attempt to open the sidepanel + if (sidePanel?.open) { + try { + await sidePanel.open({ tabId }); + } catch { + // Fallback to window-level open if tab-level fails + // This handles cases where the tab is in a special state + if (typeof windowId === 'number') { + await sidePanel.open({ windowId }); + } + } + } + } catch { + // Best-effort: side panel may be unavailable in some Chrome versions/environments + // Intentionally suppress errors to avoid breaking calling code + } +} diff --git a/app/chrome-extension/entrypoints/background/web-editor/index.ts b/app/chrome-extension/entrypoints/background/web-editor/index.ts new file mode 100644 index 0000000..6643fa6 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/web-editor/index.ts @@ -0,0 +1,1641 @@ +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import { + WEB_EDITOR_V2_ACTIONS, + WEB_EDITOR_V1_ACTIONS, + type ElementChangeSummary, + type WebEditorApplyBatchPayload, + type WebEditorTxChangedPayload, + type WebEditorHighlightElementPayload, + type WebEditorRevertElementPayload, + type WebEditorCancelExecutionPayload, + type WebEditorCancelExecutionResponse, +} from '@/common/web-editor-types'; +import { openAgentChatSidepanel } from '../utils/sidepanel'; + +const CONTEXT_MENU_ID = 'web_editor_toggle'; +const COMMAND_KEY = 'toggle_web_editor'; +const DEFAULT_NATIVE_SERVER_PORT = 12306; + +/** Storage key prefix for TX change session data (per-tab isolation) */ +const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-'; +const WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-'; + +/** Storage key prefix for excluded element keys (per-tab isolation, managed by sidepanel) */ +const WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-'; + +/** Storage key for AgentChat selected session ID */ +const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id'; + +// In-memory execution status cache (per requestId) +interface ExecutionStatusEntry { + status: string; + message?: string; + updatedAt: number; + result?: { success: boolean; summary?: string; error?: string }; +} +const executionStatusCache = new Map(); +const STATUS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +function cleanupExpiredStatuses(): void { + const now = Date.now(); + for (const [key, entry] of executionStatusCache) { + if (now - entry.updatedAt > STATUS_CACHE_TTL) { + executionStatusCache.delete(key); + } + } +} + +function setExecutionStatus( + requestId: string, + status: string, + message?: string, + result?: ExecutionStatusEntry['result'], +): void { + executionStatusCache.set(requestId, { + status, + message, + updatedAt: Date.now(), + result, + }); + // Periodic cleanup + if (executionStatusCache.size > 100) { + cleanupExpiredStatuses(); + } +} + +function getExecutionStatus(requestId: string): ExecutionStatusEntry | undefined { + return executionStatusCache.get(requestId); +} + +// SSE connections for status updates (per sessionId) +const sseConnections = new Map(); + +/** + * Start SSE subscription for a session to receive status updates + */ +async function subscribeToSessionStatus( + sessionId: string, + requestId: string, + port: number, +): Promise { + // Close existing connection for this session if any + const existing = sseConnections.get(sessionId); + if (existing) { + existing.abort.abort(); + sseConnections.delete(sessionId); + } + + const abortController = new AbortController(); + sseConnections.set(sessionId, { abort: abortController, lastRequestId: requestId }); + + // Set initial status + setExecutionStatus(requestId, 'starting', 'Connecting to Agent...'); + + const sseUrl = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/stream`; + + try { + const response = await fetch(sseUrl, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + signal: abortController.signal, + }); + + if (!response.ok || !response.body) { + setExecutionStatus(requestId, 'running', 'Agent processing...'); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + setExecutionStatus(requestId, 'running', 'Agent processing...'); + + // Read SSE stream + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const data = JSON.parse(line.slice(5).trim()); + handleSseEvent(requestId, data); + } catch { + // Ignore parse errors + } + } + } + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + // Intentionally aborted, not an error + return; + } + // Connection error - mark as unknown but not failed (Agent may still be running) + const cached = getExecutionStatus(requestId); + if (cached && !['completed', 'failed', 'cancelled'].includes(cached.status)) { + setExecutionStatus(requestId, 'running', 'Agent processing (connection lost)...'); + } + } finally { + sseConnections.delete(sessionId); + } +} + +/** + * Handle SSE event from Agent stream + */ +function handleSseEvent(requestId: string, event: unknown): void { + if (!event || typeof event !== 'object') return; + const e = event as Record; + const type = e.type; + const data = e.data as Record | undefined; + + // Check if this event is for our request + const eventRequestId = data?.requestId as string | undefined; + if (eventRequestId && eventRequestId !== requestId) return; + + if (type === 'status' && data) { + const status = data.status as string; + const message = data.message as string | undefined; + + // Map Agent status to our status + // - 'ready' -> 'running' (ready is a running sub-state) + // - 'error' -> 'failed' (normalize server 'error' to UI 'failed') + let mappedStatus = status; + if (status === 'ready') mappedStatus = 'running'; + if (status === 'error') mappedStatus = 'failed'; + + setExecutionStatus(requestId, mappedStatus, message); + } else if (type === 'message' && data) { + // Update status to show we're receiving messages + const cached = getExecutionStatus(requestId); + if (cached && cached.status === 'starting') { + setExecutionStatus(requestId, 'running', 'Agent is working...'); + } + + // Check for completion indicators in message content + const role = data.role as string | undefined; + const isFinal = data.isFinal as boolean | undefined; + if (role === 'assistant' && isFinal) { + const content = data.content as string | undefined; + setExecutionStatus(requestId, 'completed', 'Completed', { + success: true, + summary: content?.slice(0, 200), + }); + } + } else if (type === 'error') { + const errorMsg = (e.error as string) || 'Unknown error'; + setExecutionStatus(requestId, 'failed', errorMsg, { + success: false, + error: errorMsg, + }); + } +} + +/** + * Web Editor version configuration + * - v1: Legacy inject-scripts/web-editor.js (IIFE, ~850 lines) + * - v2: New TypeScript-based web-editor-v2.js (WXT unlisted script) + * + * Set USE_WEB_EDITOR_V2 to true to enable v2. + * This flag allows gradual rollout and easy rollback. + */ +const USE_WEB_EDITOR_V2 = true; + +/** Script path for v1 (legacy) */ +const V1_SCRIPT_PATH = 'inject-scripts/web-editor.js'; + +/** Script path for v2 (WXT unlisted script output) */ +const V2_SCRIPT_PATH = 'web-editor-v2.js'; + +/** Script path for Phase 7 props agent (MAIN world) */ +const PROPS_AGENT_SCRIPT_PATH = 'inject-scripts/props-agent.js'; + +type WebEditorInstructionType = 'update_text' | 'update_style'; + +interface WebEditorFingerprint { + tag: string; + id?: string; + classes: string[]; + text?: string; +} + +/** Debug source from React/Vue fiber (file, line, component name) */ +interface DebugSource { + file: string; + line?: number; + column?: number; + componentName?: string; +} + +/** Style operation details (before/after diff) */ +interface StyleOperation { + type: 'update_style'; + before: Record; + after: Record; + removed: string[]; +} + +interface WebEditorApplyPayload { + pageUrl: string; + targetFile?: string; + fingerprint: WebEditorFingerprint; + techStackHint?: string[]; + instruction: { + type: WebEditorInstructionType; + description: string; + text?: string; + style?: Record; + }; + + // V2 extended fields (best-effort, optional) + selectorCandidates?: string[]; + debugSource?: DebugSource; + operation?: StyleOperation; +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => normalizeString(item)).filter(Boolean); +} + +function normalizeStyleMap(value: unknown): Record | undefined { + if (!value || typeof value !== 'object') return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const key = normalizeString(k).trim(); + const val = normalizeString(v).trim(); + if (!key || !val) continue; + out[key] = val; + } + return Object.keys(out).length ? out : undefined; +} + +function normalizeStyleMapAllowEmpty(value: unknown): Record | undefined { + if (!value || typeof value !== 'object') return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const key = normalizeString(k).trim(); + if (!key) continue; + // Allow empty values (represents removed styles) + out[key] = normalizeString(v).trim(); + } + return Object.keys(out).length ? out : undefined; +} + +function normalizeDebugSource(value: unknown): DebugSource | undefined { + if (!value || typeof value !== 'object') return undefined; + const obj = value as Record; + const file = normalizeString(obj.file).trim(); + if (!file) return undefined; + + const source: DebugSource = { file }; + const line = Number(obj.line); + if (Number.isFinite(line) && line > 0) source.line = line; + const column = Number(obj.column); + if (Number.isFinite(column) && column >= 0) source.column = column; + const componentName = normalizeString(obj.componentName).trim(); + if (componentName) source.componentName = componentName; + + return source; +} + +function normalizeOperation(value: unknown): StyleOperation | undefined { + if (!value || typeof value !== 'object') return undefined; + const obj = value as Record; + if (obj.type !== 'update_style') return undefined; + + const before = normalizeStyleMapAllowEmpty(obj.before); + const after = normalizeStyleMapAllowEmpty(obj.after); + const removed = normalizeStringArray(obj.removed); + + if (!before && !after && removed.length === 0) return undefined; + + return { + type: 'update_style', + before: before ?? {}, + after: after ?? {}, + removed, + }; +} + +function normalizeApplyPayload(raw: unknown): WebEditorApplyPayload { + const obj = (raw && typeof raw === 'object' ? raw : {}) as Record; + const pageUrl = normalizeString(obj.pageUrl).trim(); + const targetFile = normalizeString(obj.targetFile).trim() || undefined; + const techStackHint = normalizeStringArray(obj.techStackHint); + + const fingerprintRaw = ( + obj.fingerprint && typeof obj.fingerprint === 'object' ? obj.fingerprint : {} + ) as Record; + const fingerprint: WebEditorFingerprint = { + tag: normalizeString(fingerprintRaw.tag).trim() || 'unknown', + id: normalizeString(fingerprintRaw.id).trim() || undefined, + classes: normalizeStringArray(fingerprintRaw.classes), + text: normalizeString(fingerprintRaw.text).trim() || undefined, + }; + + const instructionRaw = ( + obj.instruction && typeof obj.instruction === 'object' ? obj.instruction : {} + ) as Record; + const type = normalizeString(instructionRaw.type).trim() as WebEditorInstructionType; + if (type !== 'update_text' && type !== 'update_style') { + throw new Error('Invalid instruction.type'); + } + + const instruction = { + type, + description: normalizeString(instructionRaw.description).trim() || '', + text: normalizeString(instructionRaw.text).trim() || undefined, + style: normalizeStyleMap(instructionRaw.style), + }; + + if (!pageUrl) { + throw new Error('pageUrl is required'); + } + if (!instruction.description) { + throw new Error('instruction.description is required'); + } + + // V2 extended fields (optional) + const selectorCandidates = normalizeStringArray(obj.selectorCandidates); + const debugSource = normalizeDebugSource(obj.debugSource); + const operation = normalizeOperation(obj.operation); + + return { + pageUrl, + targetFile, + fingerprint, + techStackHint: techStackHint.length ? techStackHint : undefined, + instruction, + selectorCandidates: selectorCandidates.length ? selectorCandidates : undefined, + debugSource, + operation, + }; +} + +/** + * Normalize and validate batch apply payload. + * Runtime validation for WebEditorApplyBatchPayload. + */ +function normalizeApplyBatchPayload(raw: unknown): WebEditorApplyBatchPayload { + const obj = (raw && typeof raw === 'object' ? raw : {}) as Record; + + const tabIdRaw = Number(obj.tabId); + const tabId = Number.isFinite(tabIdRaw) && tabIdRaw > 0 ? tabIdRaw : 0; + + const elements = Array.isArray(obj.elements) ? (obj.elements as ElementChangeSummary[]) : []; + + const excludedKeys = Array.isArray(obj.excludedKeys) + ? obj.excludedKeys.map((k) => normalizeString(k).trim()).filter((k): k is string => Boolean(k)) + : []; + + const pageUrl = normalizeString(obj.pageUrl).trim() || undefined; + + return { tabId, elements, excludedKeys, pageUrl }; +} + +/** + * Build a batch prompt for multiple element changes. + * Designed for AgentChat integration to apply multiple visual edits at once. + */ +function buildAgentPromptBatch(elements: readonly ElementChangeSummary[], pageUrl: string): string { + const lines: string[] = []; + + // Header + lines.push('You are a senior frontend engineer working in a local codebase.'); + lines.push( + 'Goal: persist a batch of visual edits from the browser into the source code with minimal changes.', + ); + lines.push(''); + + // Page context + lines.push(`Page URL: ${pageUrl}`); + lines.push(''); + + lines.push('## Batch Changes'); + lines.push(`Total elements: ${elements.length}`); + lines.push(''); + lines.push( + 'For each element, prefer "source" (file/line/component) when available; otherwise use selectors/fingerprint to locate it.', + ); + lines.push(''); + + // Element details + elements.forEach((element, index) => { + const title = element.fullLabel || element.label || element.elementKey; + lines.push(`### ${index + 1}. ${title}`); + lines.push(`- elementKey: ${element.elementKey}`); + lines.push(`- change type: ${element.type}`); + + // Debug source (high-confidence location) + const ds = element.debugSource ?? element.locator?.debugSource; + if (ds?.file) { + const loc = ds.line ? `${ds.file}:${ds.line}${ds.column ? `:${ds.column}` : ''}` : ds.file; + lines.push(`- source: ${loc}${ds.componentName ? ` (${ds.componentName})` : ''}`); + } + + // Locator hints for fallback + if (element.locator?.selectors?.length) { + lines.push('- selectors:'); + for (const sel of element.locator.selectors.slice(0, 5)) { + lines.push(` - ${sel}`); + } + } + if (element.locator?.fingerprint) { + lines.push(`- fingerprint: ${element.locator.fingerprint}`); + } + if (Array.isArray(element.locator?.path) && element.locator.path.length > 0) { + lines.push(`- path: ${JSON.stringify(element.locator.path)}`); + } + if (element.locator?.shadowHostChain?.length) { + lines.push(`- shadowHostChain: ${JSON.stringify(element.locator.shadowHostChain)}`); + } + lines.push(''); + + // Net effect details + const net = element.netEffect; + lines.push('#### Net Effect (apply these final values)'); + + if (net.textChange) { + lines.push('##### Text'); + lines.push(`- before: ${JSON.stringify(net.textChange.before)}`); + lines.push(`- after: ${JSON.stringify(net.textChange.after)}`); + lines.push(''); + } + + if (net.classChanges) { + lines.push('##### Classes'); + lines.push(`- before: ${net.classChanges.before.join(' ')}`); + lines.push(`- after: ${net.classChanges.after.join(' ')}`); + lines.push(''); + } + + if (net.styleChanges) { + lines.push('##### Styles (before → after)'); + const before = net.styleChanges.before ?? {}; + const after = net.styleChanges.after ?? {}; + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const key of Array.from(allKeys).sort()) { + const beforeVal = before[key] ?? '(unset)'; + const afterRaw = Object.prototype.hasOwnProperty.call(after, key) ? after[key] : '(unset)'; + const afterVal = afterRaw === '' ? '(removed)' : afterRaw; + if (beforeVal !== afterVal) { + lines.push(`- ${key}: "${beforeVal}" → "${afterVal}"`); + } + } + lines.push(''); + } + + // Fallback message if no specific changes + if (!net.textChange && !net.classChanges && !net.styleChanges) { + lines.push( + '- No net effect details available; use locator hints to inspect the element in code.', + ); + lines.push(''); + } + }); + + // Instructions + lines.push('## How to Apply'); + lines.push('1. Use "source" when available to go directly to the component file.'); + lines.push('2. Otherwise, use selectors/fingerprint/path to locate the element in the codebase.'); + lines.push('3. Apply the net effect with minimal changes and correct styling conventions.'); + lines.push('4. Avoid generated/bundled outputs; update source files only.'); + lines.push(''); + + // Output format + lines.push('## Constraints'); + lines.push('- Make the smallest safe edit possible for each element'); + lines.push( + '- If Tailwind/CSS Modules/styled-components are used, update the correct styling source', + ); + lines.push('- Do not change unrelated behavior or formatting'); + lines.push(''); + + lines.push( + '## Output\nApply all the changes in the repo, then reply with a short summary of what file(s) you modified and the exact changes made.', + ); + + return lines.join('\n'); +} + +function buildAgentPrompt(payload: WebEditorApplyPayload): string { + const lines: string[] = []; + + // Header + lines.push('You are a senior frontend engineer working in a local codebase.'); + lines.push( + 'Goal: persist a visual edit from the browser into the source code with minimal changes.', + ); + lines.push(''); + + // Page context + lines.push(`Page URL: ${payload.pageUrl}`); + lines.push(''); + + // == Source Location (high-confidence if debugSource available) == + const ds = payload.debugSource; + if (ds?.file) { + lines.push('## Source Location (from React/Vue debug info)'); + const loc = ds.line ? `${ds.file}:${ds.line}${ds.column ? `:${ds.column}` : ''}` : ds.file; + lines.push(`- file: ${loc}`); + if (ds.componentName) lines.push(`- component: ${ds.componentName}`); + lines.push(''); + lines.push('This is high-confidence source location extracted from framework debug info.'); + lines.push('Start your search here. Only fall back to fingerprint if this file is invalid.'); + lines.push(''); + } else if (payload.targetFile) { + lines.push(`## Target File (best-effort): ${payload.targetFile}`); + lines.push( + 'If this path is invalid or points to node_modules, fall back to fingerprint search.', + ); + lines.push(''); + } + + // == Element Fingerprint == + lines.push('## Element Fingerprint'); + lines.push(`- tag: ${payload.fingerprint.tag}`); + if (payload.fingerprint.id) lines.push(`- id: ${payload.fingerprint.id}`); + if (payload.fingerprint.classes?.length) { + lines.push(`- classes: ${payload.fingerprint.classes.join(' ')}`); + } + if (payload.fingerprint.text) lines.push(`- text: ${payload.fingerprint.text}`); + lines.push(''); + + // == CSS Selectors (for precise matching) == + if (payload.selectorCandidates?.length) { + lines.push('## CSS Selectors (ordered by specificity)'); + for (const sel of payload.selectorCandidates.slice(0, 5)) { + lines.push(`- ${sel}`); + } + lines.push(''); + lines.push('Use these selectors to grep the codebase if file location is unavailable.'); + lines.push(''); + } + + // == Tech Stack == + if (payload.techStackHint?.length) { + lines.push(`## Tech Stack: ${payload.techStackHint.join(', ')}`); + lines.push(''); + } + + // == Requested Change == + lines.push('## Requested Change'); + lines.push(`- type: ${payload.instruction.type}`); + lines.push(`- description: ${payload.instruction.description}`); + + if (payload.instruction.type === 'update_text' && payload.instruction.text !== undefined) { + lines.push(`- new text: ${JSON.stringify(payload.instruction.text)}`); + } + + // For style updates, show detailed before/after diff if available + if (payload.instruction.type === 'update_style') { + const op = payload.operation; + if (op && (Object.keys(op.before).length > 0 || Object.keys(op.after).length > 0)) { + lines.push(''); + lines.push('### Style Changes (before → after)'); + const allKeys = new Set([...Object.keys(op.before), ...Object.keys(op.after)]); + for (const key of allKeys) { + const before = op.before[key] ?? '(unset)'; + const after = op.after[key] ?? '(removed)'; + if (before !== after) { + lines.push(` ${key}: "${before}" → "${after}"`); + } + } + if (op.removed.length > 0) { + lines.push(` [Removed]: ${op.removed.join(', ')}`); + } + } else if (payload.instruction.style) { + lines.push(`- style map: ${JSON.stringify(payload.instruction.style, null, 2)}`); + } + } + lines.push(''); + + // == Instructions == + lines.push('## How to Apply'); + if (ds?.file) { + lines.push(`1. Open ${ds.file}${ds.line ? ` around line ${ds.line}` : ''}`); + if (ds.componentName) { + lines.push(`2. Locate the "${ds.componentName}" component definition`); + } + lines.push( + `3. Find the element matching tag="${payload.fingerprint.tag}"${payload.fingerprint.classes?.length ? ` with classes including "${payload.fingerprint.classes[0]}"` : ''}`, + ); + lines.push('4. Apply the requested style/text change'); + } else if (payload.targetFile) { + lines.push(`1. Open ${payload.targetFile}`); + lines.push('2. Search for the element by matching fingerprint (tag, classes, text)'); + lines.push('3. If not found, use repo-wide search with selectors or class names'); + lines.push('4. Apply the requested change'); + } else { + lines.push('1. Use repo-wide search (rg) with class names or text from fingerprint'); + if (payload.selectorCandidates?.length) { + lines.push(`2. Try searching for: "${payload.selectorCandidates[0]}"`); + } + lines.push('3. Locate the component/template containing this element'); + lines.push('4. Apply the requested change'); + } + lines.push(''); + + // == Constraints == + lines.push('## Constraints'); + lines.push('- Make the smallest safe edit possible'); + if (payload.techStackHint?.includes('Tailwind')) { + lines.push('- Tailwind detected: prefer updating className over inline styles'); + } + if (payload.techStackHint?.includes('React') || payload.techStackHint?.includes('Vue')) { + lines.push('- Update the component source, not generated/bundled code'); + } + lines.push('- If CSS Modules or styled-components are used, update the correct styling source'); + lines.push('- Do not change unrelated behavior or formatting'); + lines.push(''); + + // == Output == + lines.push( + '## Output\nApply the change in the repo, then reply with a short summary of what file(s) you modified and the exact change made.', + ); + + return lines.join('\n'); +} + +async function ensureContextMenu(): Promise { + try { + if (!(chrome as any).contextMenus?.create) return; + try { + await chrome.contextMenus.remove(CONTEXT_MENU_ID); + } catch {} + await chrome.contextMenus.create({ + id: CONTEXT_MENU_ID, + title: '切换网页编辑模式', + contexts: ['all'], + }); + } catch (error) { + console.warn('[WebEditor] Failed to ensure context menu:', error); + } +} + +/** + * Get the appropriate action constants based on version + */ +function getActions() { + return USE_WEB_EDITOR_V2 ? WEB_EDITOR_V2_ACTIONS : WEB_EDITOR_V1_ACTIONS; +} + +/** + * Ensure the web editor script is injected into the tab + * Supports both v1 (legacy) and v2 (new) versions + * + * V1 and V2 use different action names to avoid conflicts: + * - V1: web_editor_ping, web_editor_toggle, etc. + * - V2: web_editor_ping_v2, web_editor_toggle_v2, etc. + */ +async function ensureEditorInjected(tabId: number): Promise { + const scriptPath = USE_WEB_EDITOR_V2 ? V2_SCRIPT_PATH : V1_SCRIPT_PATH; + const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]'; + const actions = getActions(); + + // Try to ping existing instance using version-specific action + try { + const pong: { status?: string; version?: number } = await chrome.tabs.sendMessage( + tabId, + { action: actions.PING }, + { frameId: 0 }, + ); + + if (pong?.status === 'pong') { + // Already injected with correct version + return; + } + } catch { + // No existing instance, fallthrough to inject + } + + // Inject the script + try { + await chrome.scripting.executeScript({ + target: { tabId }, + files: [scriptPath], + world: 'ISOLATED', + }); + console.log(`${logPrefix} Script injected successfully`); + } catch (error) { + console.warn(`${logPrefix} Failed to inject editor script:`, error); + } +} + +/** + * Inject props agent into MAIN world for Phase 7 Props editing + * Only inject for v2 editor + */ +async function ensurePropsAgentInjected(tabId: number): Promise { + if (!USE_WEB_EDITOR_V2) return; + + try { + await chrome.scripting.executeScript({ + target: { tabId }, + files: [PROPS_AGENT_SCRIPT_PATH], + world: 'MAIN', + }); + } catch (error) { + // Best-effort: some pages (chrome://, extensions, PDF) block injection + console.warn('[WebEditorV2] Failed to inject props agent:', error); + } +} + +/** + * Send cleanup event to props agent + */ +async function sendPropsAgentCleanup(tabId: number): Promise { + if (!USE_WEB_EDITOR_V2) return; + + try { + // Dispatch cleanup event in ISOLATED world + // CustomEvent crosses worlds and is observed by MAIN agent + await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + try { + window.dispatchEvent(new CustomEvent('web-editor-props:cleanup')); + } catch { + // ignore + } + }, + world: 'ISOLATED', + }); + } catch (error) { + // Best-effort cleanup; ignore failures if tab is gone or injection blocked + console.warn('[WebEditorV2] Failed to send props agent cleanup:', error); + } +} + +// ============================================================================= +// Phase 7.1.6: Early Injection for Props Agent +// ============================================================================= + +/** + * Content script ID prefix for early injection (document_start). + * Registered scripts persist across sessions and survive browser restarts. + */ +const PROPS_AGENT_EARLY_INJECTION_ID_PREFIX = 'mcp_we_props_early'; + +/** + * Result of early injection registration + */ +interface EarlyInjectionResult { + id: string; + host: string; + matches: string[]; + alreadyRegistered: boolean; +} + +/** + * Sanitize a string for use in content script ID + * Only allows alphanumeric, underscore, and hyphen + */ +function sanitizeContentScriptId(input: string): string { + const cleaned = String(input ?? '') + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, ''); + return cleaned.slice(0, 80) || 'site'; +} + +/** + * Build match patterns from tab URL for early injection. + * Returns patterns for the specific host only (not all URLs). + */ +function buildEarlyInjectionPatterns(tabUrl: string): { host: string; matches: string[] } { + let url: URL; + try { + url = new URL(tabUrl); + } catch { + throw new Error('Invalid tab URL'); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Early injection only supports http/https pages (got ${url.protocol})`); + } + + const host = url.hostname.trim(); + if (!host) { + throw new Error('Unable to derive host from tab URL'); + } + + // Match all paths on this host for both http and https + return { host, matches: [`*://${host}/*`] }; +} + +/** + * Register props agent for early injection (document_start, MAIN world). + * This allows capturing React DevTools hook before React initializes. + * + * The registration is per-host and persists across sessions. + */ +async function registerPropsAgentEarlyInjection(tabUrl: string): Promise { + const { host, matches } = buildEarlyInjectionPatterns(tabUrl); + const id = `${PROPS_AGENT_EARLY_INJECTION_ID_PREFIX}_${sanitizeContentScriptId(host)}`; + + // Check if already registered (idempotent) + let alreadyRegistered = false; + try { + const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [id] }); + alreadyRegistered = existing.some((s) => s.id === id); + } catch { + // API might not support getRegisteredContentScripts in all contexts + alreadyRegistered = false; + } + + if (!alreadyRegistered) { + await chrome.scripting.registerContentScripts([ + { + id, + js: [PROPS_AGENT_SCRIPT_PATH], + matches, + runAt: 'document_start', + world: 'MAIN', + allFrames: false, + persistAcrossSessions: true, + }, + ]); + console.log(`[WebEditorV2] Registered early injection for ${host}`); + } + + return { id, host, matches, alreadyRegistered }; +} + +async function toggleEditorInTab(tabId: number): Promise<{ active?: boolean }> { + await ensureEditorInjected(tabId); + const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]'; + const actions = getActions(); + + try { + const resp: { active?: boolean } = await chrome.tabs.sendMessage( + tabId, + { action: actions.TOGGLE }, + { frameId: 0 }, + ); + const active = typeof resp?.active === 'boolean' ? resp.active : undefined; + + // Phase 7: Inject props agent on start; cleanup on stop + if (active === true) { + await ensurePropsAgentInjected(tabId); + } else if (active === false) { + await sendPropsAgentCleanup(tabId); + } + + return { active }; + } catch (error) { + console.warn(`${logPrefix} Failed to toggle editor in tab:`, error); + return {}; + } +} + +async function getActiveTabId(): Promise { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + return typeof tabId === 'number' ? tabId : null; + } catch { + return null; + } +} + +export function initWebEditorListeners(): void { + ensureContextMenu().catch(() => {}); + + // Clean up session storage when tab is closed to avoid stale data + chrome.tabs.onRemoved.addListener((tabId) => { + try { + const keys = [ + `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`, + `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`, + `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`, + ]; + chrome.storage.session.remove(keys).catch(() => {}); + } catch {} + }); + + if ((chrome as any).contextMenus?.onClicked?.addListener) { + chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + if (info.menuItemId !== CONTEXT_MENU_ID) return; + const tabId = tab?.id; + if (typeof tabId !== 'number') return; + await toggleEditorInTab(tabId); + } catch {} + }); + } + + chrome.commands.onCommand.addListener(async (command) => { + try { + if (command !== COMMAND_KEY) return; + const tabId = await getActiveTabId(); + if (typeof tabId !== 'number') return; + await toggleEditorInTab(tabId); + } catch {} + }); + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + try { + // Phase 7.1.6: Handle early injection registration request + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION) { + (async () => { + const senderTab = (_sender as chrome.runtime.MessageSender)?.tab; + const senderTabId = senderTab?.id; + const senderTabUrl = senderTab?.url; + + if (typeof senderTabId !== 'number' || typeof senderTabUrl !== 'string') { + return sendResponse({ + success: false, + error: 'Sender tab information is required', + }); + } + + try { + const result = await registerPropsAgentEarlyInjection(senderTabUrl); + + // Respond first, then reload (to avoid message port closing during navigation) + sendResponse({ success: true, ...result }); + + // Small delay to ensure response is sent before navigation + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Reload the tab so early injection takes effect + try { + await chrome.tabs.reload(senderTabId); + } catch { + // Best-effort: some tabs may block reload + } + } catch (err) { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } + })(); + return true; // Async response + } + + // ===================================================================== + // WEB_EDITOR_OPEN_SOURCE: Open component source file in VSCode + // ===================================================================== + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE) { + (async () => { + try { + const payload = message.payload as { debugSource?: unknown } | undefined; + const debugSource = payload?.debugSource; + + if (!debugSource || typeof debugSource !== 'object') { + return sendResponse({ success: false, error: 'debugSource is required' }); + } + + const rec = debugSource as Record; + const file = typeof rec.file === 'string' ? rec.file.trim() : ''; + if (!file) { + return sendResponse({ success: false, error: 'debugSource.file is required' }); + } + + // Read server port and selected project + const stored = await chrome.storage.local.get([ + 'nativeServerPort', + 'agent-selected-project-id', + ]); + const portRaw = stored.nativeServerPort; + const port = Number.isFinite(Number(portRaw)) + ? Number(portRaw) + : DEFAULT_NATIVE_SERVER_PORT; + const projectId = stored['agent-selected-project-id']; + + if (!projectId || typeof projectId !== 'string') { + return sendResponse({ + success: false, + error: 'No project selected. Please select a project in AgentChat first.', + }); + } + + // Prepare line/column + const lineRaw = Number(rec.line); + const columnRaw = Number(rec.column); + const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined; + const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined; + + // Call native-server to open file (server will validate project and path) + const openResp = await fetch( + `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open-file`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filePath: file, + line, + column, + }), + }, + ); + + // Try to parse JSON response for detailed error + let result: { success: boolean; error?: string }; + try { + result = await openResp.json(); + } catch { + const text = await openResp.text().catch(() => ''); + result = { + success: false, + error: text || `HTTP ${openResp.status}`, + }; + } + + sendResponse(result); + } catch (err) { + sendResponse({ + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } + })(); + return true; // Async response + } + + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE) { + getActiveTabId() + .then(async (tabId) => { + if (typeof tabId !== 'number') return sendResponse({ success: false }); + const result = await toggleEditorInTab(tabId); + sendResponse({ success: true, ...result }); + }) + .catch(() => sendResponse({ success: false })); + return true; + } + + // ======================================================================= + // Phase 1.5: Handle TX_CHANGED broadcast from web-editor + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) { + (async () => { + const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id; + if (typeof senderTabId !== 'number') { + sendResponse({ success: false, error: 'Sender tabId is required' }); + return; + } + + const rawPayload = message.payload as WebEditorTxChangedPayload | undefined; + if (!rawPayload || typeof rawPayload !== 'object') { + sendResponse({ success: false, error: 'Invalid payload' }); + return; + } + + // Hydrate payload with tabId from sender + const payload: WebEditorTxChangedPayload = { ...rawPayload, tabId: senderTabId }; + const storageKey = `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${senderTabId}`; + + // Persist to session storage for cold-start recovery + // Remove keys on clear to avoid stale data (rollback still has edits, so keep it) + if (payload.action === 'clear') { + // Clear TX state and excluded keys together + const excludedKey = `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${senderTabId}`; + await chrome.storage.session.remove([storageKey, excludedKey]); + } else { + await chrome.storage.session.set({ [storageKey]: payload }); + } + + // Broadcast to sidepanel (best-effort, ignore errors if sidepanel is closed) + chrome.runtime + .sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED, + payload, + }) + .catch(() => { + // Ignore errors - sidepanel may be closed + }); + + sendResponse({ success: true }); + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + + // ======================================================================= + // Selection sync: Handle SELECTION_CHANGED broadcast from web-editor + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) { + (async () => { + const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id; + if (typeof senderTabId !== 'number') { + sendResponse({ success: false, error: 'Sender tabId is required' }); + return; + } + + const rawPayload = message.payload as + | import('@/common/web-editor-types').WebEditorSelectionChangedPayload + | undefined; + if (!rawPayload || typeof rawPayload !== 'object') { + sendResponse({ success: false, error: 'Invalid payload' }); + return; + } + + // Hydrate payload with tabId from sender + const payload = { ...rawPayload, tabId: senderTabId }; + const storageKey = `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${senderTabId}`; + + // Persist to session storage for cold-start recovery + // Remove key on deselection to avoid stale data + if (payload.selected === null) { + await chrome.storage.session.remove(storageKey); + } else { + await chrome.storage.session.set({ [storageKey]: payload }); + } + + // Broadcast to sidepanel (best-effort, ignore errors if sidepanel is closed) + chrome.runtime + .sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED, + payload, + }) + .catch(() => { + // Ignore errors - sidepanel may be closed + }); + + sendResponse({ success: true }); + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + + // ======================================================================= + // Clear selection: Handle CLEAR_SELECTION from sidepanel (after send) + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CLEAR_SELECTION) { + (async () => { + const payload = message.payload as { tabId?: number } | undefined; + const targetTabId = payload?.tabId; + + if (typeof targetTabId !== 'number' || targetTabId <= 0) { + sendResponse({ success: false, error: 'Invalid tabId' }); + return; + } + + // Forward to content script (web-editor-v2) + try { + await chrome.tabs.sendMessage(targetTabId, { + action: WEB_EDITOR_V2_ACTIONS.CLEAR_SELECTION, + }); + sendResponse({ success: true }); + } catch (error) { + // Tab may be closed or web-editor not active - this is expected + sendResponse({ + success: false, + error: error instanceof Error ? error.message : 'Failed to send to tab', + }); + } + })().catch((error) => { + // Catch any unhandled errors in the async IIFE + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + + // ======================================================================= + // Phase 1.5: Handle APPLY_BATCH from web-editor toolbar + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY_BATCH) { + const payload = normalizeApplyBatchPayload(message.payload); + (async () => { + const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id; + const senderWindowId = (_sender as chrome.runtime.MessageSender)?.tab?.windowId; + + // Read storage for server port and selected session + const stored = await chrome.storage.local.get([ + 'nativeServerPort', + STORAGE_KEY_SELECTED_SESSION, + ]); + + const portRaw = stored?.nativeServerPort; + const port = Number.isFinite(Number(portRaw)) + ? Number(portRaw) + : DEFAULT_NATIVE_SERVER_PORT; + + const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim(); + + // Best-effort: open AgentChat sidepanel so user can see the session + // Pass sessionId for deep linking directly to chat view + if (typeof senderTabId === 'number') { + openAgentChatSidepanel(senderTabId, senderWindowId, sessionId || undefined).catch( + () => {}, + ); + } + + if (!sessionId) { + // No session selected - sidepanel is already being opened (best-effort) + // User needs to select or create a session manually + sendResponse({ + success: false, + error: + 'No Agent session selected. Please select or create a session in AgentChat, then try Apply again.', + }); + return; + } + + // Hydrate payload with tabId + const hydratedPayload: WebEditorApplyBatchPayload = + typeof senderTabId === 'number' ? { ...payload, tabId: senderTabId } : payload; + + // Read excluded keys from session storage (per-tab, managed by sidepanel) + let sessionExcludedKeys: string[] = []; + if (typeof senderTabId === 'number') { + const excludedSessionKey = `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${senderTabId}`; + try { + if (chrome.storage?.session?.get) { + const stored = (await chrome.storage.session.get(excludedSessionKey)) as Record< + string, + unknown + >; + const raw = stored?.[excludedSessionKey]; + sessionExcludedKeys = Array.isArray(raw) + ? raw.map((k) => normalizeString(k).trim()).filter(Boolean) + : []; + } + } catch { + // Best-effort: ignore session storage failures + } + } + + // Filter out excluded elements (union: payload excludedKeys + session excludedKeys) + const excluded = new Set([...hydratedPayload.excludedKeys, ...sessionExcludedKeys]); + const elements = hydratedPayload.elements.filter((e) => !excluded.has(e.elementKey)); + if (elements.length === 0) { + sendResponse({ success: false, error: 'No elements selected to apply.' }); + return; + } + + // Build page URL from payload or sender tab + const pageUrl = + normalizeString(hydratedPayload.pageUrl).trim() || + normalizeString((_sender as chrome.runtime.MessageSender)?.tab?.url).trim() || + 'unknown'; + + // Build batch prompt and send to agent + const instruction = buildAgentPromptBatch(elements, pageUrl); + const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`; + + // Extract element labels for compact display + const elementLabels = elements.slice(0, 5).map((e) => e.label); + + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instruction, + // Pass dbSessionId so backend loads session-level configuration (engine, model, options) + dbSessionId: sessionId, + // Display text for UI (compact representation) + displayText: `Apply ${elements.length} change${elements.length === 1 ? '' : 's'}`, + // Client metadata for special message rendering + clientMeta: { + kind: 'web_editor_apply_batch', + pageUrl, + elementCount: elements.length, + elementLabels, + }, + }), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + sendResponse({ + success: false, + error: text || `HTTP ${resp.status}`, + }); + return; + } + + const json: any = await resp.json().catch(() => ({})); + const requestId = json?.requestId as string | undefined; + + if (requestId) { + // Start SSE subscription for status updates (fire and forget) + subscribeToSessionStatus(sessionId, requestId, port).catch(() => {}); + } + + sendResponse({ success: true, requestId, sessionId }); + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + + // ======================================================================= + // Phase 1.8: Handle HIGHLIGHT_ELEMENT from sidepanel chips hover + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_HIGHLIGHT_ELEMENT) { + const payload = message.payload as WebEditorHighlightElementPayload | undefined; + (async () => { + // Validate payload + const tabId = payload?.tabId; + if (typeof tabId !== 'number' || !Number.isFinite(tabId) || tabId <= 0) { + sendResponse({ success: false, error: 'Invalid tabId' }); + return; + } + + const mode = payload?.mode; + if (mode !== 'hover' && mode !== 'clear') { + sendResponse({ success: false, error: 'Invalid mode' }); + return; + } + + // Clear mode: forward directly without locator/selector validation + // This prevents overlay residue when sidepanel unmounts + if (mode === 'clear') { + try { + const response = await chrome.tabs.sendMessage(tabId, { + action: WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT, + mode: 'clear', + }); + sendResponse({ success: true, response }); + } catch (error) { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + } + return; + } + + // Hover mode: validate and forward locator + const locator = payload?.locator; + if (!locator || typeof locator !== 'object') { + sendResponse({ success: false, error: 'Invalid locator' }); + return; + } + + // Extract best selector for fallback highlighting + const selectors = Array.isArray(locator.selectors) ? locator.selectors : []; + const primarySelector = selectors.find( + (s): s is string => typeof s === 'string' && s.trim().length > 0, + ); + + if (!primarySelector) { + sendResponse({ success: false, error: 'No valid selector in locator' }); + return; + } + + // Forward to web-editor content script + try { + const response = await chrome.tabs.sendMessage(tabId, { + action: WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT, + locator, // Full locator for Shadow DOM/iframe support + selector: primarySelector, // Backward compatibility fallback + mode, + elementKey: payload.elementKey, + }); + + sendResponse({ success: true, response }); + } catch (error) { + // Content script might not be available + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + } + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + + // ======================================================================= + // Phase 2: Handle REVERT_ELEMENT from sidepanel chips + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_REVERT_ELEMENT) { + const payload = message.payload as WebEditorRevertElementPayload | undefined; + (async () => { + // Validate payload + const tabId = payload?.tabId; + if (typeof tabId !== 'number' || !Number.isFinite(tabId) || tabId <= 0) { + sendResponse({ success: false, error: 'Invalid tabId' }); + return; + } + + const elementKey = payload?.elementKey; + if (typeof elementKey !== 'string' || !elementKey.trim()) { + sendResponse({ success: false, error: 'Invalid elementKey' }); + return; + } + + // Forward to web-editor content script (frameId: 0 for main frame only) + try { + const response = await chrome.tabs.sendMessage( + tabId, + { + action: WEB_EDITOR_V2_ACTIONS.REVERT_ELEMENT, + elementKey, + }, + { frameId: 0 }, + ); + + sendResponse({ success: true, ...response }); + } catch (error) { + // Content script might not be available + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + } + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY) { + const payload = normalizeApplyPayload(message.payload); + (async () => { + const senderTabId = (_sender as any)?.tab?.id; + const sessionId = + typeof senderTabId === 'number' ? `web-editor-${senderTabId}` : 'web-editor'; + + const stored = await chrome.storage.local.get([ + 'nativeServerPort', + 'agent-selected-project-id', + ]); + const portRaw = stored?.nativeServerPort; + const port = Number.isFinite(Number(portRaw)) + ? Number(portRaw) + : DEFAULT_NATIVE_SERVER_PORT; + + const projectId = normalizeString(stored?.['agent-selected-project-id']).trim() || ''; + + if (!projectId) { + return sendResponse({ + success: false, + error: + 'No Agent project selected. Open Side Panel → 智能助手 and select/create a project first.', + }); + } + + const instruction = buildAgentPrompt(payload); + const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`; + + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instruction, + projectId, + }), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + return sendResponse({ + success: false, + error: text || `HTTP ${resp.status}`, + }); + } + + const json: any = await resp.json().catch(() => ({})); + const requestId = json?.requestId as string | undefined; + + if (requestId) { + // Start SSE subscription for status updates (fire and forget) + subscribeToSessionStatus(sessionId, requestId, port).catch(() => {}); + } + + return sendResponse({ success: true, requestId, sessionId }); + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_STATUS_QUERY) { + const { requestId } = message; + if (!requestId || typeof requestId !== 'string') { + sendResponse({ success: false, error: 'requestId is required' }); + return false; + } + + const entry = getExecutionStatus(requestId); + if (!entry) { + // No status yet - likely still pending or not tracked + sendResponse({ success: true, status: 'pending', message: 'Waiting for status...' }); + } else { + sendResponse({ + success: true, + status: entry.status, + message: entry.message, + result: entry.result, + }); + } + return false; // Synchronous response + } + + // ======================================================================= + // Cancel Execution: Handle WEB_EDITOR_CANCEL_EXECUTION from toolbar/sidepanel + // ======================================================================= + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CANCEL_EXECUTION) { + const payload = message.payload as WebEditorCancelExecutionPayload | undefined; + (async () => { + // Validate payload + const sessionId = payload?.sessionId?.trim(); + const requestId = payload?.requestId?.trim(); + + if (!sessionId) { + sendResponse({ + success: false, + error: 'sessionId is required', + } as WebEditorCancelExecutionResponse); + return; + } + if (!requestId) { + sendResponse({ + success: false, + error: 'requestId is required', + } as WebEditorCancelExecutionResponse); + return; + } + + // Get server port + const stored = await chrome.storage.local.get(['nativeServerPort']); + const port = stored.nativeServerPort || DEFAULT_NATIVE_SERVER_PORT; + + try { + // Call cancel API + const cancelUrl = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`; + const response = await fetch(cancelUrl, { method: 'DELETE' }); + + if (!response.ok) { + const errorText = await response.text().catch(() => `HTTP ${response.status}`); + sendResponse({ + success: false, + error: errorText, + } as WebEditorCancelExecutionResponse); + return; + } + + // Update local execution status cache + setExecutionStatus(requestId, 'cancelled', 'Execution cancelled by user'); + + // Abort SSE connection for this session + const sseConnection = sseConnections.get(sessionId); + if (sseConnection && sseConnection.lastRequestId === requestId) { + sseConnection.abort.abort(); + sseConnections.delete(sessionId); + } + + sendResponse({ success: true } as WebEditorCancelExecutionResponse); + } catch (error) { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + } as WebEditorCancelExecutionResponse); + } + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + } as WebEditorCancelExecutionResponse); + }); + return true; // Will respond asynchronously + } + } catch (error) { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + } + return false; + }); +} diff --git a/app/chrome-extension/entrypoints/builder/App.vue b/app/chrome-extension/entrypoints/builder/App.vue new file mode 100644 index 0000000..0bfef5c --- /dev/null +++ b/app/chrome-extension/entrypoints/builder/App.vue @@ -0,0 +1,1262 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/builder/index.html b/app/chrome-extension/entrypoints/builder/index.html new file mode 100644 index 0000000..7afd432 --- /dev/null +++ b/app/chrome-extension/entrypoints/builder/index.html @@ -0,0 +1,13 @@ + + + + + + 工作流编辑器 + + + +
+ + + diff --git a/app/chrome-extension/entrypoints/builder/main.ts b/app/chrome-extension/entrypoints/builder/main.ts new file mode 100644 index 0000000..486851f --- /dev/null +++ b/app/chrome-extension/entrypoints/builder/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +// Tailwind first, then custom tokens +import '../styles/tailwind.css'; + +createApp(App).mount('#app'); diff --git a/app/chrome-extension/entrypoints/content.ts b/app/chrome-extension/entrypoints/content.ts new file mode 100644 index 0000000..e7ee81e --- /dev/null +++ b/app/chrome-extension/entrypoints/content.ts @@ -0,0 +1,4 @@ +export default defineContentScript({ + matches: ['*://*.google.com/*'], + main() {}, +}); diff --git a/app/chrome-extension/entrypoints/element-picker.content.ts b/app/chrome-extension/entrypoints/element-picker.content.ts new file mode 100644 index 0000000..72f1de9 --- /dev/null +++ b/app/chrome-extension/entrypoints/element-picker.content.ts @@ -0,0 +1,205 @@ +/** + * Element Picker Content Script + * + * Renders the Element Picker Panel UI (Quick Panel style) and forwards UI events + * to background while a chrome_request_element_selection session is active. + * + * This script only runs in the top frame and handles: + * - Displaying the element picker panel UI + * - Forwarding user actions (cancel, confirm, etc.) to background + * - Receiving state updates from background + */ + +import { + createElementPickerController, + type ElementPickerController, + type ElementPickerUiState, +} from '@/shared/element-picker'; +import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import type { PickedElement } from 'chrome-mcp-shared'; + +// ============================================================ +// Message Types +// ============================================================ + +interface UiShowMessage { + action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW; + sessionId: string; + requests: Array<{ id: string; name: string; description?: string }>; + activeRequestId: string | null; + deadlineTs: number; +} + +interface UiUpdateMessage { + action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE; + sessionId: string; + activeRequestId: string | null; + selections: Record; + deadlineTs: number; + errorMessage: string | null; +} + +interface UiHideMessage { + action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE; + sessionId: string; +} + +interface UiPingMessage { + action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING; +} + +type PickerMessage = UiPingMessage | UiShowMessage | UiUpdateMessage | UiHideMessage; + +// ============================================================ +// Content Script Definition +// ============================================================ + +export default defineContentScript({ + matches: [''], + runAt: 'document_idle', + + main() { + // Only mount UI in the top frame + if (window.top !== window) return; + + let controller: ElementPickerController | null = null; + let currentSessionId: string | null = null; + + /** + * Ensure the controller is created and configured. + */ + function ensureController(): ElementPickerController { + if (controller) return controller; + + controller = createElementPickerController({ + onCancel: () => { + if (!currentSessionId) return; + void chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, + sessionId: currentSessionId, + event: 'cancel', + }); + }, + onConfirm: () => { + if (!currentSessionId) return; + void chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, + sessionId: currentSessionId, + event: 'confirm', + }); + }, + onSetActiveRequest: (requestId: string) => { + if (!currentSessionId) return; + void chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, + sessionId: currentSessionId, + event: 'set_active_request', + requestId, + }); + }, + onClearSelection: (requestId: string) => { + if (!currentSessionId) return; + void chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, + sessionId: currentSessionId, + event: 'clear_selection', + requestId, + }); + }, + }); + + return controller; + } + + /** + * Handle incoming messages from background. + */ + function handleMessage( + message: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, + ): boolean | void { + const msg = message as PickerMessage | undefined; + if (!msg?.action) return false; + + // Respond to ping (used by background to check if UI script is ready) + if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING) { + sendResponse({ success: true }); + return true; + } + + // Show the picker panel + if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW) { + const showMsg = msg as UiShowMessage; + currentSessionId = typeof showMsg.sessionId === 'string' ? showMsg.sessionId : null; + + if (!currentSessionId) { + sendResponse({ success: false, error: 'Missing sessionId' }); + return true; + } + + const ctrl = ensureController(); + const initialState: ElementPickerUiState = { + sessionId: currentSessionId, + requests: Array.isArray(showMsg.requests) ? showMsg.requests : [], + activeRequestId: showMsg.activeRequestId ?? null, + selections: {}, + deadlineTs: typeof showMsg.deadlineTs === 'number' ? showMsg.deadlineTs : Date.now(), + errorMessage: null, + }; + ctrl.show(initialState); + sendResponse({ success: true }); + return true; + } + + // Update the picker panel state + if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE) { + const updateMsg = msg as UiUpdateMessage; + + if (!currentSessionId || updateMsg.sessionId !== currentSessionId) { + sendResponse({ success: false, error: 'Session mismatch' }); + return true; + } + + controller?.update({ + sessionId: currentSessionId, + activeRequestId: updateMsg.activeRequestId ?? null, + selections: updateMsg.selections || {}, + deadlineTs: updateMsg.deadlineTs, + errorMessage: updateMsg.errorMessage ?? null, + }); + sendResponse({ success: true }); + return true; + } + + // Hide the picker panel + if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE) { + const hideMsg = msg as UiHideMessage; + + // Best-effort hide even if session mismatches + if (currentSessionId && hideMsg.sessionId !== currentSessionId) { + // Log but don't fail + console.warn('[ElementPicker] Session mismatch on hide, hiding anyway'); + } + + controller?.hide(); + currentSessionId = null; + sendResponse({ success: true }); + return true; + } + + return false; + } + + // Register message listener + chrome.runtime.onMessage.addListener(handleMessage); + + // Cleanup on page unload + window.addEventListener('unload', () => { + chrome.runtime.onMessage.removeListener(handleMessage); + controller?.dispose(); + controller = null; + currentSessionId = null; + }); + }, +}); diff --git a/app/chrome-extension/entrypoints/offscreen/gif-encoder.ts b/app/chrome-extension/entrypoints/offscreen/gif-encoder.ts new file mode 100644 index 0000000..c3e4d52 --- /dev/null +++ b/app/chrome-extension/entrypoints/offscreen/gif-encoder.ts @@ -0,0 +1,201 @@ +/** + * GIF Encoder Module for Offscreen Document + * + * Handles GIF encoding using the gifenc library in the offscreen document context. + * This module provides frame-by-frame GIF encoding with palette quantization. + */ + +import { GIFEncoder, quantize, applyPalette } from 'gifenc'; +import { MessageTarget, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface GifEncoderState { + encoder: ReturnType | null; + width: number; + height: number; + frameCount: number; + isInitialized: boolean; +} + +interface GifAddFrameMessage { + target: MessageTarget; + type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME; + imageData: number[]; + width: number; + height: number; + delay: number; + maxColors?: number; +} + +interface GifFinishMessage { + target: MessageTarget; + type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_FINISH; +} + +interface GifResetMessage { + target: MessageTarget; + type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_RESET; +} + +type GifMessage = GifAddFrameMessage | GifFinishMessage | GifResetMessage; + +interface GifMessageResponse { + success: boolean; + error?: string; + frameCount?: number; + gifData?: number[]; + byteLength?: number; +} + +// ============================================================================ +// State +// ============================================================================ + +const state: GifEncoderState = { + encoder: null, + width: 0, + height: 0, + frameCount: 0, + isInitialized: false, +}; + +// ============================================================================ +// Handlers +// ============================================================================ + +function initializeEncoder(width: number, height: number): void { + state.encoder = GIFEncoder(); + state.width = width; + state.height = height; + state.frameCount = 0; + state.isInitialized = true; +} + +function addFrame( + imageData: Uint8ClampedArray, + width: number, + height: number, + delay: number, + maxColors: number = 256, +): void { + // Initialize encoder on first frame + if (!state.isInitialized || state.width !== width || state.height !== height) { + initializeEncoder(width, height); + } + + if (!state.encoder) { + throw new Error('GIF encoder not initialized'); + } + + // Quantize colors to create palette + const palette = quantize(imageData, maxColors, { format: 'rgb444' }); + + // Map pixels to palette indices + const indexedPixels = applyPalette(imageData, palette, 'rgb444'); + + // Write frame to encoder + state.encoder.writeFrame(indexedPixels, width, height, { + palette, + delay, + dispose: 2, // Restore to background color + }); + + state.frameCount++; +} + +function finishEncoding(): Uint8Array { + if (!state.encoder) { + throw new Error('GIF encoder not initialized'); + } + + state.encoder.finish(); + const bytes = state.encoder.bytes(); + + // Reset state after finishing + resetEncoder(); + + return bytes; +} + +function resetEncoder(): void { + if (state.encoder) { + state.encoder.reset(); + } + state.encoder = null; + state.width = 0; + state.height = 0; + state.frameCount = 0; + state.isInitialized = false; +} + +// ============================================================================ +// Message Handler +// ============================================================================ + +function isGifMessage(message: unknown): message is GifMessage { + if (!message || typeof message !== 'object') return false; + const msg = message as Record; + if (msg.target !== MessageTarget.Offscreen) return false; + + const gifTypes = [ + OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, + OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, + OFFSCREEN_MESSAGE_TYPES.GIF_RESET, + ]; + + return gifTypes.includes(msg.type as string); +} + +export function handleGifMessage( + message: unknown, + sendResponse: (response: GifMessageResponse) => void, +): boolean { + if (!isGifMessage(message)) { + return false; + } + + try { + switch (message.type) { + case OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME: { + const { imageData, width, height, delay, maxColors } = message; + const clampedData = new Uint8ClampedArray(imageData); + addFrame(clampedData, width, height, delay, maxColors); + sendResponse({ + success: true, + frameCount: state.frameCount, + }); + break; + } + + case OFFSCREEN_MESSAGE_TYPES.GIF_FINISH: { + const gifBytes = finishEncoding(); + sendResponse({ + success: true, + gifData: Array.from(gifBytes), + byteLength: gifBytes.byteLength, + }); + break; + } + + case OFFSCREEN_MESSAGE_TYPES.GIF_RESET: { + resetEncoder(); + sendResponse({ success: true }); + break; + } + + default: + sendResponse({ success: false, error: `Unknown GIF message type` }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('GIF encoder error:', errorMessage); + sendResponse({ success: false, error: errorMessage }); + } + + return true; +} + +console.log('GIF encoder module loaded'); diff --git a/app/chrome-extension/entrypoints/offscreen/index.html b/app/chrome-extension/entrypoints/offscreen/index.html new file mode 100644 index 0000000..81239fc --- /dev/null +++ b/app/chrome-extension/entrypoints/offscreen/index.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/chrome-extension/entrypoints/offscreen/main.ts b/app/chrome-extension/entrypoints/offscreen/main.ts new file mode 100644 index 0000000..31aa588 --- /dev/null +++ b/app/chrome-extension/entrypoints/offscreen/main.ts @@ -0,0 +1,441 @@ +import { SemanticSimilarityEngine } from '@/utils/semantic-similarity-engine'; +import { + MessageTarget, + SendMessageType, + OFFSCREEN_MESSAGE_TYPES, + BACKGROUND_MESSAGE_TYPES, +} from '@/common/message-types'; +import { handleGifMessage } from './gif-encoder'; +import { initKeepalive } from './rr-keepalive'; + +// 初始化 RR V3 Keepalive +initKeepalive(); + +// Global semantic similarity engine instance +let similarityEngine: SemanticSimilarityEngine | null = null; +interface OffscreenMessage { + target: MessageTarget | string; + type: SendMessageType | string; +} + +interface SimilarityEngineInitMessage extends OffscreenMessage { + type: SendMessageType.SimilarityEngineInit; + config: any; +} + +interface SimilarityEngineComputeBatchMessage extends OffscreenMessage { + type: SendMessageType.SimilarityEngineComputeBatch; + pairs: { text1: string; text2: string }[]; + options?: Record; +} + +interface SimilarityEngineGetEmbeddingMessage extends OffscreenMessage { + type: 'similarityEngineCompute'; + text: string; + options?: Record; +} + +interface SimilarityEngineGetEmbeddingsBatchMessage extends OffscreenMessage { + type: 'similarityEngineBatchCompute'; + texts: string[]; + options?: Record; +} + +interface SimilarityEngineStatusMessage extends OffscreenMessage { + type: 'similarityEngineStatus'; +} + +type MessageResponse = { + result?: string; + error?: string; + success?: boolean; + similarities?: number[]; + embedding?: number[]; + embeddings?: number[][]; + isInitialized?: boolean; + currentConfig?: any; +}; + +// Listen for messages from the extension +chrome.runtime.onMessage.addListener( + ( + message: OffscreenMessage, + _sender: chrome.runtime.MessageSender, + sendResponse: (response: MessageResponse) => void, + ) => { + if (message.target !== MessageTarget.Offscreen) { + return; + } + + // Handle GIF encoding messages first + if (handleGifMessage(message, sendResponse)) { + return true; + } + + try { + switch (message.type) { + case SendMessageType.SimilarityEngineInit: + case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT: { + const initMsg = message as SimilarityEngineInitMessage; + console.log('Offscreen: Received similarity engine init message:', message.type); + handleSimilarityEngineInit(initMsg.config) + .then(() => sendResponse({ success: true })) + .catch((error) => sendResponse({ success: false, error: error.message })); + break; + } + + case SendMessageType.SimilarityEngineComputeBatch: { + const computeMsg = message as SimilarityEngineComputeBatchMessage; + handleComputeSimilarityBatch(computeMsg.pairs, computeMsg.options) + .then((similarities) => sendResponse({ success: true, similarities })) + .catch((error) => sendResponse({ success: false, error: error.message })); + break; + } + + case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_COMPUTE: { + const embeddingMsg = message as SimilarityEngineGetEmbeddingMessage; + handleGetEmbedding(embeddingMsg.text, embeddingMsg.options) + .then((embedding) => { + console.log('Offscreen: Sending embedding response:', { + length: embedding.length, + type: typeof embedding, + constructor: embedding.constructor.name, + isFloat32Array: embedding instanceof Float32Array, + firstFewValues: Array.from(embedding.slice(0, 5)), + }); + const embeddingArray = Array.from(embedding); + console.log('Offscreen: Converted to array:', { + length: embeddingArray.length, + type: typeof embeddingArray, + isArray: Array.isArray(embeddingArray), + firstFewValues: embeddingArray.slice(0, 5), + }); + sendResponse({ success: true, embedding: embeddingArray }); + }) + .catch((error) => sendResponse({ success: false, error: error.message })); + break; + } + + case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE: { + const batchMsg = message as SimilarityEngineGetEmbeddingsBatchMessage; + handleGetEmbeddingsBatch(batchMsg.texts, batchMsg.options) + .then((embeddings) => + sendResponse({ + success: true, + embeddings: embeddings.map((emb) => Array.from(emb)), + }), + ) + .catch((error) => sendResponse({ success: false, error: error.message })); + break; + } + + case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_STATUS: { + handleGetEngineStatus() + .then((status: any) => sendResponse({ success: true, ...status })) + .catch((error: any) => sendResponse({ success: false, error: error.message })); + break; + } + + default: + sendResponse({ error: `Unknown message type: ${message.type}` }); + } + } catch (error) { + if (error instanceof Error) { + sendResponse({ error: error.message }); + } else { + sendResponse({ error: 'Unknown error occurred' }); + } + } + + // Return true to indicate we'll respond asynchronously + return true; + }, +); + +// Global variable to track current model state +let currentModelConfig: any = null; + +/** + * Check if engine reinitialization is needed + */ +function needsReinitialization(newConfig: any): boolean { + if (!similarityEngine || !currentModelConfig) { + return true; + } + + // Check if key configuration has changed + const keyFields = ['modelPreset', 'modelVersion', 'modelIdentifier', 'dimension']; + for (const field of keyFields) { + if (newConfig[field] !== currentModelConfig[field]) { + console.log( + `Offscreen: ${field} changed from ${currentModelConfig[field]} to ${newConfig[field]}`, + ); + return true; + } + } + + return false; +} + +/** + * Progress callback function type + */ +type ProgressCallback = (progress: { status: string; progress: number; message?: string }) => void; + +/** + * Initialize semantic similarity engine + */ +async function handleSimilarityEngineInit(config: any): Promise { + console.log('Offscreen: Initializing semantic similarity engine with config:', config); + console.log('Offscreen: Config useLocalFiles:', config.useLocalFiles); + console.log('Offscreen: Config modelPreset:', config.modelPreset); + console.log('Offscreen: Config modelVersion:', config.modelVersion); + console.log('Offscreen: Config modelDimension:', config.modelDimension); + console.log('Offscreen: Config modelIdentifier:', config.modelIdentifier); + + // Check if reinitialization is needed + const needsReinit = needsReinitialization(config); + console.log('Offscreen: Needs reinitialization:', needsReinit); + + if (!needsReinit) { + console.log('Offscreen: Using existing engine (no changes detected)'); + await updateModelStatus('ready', 100); + return; + } + + // If engine already exists, clean up old instance first (support model switching) + if (similarityEngine) { + console.log('Offscreen: Cleaning up existing engine for model switch...'); + try { + // Properly call dispose method to clean up all resources + await similarityEngine.dispose(); + console.log('Offscreen: Previous engine disposed successfully'); + } catch (error) { + console.warn('Offscreen: Failed to dispose previous engine:', error); + } + similarityEngine = null; + currentModelConfig = null; + + // Clear vector data in IndexedDB to ensure data consistency + try { + console.log('Offscreen: Clearing IndexedDB vector data for model switch...'); + await clearVectorIndexedDB(); + console.log('Offscreen: IndexedDB vector data cleared successfully'); + } catch (error) { + console.warn('Offscreen: Failed to clear IndexedDB vector data:', error); + } + } + + try { + // Update status to initializing + await updateModelStatus('initializing', 10); + + // Create progress callback function + const progressCallback: ProgressCallback = async (progress) => { + console.log('Offscreen: Progress update:', progress); + await updateModelStatus(progress.status, progress.progress); + }; + + // Create engine instance and pass progress callback + similarityEngine = new SemanticSimilarityEngine(config); + console.log('Offscreen: Starting engine initialization with progress tracking...'); + + // Use enhanced initialization method (if progress callback is supported) + if (typeof (similarityEngine as any).initializeWithProgress === 'function') { + await (similarityEngine as any).initializeWithProgress(progressCallback); + } else { + // Fallback to standard initialization method + console.log('Offscreen: Using standard initialization (no progress callback support)'); + await updateModelStatus('downloading', 30); + await similarityEngine.initialize(); + await updateModelStatus('ready', 100); + } + + // Save current configuration + currentModelConfig = { ...config }; + + console.log('Offscreen: Semantic similarity engine initialized successfully'); + } catch (error) { + console.error('Offscreen: Failed to initialize semantic similarity engine:', error); + // Update status to error + const errorMessage = error instanceof Error ? error.message : 'Unknown initialization error'; + const errorType = analyzeErrorType(errorMessage); + await updateModelStatus('error', 0, errorMessage, errorType); + // Clean up failed instance + similarityEngine = null; + currentModelConfig = null; + throw error; + } +} + +/** + * Clear vector data in IndexedDB + */ +async function clearVectorIndexedDB(): Promise { + try { + // Clear vector search related IndexedDB databases + const dbNames = ['VectorSearchDB', 'ContentIndexerDB', 'SemanticSimilarityDB']; + + for (const dbName of dbNames) { + try { + // Try to delete database + const deleteRequest = indexedDB.deleteDatabase(dbName); + await new Promise((resolve, _reject) => { + deleteRequest.onsuccess = () => { + console.log(`Offscreen: Successfully deleted database: ${dbName}`); + resolve(); + }; + deleteRequest.onerror = () => { + console.warn(`Offscreen: Failed to delete database: ${dbName}`, deleteRequest.error); + resolve(); // 不阻塞其他数据库的清理 + }; + deleteRequest.onblocked = () => { + console.warn(`Offscreen: Database deletion blocked: ${dbName}`); + resolve(); // 不阻塞其他数据库的清理 + }; + }); + } catch (error) { + console.warn(`Offscreen: Error deleting database ${dbName}:`, error); + } + } + } catch (error) { + console.error('Offscreen: Failed to clear vector IndexedDB:', error); + throw error; + } +} + +// Analyze error type +function analyzeErrorType(errorMessage: string): 'network' | 'file' | 'unknown' { + const message = errorMessage.toLowerCase(); + + if ( + message.includes('network') || + message.includes('fetch') || + message.includes('timeout') || + message.includes('connection') || + message.includes('cors') || + message.includes('failed to fetch') + ) { + return 'network'; + } + + if ( + message.includes('corrupt') || + message.includes('invalid') || + message.includes('format') || + message.includes('parse') || + message.includes('decode') || + message.includes('onnx') + ) { + return 'file'; + } + + return 'unknown'; +} + +// Helper function to update model status +async function updateModelStatus( + status: string, + progress: number, + errorMessage?: string, + errorType?: string, +) { + try { + const modelState = { + status, + downloadProgress: progress, + isDownloading: status === 'downloading' || status === 'initializing', + lastUpdated: Date.now(), + errorMessage: errorMessage || '', + errorType: errorType || '', + }; + + // In offscreen document, update storage through message passing to background script + // because offscreen document may not have direct chrome.storage access + if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) { + await chrome.storage.local.set({ modelState }); + } else { + // If chrome.storage is not available, pass message to background script + console.log('Offscreen: chrome.storage not available, sending message to background'); + try { + await chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.UPDATE_MODEL_STATUS, + modelState: modelState, + }); + } catch (messageError) { + console.error('Offscreen: Failed to send status update message:', messageError); + } + } + } catch (error) { + console.error('Offscreen: Failed to update model status:', error); + } +} + +/** + * Batch compute semantic similarity + */ +async function handleComputeSimilarityBatch( + pairs: { text1: string; text2: string }[], + options: Record = {}, +): Promise { + if (!similarityEngine) { + throw new Error('Similarity engine not initialized. Please reinitialize the engine.'); + } + + console.log(`Offscreen: Computing similarities for ${pairs.length} pairs`); + const similarities = await similarityEngine.computeSimilarityBatch(pairs, options); + console.log('Offscreen: Similarity computation completed'); + + return similarities; +} + +/** + * Get embedding vector for single text + */ +async function handleGetEmbedding( + text: string, + options: Record = {}, +): Promise { + if (!similarityEngine) { + throw new Error('Similarity engine not initialized. Please reinitialize the engine.'); + } + + console.log(`Offscreen: Getting embedding for text: "${text.substring(0, 50)}..."`); + const embedding = await similarityEngine.getEmbedding(text, options); + console.log('Offscreen: Embedding computation completed'); + + return embedding; +} + +/** + * Batch get embedding vectors for texts + */ +async function handleGetEmbeddingsBatch( + texts: string[], + options: Record = {}, +): Promise { + if (!similarityEngine) { + throw new Error('Similarity engine not initialized. Please reinitialize the engine.'); + } + + console.log(`Offscreen: Getting embeddings for ${texts.length} texts`); + const embeddings = await similarityEngine.getEmbeddingsBatch(texts, options); + console.log('Offscreen: Batch embedding computation completed'); + + return embeddings; +} + +/** + * Get engine status + */ +async function handleGetEngineStatus(): Promise<{ + isInitialized: boolean; + currentConfig: any; +}> { + return { + isInitialized: !!similarityEngine, + currentConfig: currentModelConfig, + }; +} + +console.log('Offscreen: Semantic similarity engine handler loaded'); diff --git a/app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts b/app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts new file mode 100644 index 0000000..0a58c63 --- /dev/null +++ b/app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts @@ -0,0 +1,280 @@ +/** + * @fileoverview Offscreen Keepalive + * @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat. + * + * Architecture: + * - Offscreen connects to Background (Service Worker) via a named Port. + * - Offscreen sends periodic `keepalive.ping` messages while keepalive is enabled. + * - Background replies with `keepalive.pong` to confirm the channel is alive. + * + * Contract: + * - After `stop`, keepalive must fully stop: no ping loop, no Port, and no reconnection attempts. + * - After `start`, keepalive must (re)connect if needed and resume the ping loop. + */ + +import { + RR_V3_KEEPALIVE_PORT_NAME, + DEFAULT_KEEPALIVE_PING_INTERVAL_MS, + type KeepaliveMessage, +} from '@/common/rr-v3-keepalive-protocol'; + +// ==================== Runtime Control Protocol ==================== + +const KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const; + +type KeepaliveControlCommand = 'start' | 'stop'; + +interface KeepaliveControlMessage { + type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE; + command: KeepaliveControlCommand; +} + +function isKeepaliveControlMessage(value: unknown): value is KeepaliveControlMessage { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + if (v.type !== KEEPALIVE_CONTROL_MESSAGE_TYPE) return false; + return v.command === 'start' || v.command === 'stop'; +} + +// ==================== State ==================== + +let initialized = false; +let keepalivePort: chrome.runtime.Port | null = null; +let pingTimer: ReturnType | null = null; +/** Whether keepalive is desired (set by start/stop commands from Background) */ +let keepaliveDesired = false; +let reconnectTimer: ReturnType | null = null; + +// ==================== Type Guards ==================== + +/** + * Type guard for KeepaliveMessage. + */ +function isKeepaliveMessage(value: unknown): value is KeepaliveMessage { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + + const type = v.type; + if ( + type !== 'keepalive.ping' && + type !== 'keepalive.pong' && + type !== 'keepalive.start' && + type !== 'keepalive.stop' + ) { + return false; + } + + return typeof v.timestamp === 'number' && Number.isFinite(v.timestamp); +} + +// ==================== Port Management ==================== + +/** + * Schedule a reconnect attempt to maintain the Port connection. + * Only reconnect while keepalive is desired. + */ +function scheduleReconnect(delayMs = 1000): void { + if (!initialized) return; + if (!keepaliveDesired) return; + if (reconnectTimer) return; + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (!initialized) return; + if (!keepaliveDesired) return; + if (!keepalivePort) { + console.log('[rr-keepalive] Attempting scheduled reconnect...'); + keepalivePort = connectToBackground(); + } + }, delayMs); +} + +/** + * Create a Port connection to Background. + */ +function connectToBackground(): chrome.runtime.Port | null { + if (typeof chrome === 'undefined' || !chrome.runtime?.connect) { + console.warn('[rr-keepalive] chrome.runtime.connect not available'); + return null; + } + + try { + const port = chrome.runtime.connect({ name: RR_V3_KEEPALIVE_PORT_NAME }); + + port.onMessage.addListener((msg: unknown) => { + if (!isKeepaliveMessage(msg)) return; + + if (msg.type === 'keepalive.start') { + console.log('[rr-keepalive] Received start command via Port'); + startPingLoop(); + } else if (msg.type === 'keepalive.stop') { + console.log('[rr-keepalive] Received stop command via Port'); + stopPingLoop(); + } else if (msg.type === 'keepalive.pong') { + // Background replied to our ping. + console.debug('[rr-keepalive] Received pong'); + } + }); + + port.onDisconnect.addListener(() => { + console.log('[rr-keepalive] Port disconnected'); + keepalivePort = null; + // Only reconnect if keepalive is still desired. + scheduleReconnect(1000); + }); + + console.log('[rr-keepalive] Connected to background'); + return port; + } catch (e) { + console.warn('[rr-keepalive] Failed to connect:', e); + return null; + } +} + +// ==================== Ping Loop ==================== + +/** + * Send a ping message to Background. + */ +function sendPing(): void { + if (!keepalivePort) { + keepalivePort = connectToBackground(); + } + + if (!keepalivePort) return; + + const msg: KeepaliveMessage = { + type: 'keepalive.ping', + timestamp: Date.now(), + }; + + try { + keepalivePort.postMessage(msg); + console.debug('[rr-keepalive] Sent ping'); + } catch (e) { + console.warn('[rr-keepalive] Failed to send ping:', e); + keepalivePort = null; + scheduleReconnect(1000); + } +} + +/** + * Start the ping loop. + */ +function startPingLoop(): void { + if (pingTimer) return; + + keepaliveDesired = true; + + // Ensure we have a Port connection. + if (!keepalivePort) { + keepalivePort = connectToBackground(); + } + + // Send one ping immediately. + sendPing(); + + // Start the interval timer. + pingTimer = setInterval(() => { + sendPing(); + }, DEFAULT_KEEPALIVE_PING_INTERVAL_MS); + + console.log( + `[rr-keepalive] Ping loop started (interval=${DEFAULT_KEEPALIVE_PING_INTERVAL_MS}ms)`, + ); +} + +/** + * Stop the ping loop. + * This must fully stop keepalive: no timer, no Port, and no reconnection attempts. + */ +function stopPingLoop(): void { + keepaliveDesired = false; + + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + // Disconnect the Port to fully stop keepalive. + if (keepalivePort) { + try { + keepalivePort.disconnect(); + } catch { + // Ignore + } + keepalivePort = null; + } + + console.log('[rr-keepalive] Ping loop stopped'); +} + +// ==================== Public API ==================== + +/** + * Initialize keepalive control handlers. + * @description Registers the runtime control listener and waits for start/stop commands. + */ +export function initKeepalive(): void { + if (initialized) return; + initialized = true; + + // Check Chrome API availability. + if (typeof chrome === 'undefined' || !chrome.runtime?.onMessage) { + console.warn('[rr-keepalive] chrome.runtime.onMessage not available'); + return; + } + + // Listen for runtime control messages from Background. + // This allows Background to send start/stop even when Port is not connected. + chrome.runtime.onMessage.addListener((msg: unknown, _sender, sendResponse) => { + if (!isKeepaliveControlMessage(msg)) return; + + if (msg.command === 'start') { + console.log('[rr-keepalive] Received runtime start command'); + startPingLoop(); + } else { + console.log('[rr-keepalive] Received runtime stop command'); + stopPingLoop(); + } + + try { + sendResponse({ ok: true }); + } catch { + // Ignore + } + }); + + // Also establish initial Port connection for backwards compatibility. + if (chrome.runtime?.connect) { + keepalivePort = connectToBackground(); + } + + console.log('[rr-keepalive] Keepalive initialized'); +} + +/** + * Check whether keepalive is active. + */ +export function isKeepaliveActive(): boolean { + return keepaliveDesired && pingTimer !== null && keepalivePort !== null; +} + +/** + * Get the active port count (for debugging). + * @deprecated Use isKeepaliveActive() instead + */ +export function getActivePortCount(): number { + return keepalivePort ? 1 : 0; +} + +// Re-export for backwards compatibility +export { + RR_V3_KEEPALIVE_PORT_NAME, + type KeepaliveMessage, +} from '@/common/rr-v3-keepalive-protocol'; diff --git a/app/chrome-extension/entrypoints/options/App.vue b/app/chrome-extension/entrypoints/options/App.vue new file mode 100644 index 0000000..694f9cf --- /dev/null +++ b/app/chrome-extension/entrypoints/options/App.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/Canvas.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/Canvas.vue new file mode 100644 index 0000000..1b58071 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/Canvas.vue @@ -0,0 +1,569 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue new file mode 100644 index 0000000..ea5cfd7 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/KeyValueEditor.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/KeyValueEditor.vue new file mode 100644 index 0000000..d3b3982 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/KeyValueEditor.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/PropertyPanel.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/PropertyPanel.vue new file mode 100644 index 0000000..1d04d65 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/PropertyPanel.vue @@ -0,0 +1,839 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/Sidebar.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/Sidebar.vue new file mode 100644 index 0000000..b52ea32 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/Sidebar.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue new file mode 100644 index 0000000..4c0fa63 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue @@ -0,0 +1,941 @@ +/** * @fileoverview Trigger Panel Component for Builder * @description * A floating panel for +managing V3 triggers in the Builder interface. * * Features: * - Lists all triggers for the current +flow * - Enable/disable toggle for all trigger types * - Create/edit/delete for panel-managed +triggers (interval, once) * - Manual trigger support for 'manual' type triggers * * Ownership model: +* - Node-managed triggers (ID prefix: trg_/sch_): Created by trigger node sync, read-only in panel * +- Panel-managed triggers (interval, once): Full CRUD in panel */ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeCard.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeCard.vue new file mode 100644 index 0000000..27846cf --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeCard.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue new file mode 100644 index 0000000..90f1d99 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue @@ -0,0 +1,107 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts b/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts new file mode 100644 index 0000000..2ee5252 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts @@ -0,0 +1,119 @@ +// node-util.ts - shared UI helpers for node components +// Note: comments in English + +import type { NodeBase } from '@/entrypoints/background/record-replay/types'; +import { summarizeNode as summarize } from '../../model/transforms'; +import ILucideMousePointerClick from '~icons/lucide/mouse-pointer-click'; +import ILucideEdit3 from '~icons/lucide/edit-3'; +import ILucideKeyboard from '~icons/lucide/keyboard'; +import ILucideCompass from '~icons/lucide/compass'; +import ILucideGlobe from '~icons/lucide/globe'; +import ILucideFileCode2 from '~icons/lucide/file-code-2'; +import ILucideScan from '~icons/lucide/scan'; +import ILucideHourglass from '~icons/lucide/hourglass'; +import ILucideCheckCircle2 from '~icons/lucide/check-circle-2'; +import ILucideGitBranch from '~icons/lucide/git-branch'; +import ILucideRepeat from '~icons/lucide/repeat'; +import ILucideRefreshCcw from '~icons/lucide/refresh-ccw'; +import ILucideSquare from '~icons/lucide/square'; +import ILucideArrowLeftRight from '~icons/lucide/arrow-left-right'; +import ILucideX from '~icons/lucide/x'; +import ILucideZap from '~icons/lucide/zap'; +import ILucideCamera from '~icons/lucide/camera'; +import ILucideBell from '~icons/lucide/bell'; +import ILucideWrench from '~icons/lucide/wrench'; +import ILucideFrame from '~icons/lucide/frame'; +import ILucideDownload from '~icons/lucide/download'; +import ILucideArrowUpDown from '~icons/lucide/arrow-up-down'; +import ILucideMoveVertical from '~icons/lucide/move-vertical'; + +export function iconComp(t?: string) { + switch (t) { + case 'trigger': + return ILucideZap; + case 'click': + case 'dblclick': + return ILucideMousePointerClick; + case 'fill': + return ILucideEdit3; + case 'drag': + return ILucideArrowUpDown; + case 'scroll': + return ILucideMoveVertical; + case 'key': + return ILucideKeyboard; + case 'navigate': + return ILucideCompass; + case 'http': + return ILucideGlobe; + case 'script': + return ILucideFileCode2; + case 'screenshot': + return ILucideCamera; + case 'triggerEvent': + return ILucideBell; + case 'setAttribute': + return ILucideWrench; + case 'loopElements': + return ILucideRepeat; + case 'switchFrame': + return ILucideFrame; + case 'handleDownload': + return ILucideDownload; + case 'extract': + return ILucideScan; + case 'wait': + return ILucideHourglass; + case 'assert': + return ILucideCheckCircle2; + case 'if': + return ILucideGitBranch; + case 'foreach': + return ILucideRepeat; + case 'while': + return ILucideRefreshCcw; + case 'openTab': + return ILucideSquare; + case 'switchTab': + return ILucideArrowLeftRight; + case 'closeTab': + return ILucideX; + case 'delay': + return ILucideHourglass; + default: + return ILucideSquare; + } +} + +export function getTypeLabel(type?: string) { + const labels: Record = { + trigger: '触发器', + click: '点击', + fill: '填充', + navigate: '导航', + wait: '等待', + extract: '提取', + http: 'HTTP', + script: '脚本', + if: '条件', + foreach: '循环', + assert: '断言', + key: '键盘', + drag: '拖拽', + dblclick: '双击', + openTab: '打开标签', + switchTab: '切换标签', + closeTab: '关闭标签', + delay: '延迟', + scroll: '滚动', + while: '循环', + }; + return labels[String(type || '')] || type || ''; +} + +export function nodeSubtitle(node?: NodeBase | null): string { + if (!node) return ''; + const summary = summarize(node); + if (!summary) return node.type || ''; + return summary.length > 40 ? summary.slice(0, 40) + '...' : summary; +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue new file mode 100644 index 0000000..2692433 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyClick.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyClick.vue new file mode 100644 index 0000000..5ae0c59 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyClick.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue new file mode 100644 index 0000000..2abde2a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue new file mode 100644 index 0000000..d2a5a03 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue new file mode 100644 index 0000000..a8cf035 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue new file mode 100644 index 0000000..731819a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue new file mode 100644 index 0000000..d6badb9 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFill.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFill.vue new file mode 100644 index 0000000..f5f1768 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFill.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue new file mode 100644 index 0000000..b2508f8 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFormRenderer.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFormRenderer.vue new file mode 100644 index 0000000..062cfee --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFormRenderer.vue @@ -0,0 +1,390 @@ + + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue new file mode 100644 index 0000000..3f9c551 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue new file mode 100644 index 0000000..6cec8c6 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue new file mode 100644 index 0000000..5495cd7 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue new file mode 100644 index 0000000..3ad26cc --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyKey.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyKey.vue new file mode 100644 index 0000000..e558c69 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyKey.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue new file mode 100644 index 0000000..3f92507 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue new file mode 100644 index 0000000..9d0ddef --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue new file mode 100644 index 0000000..6fb9f82 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue new file mode 100644 index 0000000..b4fcb7c --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScript.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScript.vue new file mode 100644 index 0000000..e4c3629 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScript.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue new file mode 100644 index 0000000..529eda9 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue new file mode 100644 index 0000000..828684f --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue new file mode 100644 index 0000000..7f128ad --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue new file mode 100644 index 0000000..bdaab55 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue new file mode 100644 index 0000000..90fdf35 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue new file mode 100644 index 0000000..34820a3 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWait.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWait.vue new file mode 100644 index 0000000..4c36c95 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWait.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue new file mode 100644 index 0000000..237927d --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue new file mode 100644 index 0000000..f988a8c --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/form-widget-registry.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/form-widget-registry.ts new file mode 100644 index 0000000..41fb281 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/form-widget-registry.ts @@ -0,0 +1,25 @@ +// form-widget-registry.ts — global widget registry for PropertyFormRenderer +import FieldExpression from '@/entrypoints/popup/components/builder/widgets/FieldExpression.vue'; +import FieldSelector from '@/entrypoints/popup/components/builder/widgets/FieldSelector.vue'; +import FieldDuration from '@/entrypoints/popup/components/builder/widgets/FieldDuration.vue'; +import FieldCode from '@/entrypoints/popup/components/builder/widgets/FieldCode.vue'; +import FieldKeySequence from '@/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue'; +import FieldTargetLocator from '@/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue'; +import type { Component } from 'vue'; + +const REG = new Map(); + +export function registerDefaultWidgets() { + REG.set('expression', FieldExpression as unknown as Component); + REG.set('selector', FieldSelector as unknown as Component); + REG.set('duration', FieldDuration as unknown as Component); + REG.set('code', FieldCode as unknown as Component); + REG.set('keysequence', FieldKeySequence as unknown as Component); + // Structured TargetLocator based on a selector input + REG.set('targetlocator', FieldTargetLocator as unknown as Component); +} + +export function getWidget(name?: string): Component | null { + if (!name) return null; + return REG.get(name) || null; +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/node-spec-registry.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/node-spec-registry.ts new file mode 100644 index 0000000..521c590 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/node-spec-registry.ts @@ -0,0 +1 @@ +export * from 'chrome-mcp-shared'; diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/node-spec.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/node-spec.ts new file mode 100644 index 0000000..521c590 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/node-spec.ts @@ -0,0 +1 @@ +export * from 'chrome-mcp-shared'; diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/node-specs-builtin.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/node-specs-builtin.ts new file mode 100644 index 0000000..521c590 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/node-specs-builtin.ts @@ -0,0 +1 @@ +export * from 'chrome-mcp-shared'; diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/toast.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/toast.ts new file mode 100644 index 0000000..5e3889a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/toast.ts @@ -0,0 +1,14 @@ +// toast.ts - lightweight toast event bus for builder UI +// Usage: import { toast } and call toast('message', 'warn'|'error'|'info') + +export type ToastLevel = 'info' | 'warn' | 'error'; + +export function toast(message: string, level: ToastLevel = 'warn') { + try { + const ev = new CustomEvent('rr_toast', { detail: { message: String(message), level } }); + window.dispatchEvent(ev); + } catch { + // as a last resort + console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log']('[toast]', message); + } +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts new file mode 100644 index 0000000..368800a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts @@ -0,0 +1,147 @@ +import type { + Flow as FlowV2, + NodeBase, + Edge as EdgeV2, +} from '@/entrypoints/background/record-replay/types'; +import { + nodesToSteps as sharedNodesToSteps, + stepsToNodes as sharedStepsToNodes, + topoOrder as sharedTopoOrder, +} from 'chrome-mcp-shared'; +import { STEP_TYPES } from 'chrome-mcp-shared'; +import { EDGE_LABELS } from 'chrome-mcp-shared'; + +export function newId(prefix: string) { + return `${prefix}_${Math.random().toString(36).slice(2, 8)}`; +} + +export type NodeType = NodeBase['type']; + +export function defaultConfigFor(t: NodeType): any { + if ((t as any) === 'trigger') return { type: 'manual', description: '' }; + if (t === STEP_TYPES.CLICK || t === STEP_TYPES.FILL) + return { target: { candidates: [] }, value: t === STEP_TYPES.FILL ? '' : undefined }; + if (t === STEP_TYPES.IF) + return { branches: [{ id: newId('case'), name: '', expr: '' }], else: true }; + if (t === STEP_TYPES.NAVIGATE) return { url: '' }; + if (t === STEP_TYPES.WAIT) return { condition: { text: '', appear: true } }; + if (t === STEP_TYPES.ASSERT) return { assert: { exists: '' } }; + if (t === STEP_TYPES.KEY) return { keys: '' }; + if (t === STEP_TYPES.DELAY) return { ms: 1000 }; + if (t === STEP_TYPES.HTTP) return { method: 'GET', url: '', headers: {}, body: null, saveAs: '' }; + if (t === STEP_TYPES.EXTRACT) return { selector: '', attr: 'text', js: '', saveAs: '' }; + if (t === STEP_TYPES.SCREENSHOT) return { selector: '', fullPage: false, saveAs: 'shot' }; + if (t === STEP_TYPES.DRAG) + return { start: { candidates: [] }, end: { candidates: [] }, path: [] }; + if (t === STEP_TYPES.SCROLL) + return { mode: 'offset', offset: { x: 0, y: 300 }, target: { candidates: [] } }; + if (t === STEP_TYPES.TRIGGER_EVENT) + return { target: { candidates: [] }, event: 'input', bubbles: true, cancelable: false }; + if (t === STEP_TYPES.SET_ATTRIBUTE) return { target: { candidates: [] }, name: '', value: '' }; + if (t === STEP_TYPES.LOOP_ELEMENTS) + return { selector: '', saveAs: 'elements', itemVar: 'item', subflowId: '' }; + if (t === STEP_TYPES.SWITCH_FRAME) return { frame: { index: 0, urlContains: '' } }; + if (t === STEP_TYPES.HANDLE_DOWNLOAD) + return { filenameContains: '', waitForComplete: true, timeoutMs: 60000, saveAs: 'download' }; + if (t === STEP_TYPES.EXECUTE_FLOW) return { flowId: '', inline: true, args: {} }; + if (t === STEP_TYPES.OPEN_TAB) return { url: '', newWindow: false }; + if (t === STEP_TYPES.SWITCH_TAB) return { tabId: null, urlContains: '', titleContains: '' }; + if (t === STEP_TYPES.CLOSE_TAB) return { tabIds: [], url: '' }; + if (t === STEP_TYPES.SCRIPT) return { world: 'ISOLATED', code: '', saveAs: '', assign: {} }; + return {}; +} + +export function stepsToNodes(steps: any[]): NodeBase[] { + const base = sharedStepsToNodes(steps) as unknown as NodeBase[]; + // add simple UI positions + base.forEach((n, i) => { + (n as any).ui = (n as any).ui || { x: 200, y: 120 + i * 120 }; + }); + return base; +} + +export function topoOrder(nodes: NodeBase[], edges: EdgeV2[]): NodeBase[] { + const filtered = (edges || []).filter((e) => !e.label || e.label === EDGE_LABELS.DEFAULT); + return sharedTopoOrder(nodes as any, filtered as any) as any; +} + +export function nodesToSteps(nodes: NodeBase[], edges: EdgeV2[]): any[] { + // Exclude non-executable nodes like 'trigger' and cut edges from them + const execNodes = (nodes || []).filter((n) => n.type !== ('trigger' as any)); + const filtered = (edges || []).filter( + (e) => + (!e.label || e.label === EDGE_LABELS.DEFAULT) && !execNodes.every((n) => n.id !== e.from), + ); + return sharedNodesToSteps(execNodes as any, filtered as any); +} + +export function autoChainEdges(nodes: NodeBase[]): EdgeV2[] { + const arr: EdgeV2[] = []; + for (let i = 0; i < nodes.length - 1; i++) + arr.push({ + id: newId('e'), + from: nodes[i].id, + to: nodes[i + 1].id, + label: EDGE_LABELS.DEFAULT, + }); + return arr; +} + +export function summarizeNode(n?: NodeBase | null): string { + if (!n) return ''; + if (n.type === STEP_TYPES.CLICK || n.type === STEP_TYPES.FILL) + return n.config?.target?.candidates?.[0]?.value || '未配置选择器'; + if (n.type === STEP_TYPES.NAVIGATE) return n.config?.url || ''; + if (n.type === STEP_TYPES.KEY) return n.config?.keys || ''; + if (n.type === STEP_TYPES.DELAY) return `${Number(n.config?.ms || 0)}ms`; + if (n.type === STEP_TYPES.HTTP) return `${n.config?.method || 'GET'} ${n.config?.url || ''}`; + if (n.type === STEP_TYPES.EXTRACT) + return `${n.config?.selector || ''} -> ${n.config?.saveAs || ''}`; + if (n.type === STEP_TYPES.SCREENSHOT) + return n.config?.selector + ? `el(${n.config.selector}) -> ${n.config?.saveAs || ''}` + : `fullPage -> ${n.config?.saveAs || ''}`; + if (n.type === STEP_TYPES.TRIGGER_EVENT) + return `${n.config?.event || ''} ${n.config?.target?.candidates?.[0]?.value || ''}`; + if (n.type === STEP_TYPES.SET_ATTRIBUTE) + return `${n.config?.name || ''}=${n.config?.value ?? ''}`; + if (n.type === STEP_TYPES.LOOP_ELEMENTS) + return `${n.config?.selector || ''} as ${n.config?.itemVar || 'item'} -> ${n.config?.subflowId || ''}`; + if (n.type === STEP_TYPES.SWITCH_FRAME) + return n.config?.frame?.urlContains + ? `url~${n.config.frame.urlContains}` + : `index=${Number(n.config?.frame?.index ?? 0)}`; + if (n.type === STEP_TYPES.OPEN_TAB) return `open ${n.config?.url || ''}`; + if (n.type === STEP_TYPES.SWITCH_TAB) + return `switch ${n.config?.tabId || n.config?.urlContains || n.config?.titleContains || ''}`; + if (n.type === STEP_TYPES.CLOSE_TAB) return `close ${n.config?.url || ''}`; + if (n.type === STEP_TYPES.HANDLE_DOWNLOAD) return `download ${n.config?.filenameContains || ''}`; + if (n.type === STEP_TYPES.WAIT) return JSON.stringify(n.config?.condition || {}); + if (n.type === STEP_TYPES.ASSERT) return JSON.stringify(n.config?.assert || {}); + if (n.type === STEP_TYPES.IF) { + const cnt = Array.isArray(n.config?.branches) ? n.config.branches.length : 0; + return `if/else 分支数 ${cnt}${n.config?.else === false ? '' : ' + else'}`; + } + if (n.type === STEP_TYPES.SCRIPT) return (n.config?.code || '').slice(0, 30); + if (n.type === STEP_TYPES.DRAG) { + const a = n.config?.start?.candidates?.[0]?.value || ''; + const b = n.config?.end?.candidates?.[0]?.value || ''; + return a || b ? `${a} -> ${b}` : '拖拽'; + } + if (n.type === STEP_TYPES.SCROLL) { + const mode = n.config?.mode || 'offset'; + if (mode === 'offset' || mode === 'container') { + const x = Number(n.config?.offset?.x ?? 0); + const y = Number(n.config?.offset?.y ?? 0); + return `${mode} (${x}, ${y})`; + } + const sel = n.config?.target?.candidates?.[0]?.value || ''; + return sel ? `element ${sel}` : 'element'; + } + if (n.type === STEP_TYPES.EXECUTE_FLOW) return `exec ${n.config?.flowId || ''}`; + return ''; +} + +export function cloneFlow(flow: FlowV2): FlowV2 { + return JSON.parse(JSON.stringify(flow)); +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/ui-nodes.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/ui-nodes.ts new file mode 100644 index 0000000..20eea32 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/ui-nodes.ts @@ -0,0 +1,172 @@ +// ui-nodes.ts — UI registry for builder nodes (sidebar, canvas, properties) +// Comments in English to explain intent. + +import { markRaw, type Component } from 'vue'; +import type { NodeBase, NodeType } from '@/entrypoints/background/record-replay/types'; +import { NODE_TYPES } from '@/common/node-types'; +import { defaultConfigFor as fallbackDefaultConfig } from '@/entrypoints/popup/components/builder/model/transforms'; +import { validateNode as fallbackValidateNode } from '@/entrypoints/popup/components/builder/model/validation'; +import { + listNodeSpecs, + getNodeSpec, +} from '@/entrypoints/popup/components/builder/model/node-spec-registry'; +import { STEP_TYPES } from 'chrome-mcp-shared'; + +// Canvas renderer components +import NodeCard from '@/entrypoints/popup/components/builder/components/nodes/NodeCard.vue'; +import NodeIf from '@/entrypoints/popup/components/builder/components/nodes/NodeIf.vue'; + +// Property components (per-node or shared) +import PropClick from '@/entrypoints/popup/components/builder/components/properties/PropertyClick.vue'; +import PropFill from '@/entrypoints/popup/components/builder/components/properties/PropertyFill.vue'; +import PropTriggerEvent from '@/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue'; +import PropSetAttribute from '@/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue'; +import PropDrag from '@/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue'; +import PropScroll from '@/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue'; +import PropNavigate from '@/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue'; +import PropertyFromSpec from '@/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue'; +import { registerBuiltinSpecs } from '@/entrypoints/popup/components/builder/model/node-specs-builtin'; + +// Register builtin NodeSpecs at module init +registerBuiltinSpecs(); +import PropWait from '@/entrypoints/popup/components/builder/components/properties/PropertyWait.vue'; +import PropAssert from '@/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue'; +import PropDelay from '@/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue'; +import PropHttp from '@/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue'; +import PropExtract from '@/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue'; +import PropScreenshot from '@/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue'; +import PropLoopElements from '@/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue'; +import PropSwitchFrame from '@/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue'; +import PropHandleDownload from '@/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue'; +import PropExecuteFlow from '@/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue'; +import PropOpenTab from '@/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue'; +import PropSwitchTab from '@/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue'; +import PropCloseTab from '@/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue'; +import PropKey from '@/entrypoints/popup/components/builder/components/properties/PropertyKey.vue'; +import PropIf from '@/entrypoints/popup/components/builder/components/properties/PropertyIf.vue'; +import PropForeach from '@/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue'; +import PropWhile from '@/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue'; +import PropScript from '@/entrypoints/popup/components/builder/components/properties/PropertyScript.vue'; +import PropTrigger from '@/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue'; + +export type NodeCategory = 'Flow' | 'Actions' | 'Logic' | 'Tools' | 'Tabs' | 'Page'; + +export interface NodeUIConfig { + type: NodeType; + label: string; + category: NodeCategory; + iconClass: string; // reuse existing Sidebar.css color classes + canvas: Component; // canvas renderer + property: Component; // property renderer + docUrl?: string; + io?: { inputs?: number | 'any'; outputs?: number | 'any' }; + defaultConfig?: () => any; + validate?: (node: NodeBase) => string[]; +} + +// Registry contents generated from NodeSpec; use existing color/icon CSS classes +const baseCard = NodeCard as Component; + +function specToUi(spec: any): NodeUIConfig { + const canvas = spec.type === (STEP_TYPES.IF as any) ? (NodeIf as Component) : baseCard; + const outputs = Array.isArray(spec.ports?.outputs) ? spec.ports.outputs.length : 'any'; + return { + type: spec.type as any, + label: spec.display?.label || String(spec.type), + category: (spec.display?.category || 'Actions') as any, + iconClass: spec.display?.iconClass || 'icon-default', + // Mark component refs as raw to prevent them from being proxied/reactive by consumers + canvas: markRaw(canvas) as Component, + property: markRaw(PropertyFromSpec) as Component, + io: { inputs: spec.ports?.inputs ?? 1, outputs }, + defaultConfig: () => ({ ...(spec.defaults || {}) }), + validate: (node: NodeBase) => { + try { + const cfg = (node as any)?.config || {}; + return (getNodeSpec(node.type as any)?.validate?.(cfg) || []) as string[]; + } catch { + return []; + } + }, + } as any; +} + +export const NODE_UI_LIST: NodeUIConfig[] = listNodeSpecs().map(specToUi); + +const REGISTRY_MAP: Record = Object.fromEntries( + NODE_UI_LIST.map((n) => [n.type, n]), +); +export const NODE_UI_REGISTRY = REGISTRY_MAP as Record; + +export const NODE_CATEGORIES: NodeCategory[] = [ + 'Flow', + 'Actions', + 'Logic', + 'Tools', + 'Tabs', + 'Page', +]; + +export function listByCategory(): Record { + const out: Record = { + Flow: [], + Actions: [], + Logic: [], + Tools: [], + Tabs: [], + Page: [], + }; + for (const n of NODE_UI_LIST) out[n.category].push(n); + return out; +} + +export function canvasTypeKey(t: NodeType): string { + // Map to VueFlow node-types key, unique per node type + return `rr-${t}`; +} + +// Default config resolver with registry override +export function defaultConfigOf(t: NodeType): any { + // Prefer NodeSpec defaults + const spec = getNodeSpec(t as any); + if (spec?.defaults) return { ...spec.defaults }; + const item = (NODE_UI_REGISTRY as any)[t] as NodeUIConfig | undefined; + if (item?.defaultConfig) return item.defaultConfig(); + return fallbackDefaultConfig(t as any); +} + +// Validation via registry where present +export function validateNodeWithRegistry(n: NodeBase): string[] { + // Prefer NodeSpec validate + try { + const spec = getNodeSpec(n.type as any); + if (spec?.validate) return spec.validate((n as any).config || {}) || []; + } catch {} + const item = (NODE_UI_REGISTRY as any)[n.type] as NodeUIConfig | undefined; + if (item?.validate) { + try { + return item.validate(n) || []; + } catch {} + } + return fallbackValidateNode(n); +} + +// Allow external modules to register extra UI nodes +export function registerExtraUiNodes(list: NodeUIConfig[]) { + for (const n of list) { + (NODE_UI_LIST as any).push(n); + (REGISTRY_MAP as any)[n.type] = n; + } +} + +// IO constraints helper with sensible defaults for our graph +export function getIoConstraint(t: NodeType): { inputs: number | 'any'; outputs: number | 'any' } { + const item = (NODE_UI_REGISTRY as any)[t] as NodeUIConfig | undefined; + const io = item?.io || {}; + // Defaults: most nodes have single input; outputs unlimited unless otherwise defined + let inputs: number | 'any' = (io.inputs as any) ?? 1; + let outputs: number | 'any' = (io.outputs as any) ?? 'any'; + if ((t as any) === 'trigger') inputs = 0; + if ((t as any) === 'if') outputs = 'any'; + return { inputs, outputs }; +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/validation.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/validation.ts new file mode 100644 index 0000000..387467f --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/validation.ts @@ -0,0 +1,127 @@ +import type { NodeBase } from '@/entrypoints/background/record-replay/types'; +import { STEP_TYPES } from 'chrome-mcp-shared'; + +export function validateNode(n: NodeBase): string[] { + const errs: string[] = []; + const c: any = n.config || {}; + + switch (n.type) { + case STEP_TYPES.CLICK: + case STEP_TYPES.DBLCLICK: + case 'fill': { + const hasCandidate = !!c?.target?.candidates?.length; + if (!hasCandidate) errs.push('缺少目标选择器候选'); + if (n.type === 'fill' && (!('value' in c) || c.value === undefined)) errs.push('缺少输入值'); + break; + } + case STEP_TYPES.WAIT: { + if (!c?.condition) errs.push('缺少等待条件'); + break; + } + case STEP_TYPES.ASSERT: { + if (!c?.assert) errs.push('缺少断言条件'); + break; + } + case STEP_TYPES.NAVIGATE: { + if (!c?.url) errs.push('缺少 URL'); + break; + } + case STEP_TYPES.HTTP: { + if (!c?.url) errs.push('HTTP: 缺少 URL'); + if (c?.assign && typeof c.assign === 'object') { + const pathRe = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*$/; + for (const v of Object.values(c.assign)) { + const s = String(v); + if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`); + } + } + break; + } + case STEP_TYPES.HANDLE_DOWNLOAD: { + // filenameContains 可选 + break; + } + case STEP_TYPES.EXTRACT: { + if (!c?.saveAs) errs.push('Extract: 需填写保存变量名'); + if (!c?.selector && !c?.js) errs.push('Extract: 需提供 selector 或 js'); + break; + } + case STEP_TYPES.SWITCH_TAB: { + if (!c?.tabId && !c?.urlContains && !c?.titleContains) + errs.push('SwitchTab: 需提供 tabId 或 URL/标题包含'); + break; + } + case STEP_TYPES.SCREENSHOT: { + // selector 可空(全页/可视区),不强制 + break; + } + case STEP_TYPES.TRIGGER_EVENT: { + const hasCandidate = !!c?.target?.candidates?.length; + if (!hasCandidate) errs.push('缺少目标选择器候选'); + if (!String(c?.event || '').trim()) errs.push('需提供事件类型'); + break; + } + case STEP_TYPES.IF: { + const arr = Array.isArray(c?.branches) ? c.branches : []; + if (arr.length === 0) errs.push('需添加至少一个条件分支'); + for (let i = 0; i < arr.length; i++) { + if (!String(arr[i]?.expr || '').trim()) errs.push(`分支${i + 1}: 需填写条件表达式`); + } + break; + } + case STEP_TYPES.SET_ATTRIBUTE: { + const hasCandidate = !!c?.target?.candidates?.length; + if (!hasCandidate) errs.push('缺少目标选择器候选'); + if (!String(c?.name || '').trim()) errs.push('需提供属性名'); + break; + } + case STEP_TYPES.LOOP_ELEMENTS: { + if (!String(c?.selector || '').trim()) errs.push('需提供元素选择器'); + if (!String(c?.subflowId || '').trim()) errs.push('需提供子流 ID'); + break; + } + case STEP_TYPES.SWITCH_FRAME: { + // Both index/urlContains optional; empty means switch back to top frame + break; + } + case STEP_TYPES.EXECUTE_FLOW: { + if (!String(c?.flowId || '').trim()) errs.push('需选择要执行的工作流'); + break; + } + case STEP_TYPES.CLOSE_TAB: { + // 允许空(关闭当前标签页),不强制 + break; + } + case STEP_TYPES.SCRIPT: { + // 若配置了 saveAs/assign,应提供 code + const hasAssign = c?.assign && Object.keys(c.assign).length > 0; + if ((c?.saveAs || hasAssign) && !String(c?.code || '').trim()) + errs.push('Script: 配置了保存/映射但缺少代码'); + if (hasAssign) { + const pathRe = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*$/; + for (const v of Object.values(c.assign || {})) { + const s = String(v); + if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`); + } + } + break; + } + } + return errs; +} + +export function validateFlow(nodes: NodeBase[]): { + totalErrors: number; + nodeErrors: Record; +} { + const nodeErrors: Record = {}; + let totalErrors = 0; + for (const n of nodes) { + const e = validateNode(n); + if (e.length) { + nodeErrors[n.id] = e; + totalErrors += e.length; + } + } + return { totalErrors, nodeErrors }; +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/model/variables.ts b/app/chrome-extension/entrypoints/popup/components/builder/model/variables.ts new file mode 100644 index 0000000..f0b7c77 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/model/variables.ts @@ -0,0 +1,13 @@ +// variables.ts — Shared variable suggestion types for builder UI +export type VariableOrigin = 'global' | 'node'; + +export interface VariableOption { + key: string; + origin: VariableOrigin; + nodeId?: string; + nodeName?: string; +} + +export const VAR_TOKEN_OPEN = '{'; +export const VAR_TOKEN_CLOSE = '}'; +export const VAR_PLACEHOLDER = '{}'; diff --git a/app/chrome-extension/entrypoints/popup/components/builder/store/useBuilderStore.ts b/app/chrome-extension/entrypoints/popup/components/builder/store/useBuilderStore.ts new file mode 100644 index 0000000..05902ac --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/store/useBuilderStore.ts @@ -0,0 +1,616 @@ +import { reactive, ref } from 'vue'; +import type { + Flow as FlowV2, + NodeBase, + Edge as EdgeV2, +} from '@/entrypoints/background/record-replay/types'; +import { + autoChainEdges, + cloneFlow, + newId, + stepsToNodes, + summarizeNode, + topoOrder, +} from '../model/transforms'; +import { defaultConfigOf, getIoConstraint } from '../model/ui-nodes'; +import { toast } from '../model/toast'; + +export function useBuilderStore(initial?: FlowV2 | null) { + const flowLocal = reactive({ id: '', name: '', version: 1, steps: [], variables: [] }); + const nodes = reactive([]); + const edges = reactive([]); + const activeNodeId = ref(null); + const activeEdgeId = ref(null); + const pendingFrom = ref(null); + const pendingLabel = ref('default'); + const paletteTypes = [ + 'trigger', + 'click', + 'drag', + 'scroll', + 'fill', + 'if', + 'foreach', + 'while', + 'key', + 'wait', + 'assert', + 'navigate', + 'script', + 'delay', + 'http', + 'extract', + 'screenshot', + 'triggerEvent', + 'setAttribute', + 'loopElements', + 'switchFrame', + 'handleDownload', + 'executeFlow', + 'openTab', + 'switchTab', + 'closeTab', + ] as NodeBase['type'][]; + + // --- history (undo/redo) --- + type Snapshot = { + flow: Pick; + nodes: NodeBase[]; + edges: EdgeV2[]; + }; + const HISTORY_MAX = 50; + const past: Snapshot[] = []; + const future: Snapshot[] = []; + function takeSnapshot(): Snapshot { + return { + flow: { name: flowLocal.name, description: flowLocal.description } as any, + nodes: JSON.parse(JSON.stringify(nodes)), + edges: JSON.parse(JSON.stringify(edges)), + }; + } + function applySnapshot(s: Snapshot) { + flowLocal.name = (s.flow as any).name || ''; + (flowLocal as any).description = (s.flow as any).description || ''; + nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify(s.nodes))); + edges.splice(0, edges.length, ...JSON.parse(JSON.stringify(s.edges))); + } + function recordChange() { + past.push(takeSnapshot()); + // clear redo stack on new change + future.length = 0; + if (past.length > HISTORY_MAX) past.splice(0, past.length - HISTORY_MAX); + } + function undo() { + if (past.length === 0) return; + const current = takeSnapshot(); + const prev = past.pop()!; + future.push(current); + applySnapshot(prev); + } + function redo() { + if (future.length === 0) return; + const current = takeSnapshot(); + const next = future.pop()!; + past.push(current); + applySnapshot(next); + } + + function layoutIfNeeded() { + const startX = 120, + startY = 80, + gapY = 120; + nodes.forEach((n, i) => { + if (!n.ui || isNaN(n.ui.x) || isNaN(n.ui.y)) n.ui = { x: startX, y: startY + i * gapY }; + }); + } + + function initFromFlow(flow: FlowV2) { + const deep = cloneFlow(flow); + Object.assign(flowLocal, deep); + // DAG is required - flow-store guarantees nodes/edges via normalization + // steps fallback removed (deprecated field no longer returned) + nodes.splice(0, nodes.length, ...(Array.isArray(deep.nodes) ? deep.nodes : [])); + edges.splice( + 0, + edges.length, + ...(Array.isArray(deep.edges) && deep.edges.length ? deep.edges : autoChainEdges(nodes)), + ); + layoutIfNeeded(); + activeNodeId.value = nodes[0]?.id || null; + activeEdgeId.value = null; + // reset history + past.length = 0; + future.length = 0; + past.push(takeSnapshot()); + } + + function selectNode(id: string | null) { + // When click on empty canvas, id can be null => deselect + if (id && pendingFrom.value && pendingFrom.value !== id) { + onConnect(pendingFrom.value, id, pendingLabel.value); + pendingFrom.value = null; + } + activeNodeId.value = id || null; + // selecting a node should clear edge selection + if (id) activeEdgeId.value = null; + } + + function selectEdge(id: string | null) { + activeEdgeId.value = id || null; + if (id) activeNodeId.value = null; + } + + function addNode(t: NodeBase['type']) { + const id = newId(t); + const n: NodeBase = { + id, + type: t, + name: '', + config: defaultConfigOf(t), + ui: { x: 200 + nodes.length * 24, y: 120 + nodes.length * 96 }, + }; + nodes.push(n); + if (nodes.length > 1) { + const prev = nodes[nodes.length - 2]; + edges.push({ id: newId('e'), from: prev.id, to: id, label: 'default' }); + } + activeNodeId.value = id; + recordChange(); + } + + function addNodeAt(t: NodeBase['type'], x: number, y: number) { + const id = newId(t); + const n: NodeBase = { + id, + type: t, + name: '', + config: defaultConfigOf(t), + ui: { x: Math.round(x), y: Math.round(y) }, + }; + nodes.push(n); + activeNodeId.value = id; + recordChange(); + } + + function duplicateNode(id: string) { + const src = nodes.find((n) => n.id === id); + if (!src) return; + const cp: NodeBase = JSON.parse(JSON.stringify(src)); + cp.id = newId(src.type); + cp.name = src.name ? `${src.name} Copy` : ''; + const baseX = cp.ui && typeof cp.ui.x === 'number' ? cp.ui.x : 200; + const baseY = cp.ui && typeof cp.ui.y === 'number' ? cp.ui.y : 120; + cp.ui = { x: baseX + 40, y: baseY + 40 }; + nodes.push(cp); + activeNodeId.value = cp.id; + recordChange(); + } + + function removeNode(id: string) { + const idx = nodes.findIndex((n) => n.id === id); + if (idx < 0) return; + nodes.splice(idx, 1); + for (let i = edges.length - 1; i >= 0; i--) { + const e = edges[i]; + if (e.from === id || e.to === id) edges.splice(i, 1); + } + // After removal, do not auto-select another node to avoid accidental batch deletes + activeNodeId.value = null; + activeEdgeId.value = null; + recordChange(); + } + + function removeEdge(id: string) { + const idx = edges.findIndex((e) => e.id === id); + if (idx < 0) return; + edges.splice(idx, 1); + if (activeEdgeId.value === id) activeEdgeId.value = null; + recordChange(); + } + + function setNodePosition(id: string, x: number, y: number) { + const n = nodes.find((n) => n.id === id); + if (!n) return; + n.ui = { x: Math.round(x), y: Math.round(y) }; + // 不计入历史栈,避免频繁记录;由用户触发操作(连接/新增/删除等)记录。 + } + + function connectFrom(id: string, label: string = 'default') { + pendingFrom.value = id; + pendingLabel.value = label; + } + + function onConnect(sourceId: string, targetId: string, label: string = 'default') { + // prevent self-loop + if (sourceId === targetId) { + toast('不能连接到自身', 'warn'); + return; + } + // IO constraints + try { + const src = nodes.find((n) => n.id === sourceId); + const dst = nodes.find((n) => n.id === targetId); + if (!src || !dst) return; + const srcIo = getIoConstraint(src.type as any); + const dstIo = getIoConstraint(dst.type as any); + // Inputs: respect numeric maximum; 'any' means unlimited + const incoming = edges.filter((e) => e.to === targetId).length; + if (dstIo.inputs !== 'any' && incoming >= (dstIo.inputs as number)) { + toast(`该节点最多允许 ${dstIo.inputs} 条入边`, 'warn'); + return; + } + // Outputs: respect numeric maximum when defined + if (srcIo.outputs !== 'any') { + const outgoing = edges.filter((e) => e.from === sourceId).length; + if (outgoing >= (srcIo.outputs as number)) { + toast(`该节点最多允许 ${srcIo.outputs} 条出边`, 'warn'); + return; + } + } + } catch {} + // 单一同标签出边:删除同源 + 同标签的已有边 + for (let i = edges.length - 1; i >= 0; i--) { + const e = edges[i]; + const lab = e.label || 'default'; + if (e.from === sourceId && lab === label) edges.splice(i, 1); + } + // avoid duplicate for same pair+label + if ( + edges.some( + (e) => e.from === sourceId && e.to === targetId && (e.label || 'default') === label, + ) + ) + return; + edges.push({ id: newId('e'), from: sourceId, to: targetId, label }); + recordChange(); + // auto select the newly created edge + try { + const last = edges[edges.length - 1]; + activeEdgeId.value = last?.id || null; + activeNodeId.value = null; + } catch {} + } + + /** + * Derive available variables for the property panel. + * - Includes declared flow variables (global) + * - Includes variables produced by preceding nodes (saveAs/assign/itemVar etc.) + * If currentId is provided, only nodes before it in topological order are considered. + */ + function listAvailableVariables(currentId?: string): Array<{ + key: string; + origin: 'global' | 'node'; + nodeId?: string; + nodeName?: string; + }> { + const result: Array<{ + key: string; + origin: 'global' | 'node'; + nodeId?: string; + nodeName?: string; + }> = []; + const seen = new Set(); + + // 1) Flow-declared variables + const declared = (flowLocal.variables || []) as Array<{ key: string }>; + for (const v of declared) { + const k = String(v?.key || '').trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + result.push({ key: k, origin: 'global' }); + } + + // 2) Variables derived from previous nodes + const ordered = topoOrder(nodes as any, edges as any); + let cutoffIndex = + typeof currentId === 'string' ? ordered.findIndex((n) => n.id === currentId) : -1; + if (cutoffIndex < 0) cutoffIndex = ordered.length; // include all if not found + const prevNodes = ordered.slice(0, cutoffIndex); + for (const n of prevNodes) { + const cfg: any = (n as any).config || {}; + const nodeName = String((n as any).name || n.id || 'node'); + const pushVar = (k: string) => { + const key = String(k || '').trim(); + if (!key || seen.has(key)) return; + seen.add(key); + result.push({ key, origin: 'node', nodeId: n.id, nodeName }); + }; + // Generic saveAs + if (typeof cfg.saveAs === 'string') pushVar(cfg.saveAs); + // assign mapping (keys are variable names) + if (cfg.assign && typeof cfg.assign === 'object') { + for (const k of Object.keys(cfg.assign)) pushVar(k); + } + // loop elements: list var + item var + if ((n as any).type === 'loopElements') { + if (typeof cfg.saveAs === 'string') pushVar(cfg.saveAs); + if (typeof cfg.itemVar === 'string') pushVar(cfg.itemVar); + } + } + + return result; + } + + function importFromSteps() { + const arr = stepsToNodes(flowLocal.steps || []); + nodes.splice(0, nodes.length, ...arr); + edges.splice(0, edges.length, ...autoChainEdges(arr)); + layoutIfNeeded(); + recordChange(); + } + + // --- subflow management --- + const currentSubflowId = ref(null); + function ensureSubflows() { + if (!flowLocal.subflows) (flowLocal as any).subflows = {} as any; + } + function listSubflowIds(): string[] { + ensureSubflows(); + return Object.keys((flowLocal as any).subflows || {}); + } + function addSubflow(id: string) { + ensureSubflows(); + const sf = (flowLocal as any).subflows as any; + if (!id || sf[id]) return; + sf[id] = { nodes: [], edges: [] }; + recordChange(); + } + function removeSubflow(id: string) { + ensureSubflows(); + const sf = (flowLocal as any).subflows as any; + if (!sf[id]) return; + delete sf[id]; + if (currentSubflowId.value === id) switchToMain(); + recordChange(); + } + function flushCurrent() { + if (!currentSubflowId.value) { + // write back main + (flowLocal as any).nodes = JSON.parse(JSON.stringify(nodes)); + (flowLocal as any).edges = JSON.parse(JSON.stringify(edges)); + return; + } + ensureSubflows(); + (flowLocal as any).subflows[currentSubflowId.value] = { + nodes: JSON.parse(JSON.stringify(nodes)), + edges: JSON.parse(JSON.stringify(edges)), + }; + } + function switchToMain() { + flushCurrent(); + currentSubflowId.value = null; + nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify((flowLocal.nodes || []) as any))); + edges.splice(0, edges.length, ...JSON.parse(JSON.stringify((flowLocal.edges || []) as any))); + layoutIfNeeded(); + } + function switchToSubflow(id: string) { + flushCurrent(); + currentSubflowId.value = id; + ensureSubflows(); + const sf = (flowLocal as any).subflows[id] || { nodes: [], edges: [] }; + nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify(sf.nodes || []))); + edges.splice(0, edges.length, ...JSON.parse(JSON.stringify(sf.edges || []))); + layoutIfNeeded(); + } + const isEditingMain = () => currentSubflowId.value == null; + + /** + * Export flow for saving. This properly handles subflow editing: + * 1. Flushes current canvas state back to flowLocal + * 2. Returns a deep copy to avoid reference issues + * + * IMPORTANT: Always use this method for saving instead of directly + * accessing store.nodes/edges, which may contain subflow data. + * + * NOTE: flow.steps is no longer written here. The storage layer (flow-store.ts) + * will strip steps on save. Only nodes/edges are the source of truth. + */ + function exportFlowForSave(): FlowV2 { + // Step 1: Flush current canvas state to flowLocal + flushCurrent(); + + // Step 2: Return deep copy to prevent mutation + return JSON.parse(JSON.stringify(flowLocal)); + } + + function summarize(id?: string) { + const n = nodes.find((x) => x.id === id); + return summarizeNode(n || null); + } + + // 备用布局:分层 + 重心排序(不依赖外部库) + function layoutFallback() { + const idMap = new Map(); + nodes.forEach((n) => idMap.set(n.id, n)); + + // Build graph using all edges (include branches like case:/else/onError) + const inEdges = new Map(); + const outEdges = new Map(); + for (const n of nodes) { + inEdges.set(n.id, []); + outEdges.set(n.id, []); + } + for (const e of edges) { + if (!idMap.has(e.from) || !idMap.has(e.to)) continue; + inEdges.get(e.to)!.push(e); + outEdges.get(e.from)!.push(e); + } + + // Kahn topo with all edges; fall back to original order on cycles + const indeg = new Map(); + nodes.forEach((n) => indeg.set(n.id, inEdges.get(n.id)!.length)); + const q: string[] = []; + // Prefer trigger and existing left-most nodes first for stability + const roots = nodes + .filter((n) => (indeg.get(n.id) || 0) === 0) + .sort( + (a, b) => + (a.type === ('trigger' as any) ? -1 : 0) - (b.type === ('trigger' as any) ? -1 : 0), + ); + roots.forEach((r) => q.push(r.id)); + const topo: string[] = []; + const indegMut = new Map(indeg); + while (q.length) { + const v = q.shift()!; + topo.push(v); + for (const e of outEdges.get(v) || []) { + const d = (indegMut.get(e.to) || 0) - 1; + indegMut.set(e.to, d); + if (d === 0) q.push(e.to); + } + } + if (topo.length < nodes.length) { + // Graph may contain cycles; append remaining nodes in original order + for (const n of nodes) if (!topo.includes(n.id)) topo.push(n.id); + } + + // Level assignment: level = max(parent.level + 1) + const level = new Map(); + for (const id of topo) { + const parents = inEdges.get(id) || []; + let lv = 0; + for (const e of parents) lv = Math.max(lv, (level.get(e.from) || 0) + 1); + // Ensure trigger stays at level 0 + const node = idMap.get(id)!; + if ((node.type as any) === 'trigger') lv = 0; + level.set(id, lv); + } + + // Group nodes by level + const maxLevel = Math.max(0, ...Array.from(level.values())); + const layers: string[][] = Array.from({ length: maxLevel + 1 }, () => []); + for (const id of topo) layers[level.get(id) || 0].push(id); + + // Barycenter/median ordering per layer based on parent y-index + const yIndex = new Map(); + // initialize first layer stable order + layers[0].forEach((id, i) => yIndex.set(id, i)); + for (let lv = 1; lv < layers.length; lv++) { + const arr = layers[lv]; + const scored = arr.map((id) => { + const ps = inEdges.get(id) || []; + const parentIdx = ps + .map((e) => yIndex.get(e.from)) + .filter((v): v is number => typeof v === 'number'); + const score = parentIdx.length + ? parentIdx.reduce((a, b) => a + b, 0) / parentIdx.length + : 1e9; + return { id, score }; + }); + scored.sort((a, b) => a.score - b.score); + scored.forEach((s, i) => yIndex.set(s.id, i)); + layers[lv] = scored.map((s) => s.id); + } + + // Place nodes + const startX = 120; + const startY = 80; + const stepX = 280; // tighter than 300 to reduce wide gaps + const stepY = 110; + for (let lv = 0; lv < layers.length; lv++) { + const arr = layers[lv]; + for (let i = 0; i < arr.length; i++) { + const id = arr[i]; + const n = idMap.get(id)!; + n.ui = { x: startX + lv * stepX, y: startY + i * stepY } as any; + } + } + recordChange(); + } + + // 自动排版(ELK 优先): + // - 动态引入 elkjs,避免常驻体积 + // - 失败则回退到 layoutFallback() + async function layoutAuto() { + try { + // Dynamic import of bundled build to avoid 'web-worker' resolution issues + const mod: any = await import('elkjs/lib/elk.bundled.js'); + const ELK = mod.default || mod.ELK || mod; + const elk = new ELK(); + + // Estimate node sizes (px). Keep close to actual NodeCard dimensions. + const estimateSize = (n: NodeBase) => { + const baseW = 280; + let baseH = 72; + if ((n.type as any) === 'if') baseH = 110; + return { width: baseW, height: baseH }; + }; + + const children = nodes.map((n) => ({ id: n.id, ...estimateSize(n) })); + const elkEdges = edges + .filter((e) => nodes.some((n) => n.id === e.from) && nodes.some((n) => n.id === e.to)) + .map((e) => ({ id: e.id, sources: [e.from], targets: [e.to] })); + + const graph = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'RIGHT', + 'elk.layered.spacing.nodeNodeBetweenLayers': '80', + 'elk.spacing.nodeNode': '40', + 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', + }, + children, + edges: elkEdges, + } as any; + + const res = await elk.layout(graph); + const pos = new Map(); + for (const c of res.children || []) { + pos.set(String(c.id), { x: Math.round(c.x || 0), y: Math.round(c.y || 0) }); + } + // anchor + const startX = 120; + const startY = 80; + for (const n of nodes) { + const p = pos.get(n.id); + if (p) n.ui = { x: startX + p.x, y: startY + p.y } as any; + } + recordChange(); + } catch (e) { + // Fallback without dependency + try { + layoutFallback(); + toast('ELK 自动布局不可用,已使用备用布局', 'warn'); + } catch {} + } + } + + if (initial) initFromFlow(initial); + + return { + flowLocal, + nodes, + edges, + activeNodeId, + activeEdgeId, + pendingFrom, + pendingLabel, + currentSubflowId, + paletteTypes, + undo, + redo, + initFromFlow, + selectNode, + selectEdge, + addNode, + duplicateNode, + removeNode, + removeEdge, + setNodePosition, + addNodeAt, + connectFrom, + onConnect, + listAvailableVariables, + listSubflowIds, + addSubflow, + removeSubflow, + switchToMain, + switchToSubflow, + isEditingMain, + importFromSteps, + exportFlowForSave, + summarize, + layoutAuto, + }; +} diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldCode.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldCode.vue new file mode 100644 index 0000000..24a0c79 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldCode.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldDuration.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldDuration.vue new file mode 100644 index 0000000..2b95aa8 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldDuration.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldExpression.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldExpression.vue new file mode 100644 index 0000000..6fa3276 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldExpression.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue new file mode 100644 index 0000000..9f9dccb --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue @@ -0,0 +1,20 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue new file mode 100644 index 0000000..d06c971 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue new file mode 100644 index 0000000..c5f21ad --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue b/app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue new file mode 100644 index 0000000..da3489a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/BoltIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/BoltIcon.vue new file mode 100644 index 0000000..a384beb --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/BoltIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/CheckIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/CheckIcon.vue new file mode 100644 index 0000000..a5c40c4 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/CheckIcon.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/DatabaseIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/DatabaseIcon.vue new file mode 100644 index 0000000..1962723 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/DatabaseIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/DocumentIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/DocumentIcon.vue new file mode 100644 index 0000000..4f68c87 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/DocumentIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/EditIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/EditIcon.vue new file mode 100644 index 0000000..3d5627a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/EditIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/MarkerIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/MarkerIcon.vue new file mode 100644 index 0000000..1dc321d --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/MarkerIcon.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/RecordIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/RecordIcon.vue new file mode 100644 index 0000000..df97c62 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/RecordIcon.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/RefreshIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/RefreshIcon.vue new file mode 100644 index 0000000..8ab4738 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/RefreshIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/StopIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/StopIcon.vue new file mode 100644 index 0000000..57e1bb3 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/StopIcon.vue @@ -0,0 +1,15 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/TabIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/TabIcon.vue new file mode 100644 index 0000000..17ba4aa --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/TabIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/TrashIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/TrashIcon.vue new file mode 100644 index 0000000..9a3b0c9 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/TrashIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/VectorIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/VectorIcon.vue new file mode 100644 index 0000000..9bdffb6 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/VectorIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/WorkflowIcon.vue b/app/chrome-extension/entrypoints/popup/components/icons/WorkflowIcon.vue new file mode 100644 index 0000000..b64a152 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/WorkflowIcon.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/chrome-extension/entrypoints/popup/components/icons/index.ts b/app/chrome-extension/entrypoints/popup/components/icons/index.ts new file mode 100644 index 0000000..e86a5ac --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/icons/index.ts @@ -0,0 +1,13 @@ +export { default as DocumentIcon } from './DocumentIcon.vue'; +export { default as DatabaseIcon } from './DatabaseIcon.vue'; +export { default as BoltIcon } from './BoltIcon.vue'; +export { default as TrashIcon } from './TrashIcon.vue'; +export { default as CheckIcon } from './CheckIcon.vue'; +export { default as TabIcon } from './TabIcon.vue'; +export { default as VectorIcon } from './VectorIcon.vue'; +export { default as RecordIcon } from './RecordIcon.vue'; +export { default as StopIcon } from './StopIcon.vue'; +export { default as WorkflowIcon } from './WorkflowIcon.vue'; +export { default as RefreshIcon } from './RefreshIcon.vue'; +export { default as EditIcon } from './EditIcon.vue'; +export { default as MarkerIcon } from './MarkerIcon.vue'; diff --git a/app/chrome-extension/entrypoints/popup/index.html b/app/chrome-extension/entrypoints/popup/index.html new file mode 100644 index 0000000..5a2184e --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/index.html @@ -0,0 +1,13 @@ + + + + + + Default Popup Title + + + +
+ + + diff --git a/app/chrome-extension/entrypoints/popup/main.ts b/app/chrome-extension/entrypoints/popup/main.ts new file mode 100644 index 0000000..a3e42f6 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/main.ts @@ -0,0 +1,16 @@ +import { createApp } from 'vue'; +import { NativeMessageType } from 'chrome-mcp-shared'; +import './style.css'; +// 引入AgentChat主题样式 +import '../sidepanel/styles/agent-chat.css'; +import { preloadAgentTheme } from '../sidepanel/composables/useAgentTheme'; +import App from './App.vue'; + +// 在Vue挂载前预加载主题,防止主题闪烁 +preloadAgentTheme().then(() => { + // Trigger ensure native connection (fire-and-forget, don't block UI mounting) + void chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => { + // Silent failure - background will handle reconnection + }); + createApp(App).mount('#app'); +}); diff --git a/app/chrome-extension/entrypoints/popup/style.css b/app/chrome-extension/entrypoints/popup/style.css new file mode 100644 index 0000000..a9b6d9a --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/style.css @@ -0,0 +1,246 @@ +/* 现代化全局样式 */ +:root { + /* 字体系统 */ + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + font-weight: 400; + + /* 颜色系统 */ + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --primary-color: #667eea; + --primary-dark: #5a67d8; + --secondary-color: #764ba2; + + --success-color: #48bb78; + --warning-color: #ed8936; + --error-color: #f56565; + --info-color: #4299e1; + + --text-primary: #2d3748; + --text-secondary: #4a5568; + --text-muted: #718096; + --text-light: #a0aec0; + + --bg-primary: #ffffff; + --bg-secondary: #f7fafc; + --bg-tertiary: #edf2f7; + --bg-overlay: rgba(255, 255, 255, 0.95); + + --border-color: #e2e8f0; + --border-light: #f1f5f9; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1); + + /* 间距系统 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 20px; + --spacing-2xl: 24px; + --spacing-3xl: 32px; + + /* 圆角系统 */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-2xl: 16px; + + /* 动画 */ + --transition-fast: 0.15s ease; + --transition-normal: 0.3s ease; + --transition-slow: 0.5s ease; + + /* 字体渲染优化 */ + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +/* 重置样式 */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + margin: 0; + padding: 0; + width: 400px; + min-height: 500px; + max-height: 600px; + overflow: hidden; + font-family: inherit; + background: var(--bg-secondary); + color: var(--text-primary); +} + +#app { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +/* 链接样式 */ +a { + color: var(--primary-color); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--primary-dark); +} + +/* 按钮基础样式重置 */ +button { + font-family: inherit; + font-size: inherit; + line-height: inherit; + border: none; + background: none; + cursor: pointer; + transition: all var(--transition-normal); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +/* 输入框基础样式 */ +input, +textarea, +select { + font-family: inherit; + font-size: inherit; + line-height: inherit; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-primary); + color: var(--text-primary); + transition: all var(--transition-fast); +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* 选择文本样式 */ +::selection { + background: rgba(102, 126, 234, 0.2); + color: var(--text-primary); +} + +/* 焦点可见性 */ +:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +/* 动画关键帧 */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* 响应式断点 */ +@media (max-width: 420px) { + :root { + --spacing-xs: 3px; + --spacing-sm: 6px; + --spacing-md: 10px; + --spacing-lg: 14px; + --spacing-xl: 18px; + --spacing-2xl: 22px; + --spacing-3xl: 28px; + } +} + +/* 高对比度模式支持 */ +@media (prefers-contrast: high) { + :root { + --border-color: #000000; + --text-muted: #000000; + } +} + +/* 减少动画偏好 */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app/chrome-extension/entrypoints/quick-panel.content.ts b/app/chrome-extension/entrypoints/quick-panel.content.ts new file mode 100644 index 0000000..c25fd72 --- /dev/null +++ b/app/chrome-extension/entrypoints/quick-panel.content.ts @@ -0,0 +1,115 @@ +/** + * Quick Panel Content Script + * + * This content script manages the Quick Panel AI Chat feature on web pages. + * It responds to: + * - Background messages (toggle_quick_panel from keyboard shortcut) + * - Direct programmatic calls + * + * The Quick Panel provides a floating AI chat interface that: + * - Uses Shadow DOM for style isolation + * - Streams AI responses in real-time + * - Supports keyboard shortcuts (Enter to send, Esc to close) + * - Collects page context (URL, selection) automatically + */ + +import { createQuickPanelController, type QuickPanelController } from '@/shared/quick-panel'; + +export default defineContentScript({ + matches: [''], + runAt: 'document_idle', + + main() { + console.log('[QuickPanelContentScript] Content script loaded on:', window.location.href); + let controller: QuickPanelController | null = null; + + /** + * Ensure controller is initialized (lazy initialization) + */ + function ensureController(): QuickPanelController { + if (!controller) { + controller = createQuickPanelController({ + title: 'Agent', + subtitle: 'Quick Panel', + placeholder: 'Ask about this page...', + }); + } + return controller; + } + + /** + * Handle messages from background script + */ + function handleMessage( + message: unknown, + _sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, + ): boolean | void { + const msg = message as { action?: string } | undefined; + + if (msg?.action === 'toggle_quick_panel') { + console.log('[QuickPanelContentScript] Received toggle_quick_panel message'); + try { + const ctrl = ensureController(); + ctrl.toggle(); + const visible = ctrl.isVisible(); + console.log('[QuickPanelContentScript] Toggle completed, visible:', visible); + sendResponse({ success: true, visible }); + } catch (err) { + console.error('[QuickPanelContentScript] Toggle error:', err); + sendResponse({ success: false, error: String(err) }); + } + return true; // Async response + } + + if (msg?.action === 'show_quick_panel') { + try { + const ctrl = ensureController(); + ctrl.show(); + sendResponse({ success: true }); + } catch (err) { + console.error('[QuickPanelContentScript] Show error:', err); + sendResponse({ success: false, error: String(err) }); + } + return true; + } + + if (msg?.action === 'hide_quick_panel') { + try { + if (controller) { + controller.hide(); + } + sendResponse({ success: true }); + } catch (err) { + console.error('[QuickPanelContentScript] Hide error:', err); + sendResponse({ success: false, error: String(err) }); + } + return true; + } + + if (msg?.action === 'get_quick_panel_status') { + sendResponse({ + success: true, + visible: controller?.isVisible() ?? false, + initialized: controller !== null, + }); + return true; + } + + // Not handled + return false; + } + + // Register message listener + chrome.runtime.onMessage.addListener(handleMessage); + + // Cleanup on page unload + window.addEventListener('unload', () => { + chrome.runtime.onMessage.removeListener(handleMessage); + if (controller) { + controller.dispose(); + controller = null; + } + }); + }, +}); diff --git a/app/chrome-extension/entrypoints/shared/composables/index.ts b/app/chrome-extension/entrypoints/shared/composables/index.ts new file mode 100644 index 0000000..a52c408 --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/composables/index.ts @@ -0,0 +1,11 @@ +/** + * @fileoverview Shared UI Composables + * @description Composables shared between multiple UI entrypoints (Sidepanel, Builder, Popup, etc.) + * + * Note: These composables are for UI-only use. Do not import them in background scripts + * as they depend on Vue and will bloat the service worker bundle. + */ + +// RR V3 RPC Client +export { useRRV3Rpc } from './useRRV3Rpc'; +export type { UseRRV3Rpc, UseRRV3RpcOptions, RpcRequestOptions } from './useRRV3Rpc'; diff --git a/app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts b/app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts new file mode 100644 index 0000000..3d527f9 --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts @@ -0,0 +1,504 @@ +/** + * @fileoverview RR V3 Port-RPC Client Composable (Shared) + * @description RPC client for UI components to connect with Background Service Worker + * + * This composable is shared between Sidepanel, Builder, and other UI entrypoints. + * + * Responsibilities: + * - Connect to background via chrome.runtime.Port + * - Provide request/response RPC calls (with timeout and cancellation) + * - Support event stream subscription + * - Auto-reconnect with exponential backoff + * + * Design considerations: + * - MV3 service worker may be terminated due to idle, causing Port disconnect + * - Implement idempotent reconnection and subscription recovery + */ + +import { computed, onUnmounted, ref, shallowRef, type ComputedRef, type Ref } from 'vue'; + +import type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json'; +import type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events'; +import type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids'; +import { + RR_V3_PORT_NAME, + createRpcRequest, + isRpcEvent, + isRpcResponse, + type RpcMethod, +} from '@/entrypoints/background/record-replay-v3/engine/transport/rpc'; + +// ==================== Types ==================== + +/** RPC request options */ +export interface RpcRequestOptions { + /** Timeout in milliseconds, 0 means no timeout */ + timeoutMs?: number; + /** Abort signal for cancellation */ + signal?: AbortSignal; +} + +/** Composable configuration */ +export interface UseRRV3RpcOptions { + /** Default request timeout (ms) */ + requestTimeoutMs?: number; + /** Maximum reconnect attempts */ + maxReconnectAttempts?: number; + /** Base delay for reconnection (ms) */ + baseReconnectDelayMs?: number; + /** Auto-connect on initialization */ + autoConnect?: boolean; + /** Connection state change callback */ + onConnectionChange?: (connected: boolean) => void; + /** Error callback */ + onError?: (error: string) => void; +} + +/** Event listener function */ +type EventListener = (event: RunEvent) => void; + +/** Pending request entry */ +interface PendingRequest { + method: RpcMethod; + resolve: (value: JsonValue) => void; + reject: (error: Error) => void; + timeoutId: ReturnType | null; + /** AbortSignal reference for cleanup */ + signal?: AbortSignal; + /** Abort handler for cleanup */ + abortHandler?: () => void; +} + +/** Composable return type */ +export interface UseRRV3Rpc { + // Connection state + connected: Ref; + connecting: Ref; + reconnecting: Ref; + reconnectAttempts: Ref; + lastError: Ref; + isReady: ComputedRef; + + // Diagnostics + pendingCount: Ref; + subscribedRunIds: Ref>; + + // Connection lifecycle + connect: () => Promise; + disconnect: (reason?: string) => void; + ensureConnected: () => Promise; + + // RPC calls + request: ( + method: RpcMethod, + params?: JsonObject, + options?: RpcRequestOptions, + ) => Promise; + + // Event subscription + subscribe: (runId?: RunId | null) => Promise; + unsubscribe: (runId?: RunId | null) => Promise; + onEvent: (listener: EventListener) => () => void; +} + +// ==================== Helpers ==================== + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRunEvent(value: unknown): value is RunEvent { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + typeof obj.runId === 'string' && + typeof obj.type === 'string' && + typeof obj.seq === 'number' && + typeof obj.ts === 'number' + ); +} + +// ==================== Composable ==================== + +/** + * RR V3 Port-RPC client + */ +export function useRRV3Rpc(options: UseRRV3RpcOptions = {}): UseRRV3Rpc { + // Configuration + const DEFAULT_TIMEOUT_MS = options.requestTimeoutMs ?? 12_000; + const MAX_RECONNECT_ATTEMPTS = options.maxReconnectAttempts ?? 8; + const BASE_RECONNECT_DELAY_MS = options.baseReconnectDelayMs ?? 500; + + // Reactive state + const connected = ref(false); + const connecting = ref(false); + const reconnecting = ref(false); + const reconnectAttempts = ref(0); + const lastError = ref(null); + const pendingCount = ref(0); + const subscribedRunIds = ref>([]); + + // Internal state (non-reactive) + const port = shallowRef(null); + const pendingRequests = new Map(); + const eventListeners = new Set(); + const desiredSubscriptions = new Set(); + let connectPromise: Promise | null = null; + let reconnectTimer: ReturnType | null = null; + let manualDisconnect = false; + + // Computed + const isReady = computed(() => connected.value && port.value !== null); + + // ==================== Internal Methods ==================== + + function setError(message: string | null): void { + lastError.value = message; + if (message) options.onError?.(message); + } + + function setConnected(next: boolean): void { + if (connected.value === next) return; + connected.value = next; + options.onConnectionChange?.(next); + } + + function syncSubscriptionsSnapshot(): void { + const arr = Array.from(desiredSubscriptions.values()); + arr.sort((a, b) => { + // Both null - equal + if (a === null && b === null) return 0; + // null comes first + if (a === null) return -1; + if (b === null) return 1; + return String(a).localeCompare(String(b)); + }); + subscribedRunIds.value = arr; + } + + /** + * Clean up a pending request entry (timeout, abort listener) + */ + function cleanupPendingRequest(entry: PendingRequest): void { + if (entry.timeoutId) { + clearTimeout(entry.timeoutId); + entry.timeoutId = null; + } + if (entry.signal && entry.abortHandler) { + try { + entry.signal.removeEventListener('abort', entry.abortHandler); + } catch { + // Ignore - signal may be invalid + } + } + } + + function rejectAllPending(reason: string): void { + const error = new Error(reason); + for (const [requestId, entry] of pendingRequests) { + cleanupPendingRequest(entry); + entry.reject(error); + pendingRequests.delete(requestId); + } + pendingCount.value = 0; + } + + async function rehydrateSubscriptions(): Promise { + if (!isReady.value || desiredSubscriptions.size === 0) return; + + for (const runId of desiredSubscriptions) { + try { + const params: JsonObject = runId === null ? {} : { runId }; + await request('rr_v3.subscribe', params).catch(() => { + // Best-effort, ignore errors + }); + } catch { + // Ignore + } + } + } + + function scheduleReconnect(): void { + if (manualDisconnect || reconnectTimer) return; + + if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) { + reconnecting.value = false; + setError('RR V3 RPC: max reconnect attempts reached'); + return; + } + + reconnecting.value = true; + const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts.value); + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + reconnectAttempts.value += 1; + void connect().then((ok) => { + if (!ok) scheduleReconnect(); + }); + }, delay); + } + + // ==================== Port Handlers ==================== + + function handlePortDisconnect(): void { + // Capture disconnect reason for debugging + const disconnectReason = chrome.runtime.lastError?.message; + const reason = disconnectReason + ? `RR V3 RPC disconnected: ${disconnectReason}` + : 'RR V3 RPC disconnected'; + + port.value = null; + setConnected(false); + connecting.value = false; + rejectAllPending(reason); + + // Update lastError for UI visibility (only on unexpected disconnect) + if (!manualDisconnect) { + setError(reason); + scheduleReconnect(); + } + } + + function handlePortMessage(msg: unknown): void { + // Handle RPC response + if (isRpcResponse(msg)) { + const entry = pendingRequests.get(msg.requestId); + if (!entry) return; + + pendingRequests.delete(msg.requestId); + pendingCount.value = pendingRequests.size; + + // Clean up timeout and abort listener + cleanupPendingRequest(entry); + + if (msg.ok) { + entry.resolve(msg.result as JsonValue); + } else { + entry.reject(new Error(msg.error || `RPC error: ${entry.method}`)); + } + return; + } + + // Handle event push + if (isRpcEvent(msg)) { + const event = msg.event; + if (!isRunEvent(event)) return; + + for (const listener of eventListeners) { + try { + listener(event); + } catch (e) { + console.error('[useRRV3Rpc] Event listener error:', e); + } + } + } + } + + // ==================== Public Methods ==================== + + async function connect(): Promise { + if (isReady.value) return true; + if (connectPromise) return connectPromise; + + connectPromise = (async () => { + manualDisconnect = false; + connecting.value = true; + setError(null); + + try { + if (typeof chrome === 'undefined' || !chrome.runtime?.connect) { + setError('chrome.runtime.connect not available'); + return false; + } + + const p = chrome.runtime.connect({ name: RR_V3_PORT_NAME }); + port.value = p; + + // Reset reconnect state + reconnectAttempts.value = 0; + reconnecting.value = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + p.onMessage.addListener(handlePortMessage); + p.onDisconnect.addListener(handlePortDisconnect); + + setConnected(true); + + // Restore subscriptions + void rehydrateSubscriptions(); + + return true; + } catch (error) { + setError(`Connection failed: ${toErrorMessage(error)}`); + return false; + } finally { + connecting.value = false; + connectPromise = null; + } + })(); + + return connectPromise; + } + + function disconnect(reason?: string): void { + manualDisconnect = true; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnecting.value = false; + + const p = port.value; + port.value = null; + setConnected(false); + connecting.value = false; + + rejectAllPending(reason || 'RR V3 RPC: client disconnected'); + + if (p) { + try { + p.onMessage.removeListener(handlePortMessage); + p.onDisconnect.removeListener(handlePortDisconnect); + p.disconnect(); + } catch { + // Ignore + } + } + } + + async function ensureConnected(): Promise { + if (isReady.value) return true; + return connect(); + } + + async function request( + method: RpcMethod, + params?: JsonObject, + reqOptions: RpcRequestOptions = {}, + ): Promise { + const ready = await ensureConnected(); + const p = port.value; + + if (!ready || !p) { + throw new Error('RR V3 RPC: not connected'); + } + + const timeoutMs = reqOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const { signal } = reqOptions; + + if (signal?.aborted) { + throw new Error('RPC request already aborted'); + } + + const req = createRpcRequest(method, params); + + return new Promise((resolve, reject) => { + const entry: PendingRequest = { + method, + resolve: resolve as (value: JsonValue) => void, + reject, + timeoutId: null, + signal, + }; + + // Helper to complete request with cleanup + const complete = (fn: () => void) => { + pendingRequests.delete(req.requestId); + pendingCount.value = pendingRequests.size; + cleanupPendingRequest(entry); + fn(); + }; + + // Timeout handling + if (timeoutMs > 0) { + entry.timeoutId = setTimeout(() => { + complete(() => reject(new Error(`RPC timeout (${timeoutMs}ms): ${method}`))); + }, timeoutMs); + } + + // Abort handling + if (signal) { + const onAbort = () => { + complete(() => reject(new Error('RPC request aborted'))); + }; + entry.abortHandler = onAbort; + signal.addEventListener('abort', onAbort, { once: true }); + } + + pendingRequests.set(req.requestId, entry); + pendingCount.value = pendingRequests.size; + + try { + p.postMessage(req); + } catch (e) { + complete(() => reject(new Error(`Failed to send RPC request: ${toErrorMessage(e)}`))); + } + }); + } + + async function subscribe(runId: RunId | null = null): Promise { + desiredSubscriptions.add(runId); + syncSubscriptionsSnapshot(); + + try { + const params: JsonObject = runId === null ? {} : { runId }; + await request('rr_v3.subscribe', params); + return true; + } catch (error) { + setError(toErrorMessage(error)); + return false; + } + } + + async function unsubscribe(runId: RunId | null = null): Promise { + desiredSubscriptions.delete(runId); + syncSubscriptionsSnapshot(); + + try { + const params: JsonObject = runId === null ? {} : { runId }; + await request('rr_v3.unsubscribe', params); + return true; + } catch (error) { + setError(toErrorMessage(error)); + return false; + } + } + + function onEvent(listener: EventListener): () => void { + eventListeners.add(listener); + return () => eventListeners.delete(listener); + } + + // ==================== Lifecycle ==================== + + onUnmounted(() => { + disconnect('Component unmounted'); + }); + + if (options.autoConnect) { + void ensureConnected(); + } + + return { + connected, + connecting, + reconnecting, + reconnectAttempts, + lastError, + isReady, + pendingCount, + subscribedRunIds, + connect, + disconnect, + ensureConnected, + request, + subscribe, + unsubscribe, + onEvent, + }; +} diff --git a/app/chrome-extension/entrypoints/shared/utils/index.ts b/app/chrome-extension/entrypoints/shared/utils/index.ts new file mode 100644 index 0000000..522adcd --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/utils/index.ts @@ -0,0 +1,14 @@ +/** + * @fileoverview Shared Utilities Index + * @description Utility functions shared between UI entrypoints + */ + +// Flow conversion utilities +export { + flowV2ToV3ForRpc, + flowV3ToV2ForBuilder, + isFlowV3, + isFlowV2, + extractFlowCandidates, + type FlowConversionResult, +} from './rr-flow-convert'; diff --git a/app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts b/app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts new file mode 100644 index 0000000..338a856 --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts @@ -0,0 +1,141 @@ +/** + * @fileoverview V2/V3 Flow 双向转换工具 + * @description 桥接 Builder V2 Flow 类型与 V3 RPC FlowV3 类型 + * + * 设计说明: + * - Builder store 目前仍使用 V2 类型 (type, version, steps) + * - RPC 层使用 V3 类型 (kind, schemaVersion, entryNodeId) + * - 本模块提供 UI 层的类型转换,封装底层转换器 + */ + +import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types'; +import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow'; +import { + convertFlowV2ToV3, + convertFlowV3ToV2, +} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3'; + +// ==================== Types ==================== + +export interface FlowConversionResult { + flow: T; + warnings: string[]; +} + +// ==================== V2 -> V3 (for RPC calls) ==================== + +/** + * 将 V2 Flow 转换为 V3 格式,用于 RPC 保存 + * @param flowV2 Builder store 中的 V2 Flow + * @returns V3 Flow 和警告信息 + * @throws 转换失败时抛出错误 + */ +export function flowV2ToV3ForRpc(flowV2: FlowV2): FlowConversionResult { + const result = convertFlowV2ToV3(flowV2 as unknown as Parameters[0]); + + if (!result.success || !result.data) { + const errorMsg = + result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error'; + throw new Error(`V2→V3 conversion failed: ${errorMsg}`); + } + + return { + flow: result.data, + warnings: result.warnings, + }; +} + +// ==================== V3 -> V2 (for Builder display) ==================== + +/** + * 将 V3 Flow 转换为 V2 格式,用于 Builder 显示和编辑 + * @param flowV3 从 RPC 获取的 V3 Flow + * @returns V2 Flow 和警告信息 + * @throws 转换失败时抛出错误 + */ +export function flowV3ToV2ForBuilder(flowV3: FlowV3): FlowConversionResult { + const result = convertFlowV3ToV2(flowV3); + + if (!result.success || !result.data) { + const errorMsg = + result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error'; + throw new Error(`V3→V2 conversion failed: ${errorMsg}`); + } + + return { + flow: result.data as unknown as FlowV2, + warnings: result.warnings, + }; +} + +// ==================== Type Guards ==================== + +/** + * 判断是否为 V3 Flow + * @description 用于导入时判断 JSON 格式 + */ +export function isFlowV3(value: unknown): value is FlowV3 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const obj = value as Record; + return ( + obj.schemaVersion === 3 && + typeof obj.id === 'string' && + typeof obj.name === 'string' && + typeof obj.entryNodeId === 'string' && + Array.isArray(obj.nodes) + ); +} + +/** + * 判断是否为 V2 Flow + * @description 用于导入时判断 JSON 格式 + */ +export function isFlowV2(value: unknown): value is FlowV2 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const obj = value as Record; + return ( + typeof obj.id === 'string' && + typeof obj.name === 'string' && + // V2 有 version 字段(数字),且没有 schemaVersion + typeof obj.version === 'number' && + obj.schemaVersion === undefined && + // V2 可能有 steps 或 nodes + (Array.isArray(obj.steps) || Array.isArray(obj.nodes)) + ); +} + +// ==================== Import Helpers ==================== + +/** + * 从导入的 JSON 中提取 Flow 候选列表 + * @description 支持单个 Flow、Flow 数组、或 { flows: Flow[] } 格式 + */ +export function extractFlowCandidates(parsed: unknown): unknown[] { + // 数组格式 + if (Array.isArray(parsed)) { + return parsed; + } + + // 对象格式 + if (parsed && typeof parsed === 'object') { + const obj = parsed as Record; + + // { flows: [...] } 格式 + if (Array.isArray(obj.flows)) { + return obj.flows; + } + + // 单个 Flow 对象 + if (obj.id && (Array.isArray(obj.steps) || Array.isArray(obj.nodes))) { + return [obj]; + } + } + + return []; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/App.vue b/app/chrome-extension/entrypoints/sidepanel/App.vue new file mode 100644 index 0000000..9b59e36 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/App.vue @@ -0,0 +1,1368 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/AgentChat.vue b/app/chrome-extension/entrypoints/sidepanel/components/AgentChat.vue new file mode 100644 index 0000000..2a11599 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/AgentChat.vue @@ -0,0 +1,1401 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/SidepanelNavigator.vue b/app/chrome-extension/entrypoints/sidepanel/components/SidepanelNavigator.vue new file mode 100644 index 0000000..6891636 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/SidepanelNavigator.vue @@ -0,0 +1,441 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue new file mode 100644 index 0000000..a661860 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue @@ -0,0 +1,229 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentComposer.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentComposer.vue new file mode 100644 index 0000000..826e490 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentComposer.vue @@ -0,0 +1,731 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue new file mode 100644 index 0000000..70095e9 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue @@ -0,0 +1,130 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/ConnectionStatus.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent/ConnectionStatus.vue new file mode 100644 index 0000000..7f686c8 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/ConnectionStatus.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/MessageItem.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent/MessageItem.vue new file mode 100644 index 0000000..cf0e068 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/MessageItem.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/MessageList.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent/MessageList.vue new file mode 100644 index 0000000..d470cef --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/MessageList.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectCreateForm.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectCreateForm.vue new file mode 100644 index 0000000..bfb8ab3 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectCreateForm.vue @@ -0,0 +1,81 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectSelector.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectSelector.vue new file mode 100644 index 0000000..8f37cdb --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectSelector.vue @@ -0,0 +1,97 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent/index.ts b/app/chrome-extension/entrypoints/sidepanel/components/agent/index.ts new file mode 100644 index 0000000..880152b --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent/index.ts @@ -0,0 +1,12 @@ +/** + * Agent Chat Components + * Export all sub-components for the agent chat feature. + */ +export { default as ConnectionStatus } from './ConnectionStatus.vue'; +export { default as ProjectSelector } from './ProjectSelector.vue'; +export { default as ProjectCreateForm } from './ProjectCreateForm.vue'; +export { default as CliSettings } from './CliSettings.vue'; +export { default as MessageList } from './MessageList.vue'; +export { default as MessageItem } from './MessageItem.vue'; +export { default as ChatInput } from './ChatInput.vue'; +export { default as AttachmentPreview } from './AttachmentPreview.vue'; diff --git a/app/chrome-extension/entrypoints/sidepanel/components/rr-v3/DebuggerPanel.vue b/app/chrome-extension/entrypoints/sidepanel/components/rr-v3/DebuggerPanel.vue new file mode 100644 index 0000000..7ba302e --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/rr-v3/DebuggerPanel.vue @@ -0,0 +1,377 @@ + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowListItem.vue b/app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowListItem.vue new file mode 100644 index 0000000..b994609 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowListItem.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowsView.vue b/app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowsView.vue new file mode 100644 index 0000000..21e69d5 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowsView.vue @@ -0,0 +1,747 @@ + + + + + diff --git a/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts b/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts new file mode 100644 index 0000000..b37c75a --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts @@ -0,0 +1,2 @@ +export { default as WorkflowsView } from './WorkflowsView.vue'; +export { default as WorkflowListItem } from './WorkflowListItem.vue'; diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/index.ts b/app/chrome-extension/entrypoints/sidepanel/composables/index.ts new file mode 100644 index 0000000..c66aa7e --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/index.ts @@ -0,0 +1,65 @@ +/** + * Agent Chat Composables + * Export all composables for agent chat functionality. + */ +export { useAgentServer } from './useAgentServer'; +export { useAgentChat } from './useAgentChat'; +export { useAgentProjects } from './useAgentProjects'; +export { useAgentSessions } from './useAgentSessions'; +export { useAttachments, type AttachmentWithPreview } from './useAttachments'; +export { useAgentTheme, preloadAgentTheme, THEME_LABELS } from './useAgentTheme'; +export { useAgentThreads, AGENT_SERVER_PORT_KEY } from './useAgentThreads'; +export { useWebEditorTxState, WEB_EDITOR_TX_STATE_INJECTION_KEY } from './useWebEditorTxState'; +export { useAgentChatViewRoute } from './useAgentChatViewRoute'; + +export type { UseAgentServerOptions } from './useAgentServer'; +export type { UseAgentChatOptions } from './useAgentChat'; +export type { UseAgentProjectsOptions } from './useAgentProjects'; +export type { UseAgentSessionsOptions } from './useAgentSessions'; +export type { AgentThemeId, UseAgentTheme } from './useAgentTheme'; +export type { + AgentThread, + TimelineItem, + ToolPresentation, + ToolKind, + ToolSeverity, + AgentThreadState, + UseAgentThreadsOptions, + ThreadHeader, + WebEditorApplyMeta, +} from './useAgentThreads'; +export type { UseWebEditorTxStateOptions, WebEditorTxStateReturn } from './useWebEditorTxState'; +export type { + AgentChatView, + AgentChatRouteState, + UseAgentChatViewRouteOptions, + UseAgentChatViewRoute, +} from './useAgentChatViewRoute'; + +// RR V3 Composables +export { useRRV3Rpc } from './useRRV3Rpc'; +export { useRRV3Debugger } from './useRRV3Debugger'; +export type { UseRRV3Rpc, UseRRV3RpcOptions, RpcRequestOptions } from './useRRV3Rpc'; +export type { UseRRV3Debugger, UseRRV3DebuggerOptions } from './useRRV3Debugger'; + +// Textarea Auto-Resize +export { useTextareaAutoResize } from './useTextareaAutoResize'; +export type { + UseTextareaAutoResizeOptions, + UseTextareaAutoResizeReturn, +} from './useTextareaAutoResize'; + +// Fake Caret (comet tail animation) +export { useFakeCaret } from './useFakeCaret'; +export type { UseFakeCaretOptions, UseFakeCaretReturn, FakeCaretTrailPoint } from './useFakeCaret'; + +// Open Project Preference +export { useOpenProjectPreference } from './useOpenProjectPreference'; +export type { + UseOpenProjectPreferenceOptions, + UseOpenProjectPreference, +} from './useOpenProjectPreference'; + +// Agent Input Preferences (fake caret, etc.) +export { useAgentInputPreferences } from './useAgentInputPreferences'; +export type { UseAgentInputPreferences } from './useAgentInputPreferences'; diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts new file mode 100644 index 0000000..51cae91 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts @@ -0,0 +1,504 @@ +/** + * Composable for managing Agent Chat state and messages. + * Handles message sending, receiving, and cancellation. + */ +import { ref, computed } from 'vue'; +import type { + AgentMessage, + AgentActRequest, + AgentActRequestClientMeta, + AgentAttachment, + RealtimeEvent, + AgentStatusEvent, + AgentCliPreference, + AgentUsageStats, +} from 'chrome-mcp-shared'; + +/** + * Request lifecycle state. + * - 'idle': No active request + * - 'starting': Request accepted, waiting for engine initialization + * - 'ready': Engine initialized, preparing to run + * - 'running': Engine actively processing (may emit tool_use/tool_result) + * - 'completed': Request finished successfully + * - 'cancelled': Request was cancelled by user + * - 'error': Request failed with error + */ +export type RequestState = 'idle' | AgentStatusEvent['status']; + +export interface UseAgentChatOptions { + getServerPort: () => number | null; + getSessionId: () => string; + ensureServer: () => Promise; + openEventSource: () => void; +} + +export function useAgentChat(options: UseAgentChatOptions) { + // State + const messages = ref([]); + const input = ref(''); + const sending = ref(false); + /** + * Message-level streaming state. + * True when receiving delta updates for assistant/tool messages. + * Note: This is separate from requestState - a request can be 'running' + * even when isStreaming is false (e.g., during tool execution). + */ + const isStreaming = ref(false); + /** + * Request lifecycle state driven by status events. + * Use this (via isRequestActive) for UI elements like stop button, + * loading indicators, and running badges. + */ + const requestState = ref('idle'); + const errorMessage = ref(null); + const currentRequestId = ref(null); + const cancelling = ref(false); + const attachments = ref([]); + const lastUsage = ref(null); + + // Computed + const canSend = computed(() => { + return input.value.trim().length > 0 && !sending.value; + }); + + /** + * Whether there is an active request in progress. + * Use this for UI elements like stop button, loading indicators, and running badges. + */ + const isRequestActive = computed(() => { + return ( + requestState.value === 'starting' || + requestState.value === 'ready' || + requestState.value === 'running' + ); + }); + + /** + * Check if an incoming event belongs to a different active request. + * Used to filter out stale events from previous requests. + */ + function isDifferentActiveRequest(incomingRequestId?: string): boolean { + const incoming = incomingRequestId?.trim(); + const current = currentRequestId.value?.trim(); + // No incoming ID or no current ID means we can't determine - don't filter + if (!incoming || !current) return false; + // Same request ID - don't filter + if (incoming === current) return false; + // Different request ID while we have an active request - filter it out + return isRequestActive.value; + } + + /** + * Handle incoming realtime events. + * Events are filtered by sessionId to prevent cross-session state pollution + * when user switches sessions while SSE connection is still active. + */ + function handleRealtimeEvent(event: RealtimeEvent): void { + const currentSessionId = options.getSessionId(); + + switch (event.type) { + case 'message': + // Guard: only handle messages for the current session + if (event.data.sessionId !== currentSessionId) { + return; + } + handleMessageEvent(event.data); + break; + case 'status': + // Guard: only handle status for the current session + if (event.data.sessionId !== currentSessionId) { + return; + } + handleStatusEvent(event.data); + break; + case 'error': + // Error events may not have sessionId, but if they do, filter + if (event.data?.sessionId && event.data.sessionId !== currentSessionId) { + return; + } + // Filter out errors from different active requests + if (isDifferentActiveRequest(event.data?.requestId)) { + return; + } + errorMessage.value = event.error; + isStreaming.value = false; + requestState.value = 'error'; + // Clear requestId if it matches the error event's requestId (or unconditionally if no requestId in error) + if (!event.data?.requestId || event.data.requestId === currentRequestId.value) { + currentRequestId.value = null; + } + break; + case 'connected': + console.log('[AgentChat] Connected to session:', event.data.sessionId); + break; + case 'heartbeat': + // Heartbeat received, connection is alive + break; + case 'usage': + // Guard: only accept usage for the current session + if (event.data?.sessionId && event.data.sessionId !== currentSessionId) { + return; + } + lastUsage.value = event.data; + break; + } + } + + // Handle message events + function handleMessageEvent(msg: AgentMessage): void { + // For user messages from server, replace local optimistic message + // Server echoes user message with real id/metadata, but we want to keep our display text + // (which doesn't include injected context like web editor selection) + if (msg.role === 'user' && msg.requestId) { + const optimisticIndex = messages.value.findIndex( + (m) => m.role === 'user' && m.requestId === msg.requestId && m.id.startsWith('temp-'), + ); + if (optimisticIndex >= 0) { + // Replace optimistic message: keep display content, update id and metadata + const optimistic = messages.value[optimisticIndex]; + messages.value[optimisticIndex] = { + ...msg, + // Preserve the display content (user's raw input without injected context) + content: optimistic.content, + // Prefer server metadata, fallback to optimistic metadata (for chip rendering) + metadata: msg.metadata ?? optimistic.metadata, + }; + return; + } + } + + // Check if this message belongs to a different active request + // Note: We still save the message to messages array (for auditing/replay), + // but skip state updates if it's from a stale request + const msgRequestId = msg.requestId?.trim() || undefined; + const isStaleForState = isDifferentActiveRequest(msgRequestId); + + const existingIndex = messages.value.findIndex((m) => m.id === msg.id); + + if (existingIndex >= 0) { + // Update existing message (streaming update) + messages.value[existingIndex] = msg; + } else { + // Add new message - always save, even if stale for state + messages.value.push(msg); + } + + // Skip state updates for messages from different active requests + if (isStaleForState) { + return; + } + + // Track requestId from messages (handles cases where status events were missed) + if (msgRequestId && msgRequestId !== currentRequestId.value) { + currentRequestId.value = msgRequestId; + } + + // Update message-level streaming state (delta updates) + // Note: This does NOT affect requestState - tool_use with isStreaming=false + // should not stop the overall request, only indicate this message is complete + if (msg.role === 'assistant' || msg.role === 'tool') { + isStreaming.value = msg.isStreaming === true && !msg.isFinal; + + // If we're receiving model/tool output but requestState hasn't progressed to 'running', + // update it. This handles: + // 1. Edge case where status events were missed due to SSE timing + // 2. User enters AgentChat mid-request (e.g., from Quick Panel/toolbar trigger) + // 3. SSE reconnection after temporary disconnect + if ( + requestState.value === 'idle' || + requestState.value === 'starting' || + requestState.value === 'ready' + ) { + requestState.value = 'running'; + } + } + } + + // Handle status events + function handleStatusEvent(status: AgentStatusEvent): void { + const statusRequestId = status.requestId?.trim() || undefined; + + // Filter out status events from different active requests + if (isDifferentActiveRequest(statusRequestId)) { + return; + } + + // Track requestId from status events + if (statusRequestId && statusRequestId !== currentRequestId.value) { + currentRequestId.value = statusRequestId; + } + + // Update request lifecycle state (driven by status events only) + requestState.value = status.status; + + switch (status.status) { + case 'starting': + case 'ready': + case 'running': + // Request is active - no additional state changes needed + break; + case 'completed': + case 'error': + case 'cancelled': + // Request finished - clear message streaming and requestId + isStreaming.value = false; + // Reset cancelling state (in case we were waiting for SSE confirmation) + cancelling.value = false; + if (!statusRequestId || statusRequestId === currentRequestId.value) { + currentRequestId.value = null; + } + break; + } + } + + // Send message + async function send( + chatOptions: { + cliPreference?: string; + model?: string; + projectId?: string; + projectRoot?: string; + dbSessionId?: string; + /** + * Optional instruction to send instead of input.value. + * When provided, this is used as the actual instruction sent to the server, + * while input.value is still used for UI display in the optimistic message. + * This is useful for injecting context (e.g., web editor selection) into the prompt + * without showing it in the chat UI. + */ + instruction?: string; + /** + * Optional compact display text stored in the user message metadata. + * When provided, the UI can render a special header (e.g., a chip) instead + * of the raw prompt content. + */ + displayText?: string; + /** + * Optional client metadata to persist with the user message. + * Used for special UI rendering (e.g., web editor apply/selection chips). + */ + clientMeta?: AgentActRequestClientMeta; + } = {}, + ): Promise { + // User-visible content is always the user's raw input + const userText = input.value.trim(); + // Actual instruction sent to server can be overridden (e.g., with context prepended) + const instructionText = chatOptions.instruction?.trim() || userText; + + if (!userText) return; + + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + const sessionId = options.getSessionId(); + + if (!ready || !serverPort) { + errorMessage.value = 'Agent server is not available.'; + return; + } + + // Ensure SSE is connected before sending + options.openEventSource(); + + // Generate requestId on client side for optimistic message matching + // Server will use this requestId when echoing user message via SSE + const requestId = crypto.randomUUID(); + + // Create optimistic user message for immediate feedback + // Note: Use userText for UI, not instructionText (which may contain injected context) + const tempMessageId = `temp-${Date.now()}`; + const optimisticMessage: AgentMessage = { + id: tempMessageId, + sessionId: sessionId, + role: 'user', + content: userText, + messageType: 'chat', + requestId, // Include requestId so we can match with server-echoed message + createdAt: new Date().toISOString(), + // Include metadata for immediate chip rendering (before server echo) + metadata: + chatOptions.displayText || chatOptions.clientMeta + ? { + displayText: chatOptions.displayText?.trim(), + clientMeta: chatOptions.clientMeta, + } + : undefined, + }; + + // Add user message immediately + messages.value.push(optimisticMessage); + + const payload: AgentActRequest = { + // Use instructionText which may include injected context (e.g., web editor selection) + instruction: instructionText, + requestId, // Send requestId to server so it can be used in SSE events + // Optional metadata for special UI rendering (stored with the user message) + displayText: chatOptions.displayText?.trim() || undefined, + clientMeta: chatOptions.clientMeta, + cliPreference: chatOptions.cliPreference + ? (chatOptions.cliPreference as AgentCliPreference) + : undefined, + model: chatOptions.model?.trim() || undefined, + projectId: chatOptions.projectId || undefined, + projectRoot: chatOptions.projectRoot?.trim() || undefined, + dbSessionId: chatOptions.dbSessionId || undefined, + attachments: attachments.value.length > 0 ? attachments.value : undefined, + }; + + sending.value = true; + // Initialize request lifecycle state - request begins once we dispatch /act + requestState.value = 'starting'; + currentRequestId.value = requestId; + // Reset message-level streaming; it will be driven by message.isStreaming deltas + isStreaming.value = false; + errorMessage.value = null; + + // Clear input immediately for better UX + const savedInput = input.value; + input.value = ''; + const savedAttachments = [...attachments.value]; + attachments.value = []; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(sessionId)}/act`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const result = await response.json().catch(() => ({})); + + // Guard: only update state if we're still on the same session + // This prevents cross-session state pollution when user switches during request + const currentSessionId = options.getSessionId(); + if (currentSessionId !== sessionId) { + // Session changed during request - discard result silently + // The optimistic message will be cleared when messages are reloaded + isStreaming.value = false; + requestState.value = 'idle'; + currentRequestId.value = null; + return; + } + + // Update currentRequestId from response (should match our client-generated one) + // This is used for cancel functionality + if (result.requestId) { + currentRequestId.value = result.requestId; + } else { + // Fallback: use our client-generated requestId + currentRequestId.value = requestId; + } + } catch (error: unknown) { + // Guard: only handle error if still on same session + const currentSessionId = options.getSessionId(); + if (currentSessionId !== sessionId) { + isStreaming.value = false; + requestState.value = 'idle'; + currentRequestId.value = null; + return; + } + + console.error('Failed to send agent act request:', error); + errorMessage.value = + error instanceof Error ? error.message : 'Failed to send request to agent server.'; + // Restore input on error + input.value = savedInput; + attachments.value = savedAttachments; + // Remove optimistic message on error + const msgIndex = messages.value.findIndex((m) => m.id === tempMessageId); + if (msgIndex >= 0) { + messages.value.splice(msgIndex, 1); + } + isStreaming.value = false; + requestState.value = 'idle'; + currentRequestId.value = null; + } finally { + sending.value = false; + } + } + + // Cancel current request + async function cancelCurrentRequest(): Promise { + if (!currentRequestId.value) return; + + const serverPort = options.getServerPort(); + const sessionId = options.getSessionId(); + + if (!serverPort) return; + + cancelling.value = true; + try { + const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(currentRequestId.value)}`; + + const response = await fetch(url, { method: 'DELETE' }); + const data = await response.json().catch(() => null); + + // Check if cancel was successful + // Backend returns { success: boolean, message?: string } + const isSuccess = response.ok && data?.success !== false; + + if (!isSuccess) { + // Cancel failed - show error but keep request state intact + // so user can try again or wait for natural completion + const errorMsg = data?.message || `Failed to cancel request (HTTP ${response.status})`; + console.error('Cancel request failed:', errorMsg); + errorMessage.value = errorMsg; + return; + } + + // Cancel request sent successfully + // Note: We intentionally do NOT clear currentRequestId/requestState here + // The actual state cleanup will happen when we receive the 'cancelled' status event via SSE + // This ensures UI stays consistent with backend state and avoids race conditions + // Keep cancelling=true so UI shows "Stopping..." until SSE confirms + // cancelling will be reset when handleStatusEvent receives 'cancelled' status + } catch (error) { + console.error('Failed to cancel request:', error); + errorMessage.value = error instanceof Error ? error.message : 'Failed to cancel request'; + // Only reset cancelling on error, not on success + cancelling.value = false; + } + } + + // Clear messages + function clearMessages(): void { + messages.value = []; + } + + // Set messages (for loading history) + function setMessages(newMessages: AgentMessage[]): void { + messages.value = newMessages; + } + + return { + // State + messages, + input, + sending, + isStreaming, + requestState, + errorMessage, + currentRequestId, + cancelling, + attachments, + lastUsage, + + // Computed + canSend, + isRequestActive, + + // Methods + handleRealtimeEvent, + send, + cancelCurrentRequest, + clearMessages, + setMessages, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChatViewRoute.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChatViewRoute.ts new file mode 100644 index 0000000..ba22c67 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChatViewRoute.ts @@ -0,0 +1,234 @@ +/** + * Composable for managing AgentChat view routing. + * + * Handles navigation between 'sessions' (list) and 'chat' (conversation) views + * without requiring vue-router. Supports URL parameters for deep linking. + * + * URL Parameters: + * - `view`: 'sessions' | 'chat' (default: 'sessions') + * - `sessionId`: Session ID to open directly in chat view + * + * Example URLs: + * - `sidepanel.html?tab=agent-chat` → sessions list + * - `sidepanel.html?tab=agent-chat&view=chat&sessionId=xxx` → direct to chat + */ +import { ref, computed } from 'vue'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Available view modes */ +export type AgentChatView = 'sessions' | 'chat'; + +/** Route state */ +export interface AgentChatRouteState { + view: AgentChatView; + sessionId: string | null; +} + +/** Options for useAgentChatViewRoute */ +export interface UseAgentChatViewRouteOptions { + /** + * Callback when route changes. + * Called after internal state is updated. + */ + onRouteChange?: (state: AgentChatRouteState) => void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const DEFAULT_VIEW: AgentChatView = 'sessions'; +const URL_PARAM_VIEW = 'view'; +const URL_PARAM_SESSION_ID = 'sessionId'; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Parse view from URL parameter. + * Returns default if invalid. + */ +function parseView(value: string | null): AgentChatView { + if (value === 'sessions' || value === 'chat') { + return value; + } + return DEFAULT_VIEW; +} + +/** + * Update URL parameters without page reload. + * Preserves existing parameters (like `tab`). + */ +function updateUrlParams(view: AgentChatView, sessionId: string | null): void { + try { + const url = new URL(window.location.href); + + // Update view param + if (view === DEFAULT_VIEW) { + url.searchParams.delete(URL_PARAM_VIEW); + } else { + url.searchParams.set(URL_PARAM_VIEW, view); + } + + // Update sessionId param + if (sessionId) { + url.searchParams.set(URL_PARAM_SESSION_ID, sessionId); + } else { + url.searchParams.delete(URL_PARAM_SESSION_ID); + } + + // Update URL without reload + window.history.replaceState({}, '', url.toString()); + } catch { + // Ignore URL update errors (e.g., in non-browser environment) + } +} + +// ============================================================================= +// Composable +// ============================================================================= + +export function useAgentChatViewRoute(options: UseAgentChatViewRouteOptions = {}) { + // ========================================================================== + // State + // ========================================================================== + + const currentView = ref(DEFAULT_VIEW); + const currentSessionId = ref(null); + + // ========================================================================== + // Computed + // ========================================================================== + + /** Whether currently showing sessions list */ + const isSessionsView = computed(() => currentView.value === 'sessions'); + + /** Whether currently showing chat conversation */ + const isChatView = computed(() => currentView.value === 'chat'); + + /** Current route state */ + const routeState = computed(() => ({ + view: currentView.value, + sessionId: currentSessionId.value, + })); + + // ========================================================================== + // Actions + // ========================================================================== + + /** + * Navigate to sessions list view. + * Clears sessionId from URL. + */ + function goToSessions(): void { + currentView.value = 'sessions'; + // Don't clear sessionId internally - it's used to highlight selected session + updateUrlParams('sessions', null); + options.onRouteChange?.(routeState.value); + } + + /** + * Navigate to chat view for a specific session. + * @param sessionId - Session ID to open + */ + function goToChat(sessionId: string): void { + if (!sessionId) { + console.warn('[useAgentChatViewRoute] goToChat called without sessionId'); + return; + } + + currentView.value = 'chat'; + currentSessionId.value = sessionId; + updateUrlParams('chat', sessionId); + options.onRouteChange?.(routeState.value); + } + + /** + * Initialize route from URL parameters. + * Should be called on mount. + * @returns Initial route state + */ + function initFromUrl(): AgentChatRouteState { + try { + const params = new URLSearchParams(window.location.search); + const viewParam = params.get(URL_PARAM_VIEW); + const sessionIdParam = params.get(URL_PARAM_SESSION_ID); + + const view = parseView(viewParam); + const sessionId = sessionIdParam?.trim() || null; + + // If view=chat but no sessionId, fall back to sessions + if (view === 'chat' && !sessionId) { + currentView.value = 'sessions'; + currentSessionId.value = null; + } else { + currentView.value = view; + currentSessionId.value = sessionId; + } + } catch { + // Use defaults on error + currentView.value = DEFAULT_VIEW; + currentSessionId.value = null; + } + + return routeState.value; + } + + /** + * Update session ID without changing view. + * Updates URL based on current view and sessionId: + * - In chat view: always update URL with sessionId + * - In sessions view with null sessionId: clear sessionId from URL (cleanup) + */ + function setSessionId(sessionId: string | null): void { + currentSessionId.value = sessionId; + + if (currentView.value === 'chat') { + // In chat view, always sync URL with current sessionId + updateUrlParams('chat', sessionId); + } else if (sessionId === null) { + // In sessions view, clear any stale sessionId from URL + // This handles edge cases like deleting the last session + updateUrlParams('sessions', null); + } + } + + // ========================================================================== + // Lifecycle + // ========================================================================== + + // Note: We don't call initFromUrl() here because AgentChat.vue needs to + // call it after loading sessions (to verify sessionId exists). + // Caller is responsible for calling initFromUrl() at the right time. + + // ========================================================================== + // Return + // ========================================================================== + + return { + // State + currentView, + currentSessionId, + + // Computed + isSessionsView, + isChatView, + routeState, + + // Actions + goToSessions, + goToChat, + initFromUrl, + setSessionId, + }; +} + +// ============================================================================= +// Type Export +// ============================================================================= + +export type UseAgentChatViewRoute = ReturnType; diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentInputPreferences.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentInputPreferences.ts new file mode 100644 index 0000000..2a8c42a --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentInputPreferences.ts @@ -0,0 +1,88 @@ +/** + * Composable for user-facing input preferences in AgentChat. + * Preferences are persisted in chrome.storage.local. + */ +import { ref, type Ref } from 'vue'; + +// ============================================================================= +// Constants +// ============================================================================= + +const STORAGE_KEY_FAKE_CARET = 'agent-chat-fake-caret-enabled'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface UseAgentInputPreferences { + /** Whether the fake caret + comet trail is enabled (opt-in). Default: false */ + fakeCaretEnabled: Ref; + /** Whether preferences have been loaded from storage */ + ready: Ref; + /** Load preferences from chrome.storage.local (call on mount) */ + init: () => Promise; + /** Persist and update fake caret preference */ + setFakeCaretEnabled: (enabled: boolean) => Promise; +} + +// ============================================================================= +// Composable +// ============================================================================= + +/** + * Composable for managing user input preferences. + * + * Features: + * - Fake caret toggle (opt-in, default off) + * - Persistence via chrome.storage.local + * - Graceful fallback when storage is unavailable + */ +export function useAgentInputPreferences(): UseAgentInputPreferences { + const fakeCaretEnabled = ref(false); + const ready = ref(false); + + /** + * Load preferences from chrome.storage.local. + * Should be called during component mount. + */ + async function init(): Promise { + try { + if (typeof chrome === 'undefined' || !chrome.storage?.local) { + ready.value = true; + return; + } + + const result = await chrome.storage.local.get(STORAGE_KEY_FAKE_CARET); + const stored = result[STORAGE_KEY_FAKE_CARET]; + + if (typeof stored === 'boolean') { + fakeCaretEnabled.value = stored; + } + } catch (error) { + console.error('[useAgentInputPreferences] Failed to load preferences:', error); + } finally { + ready.value = true; + } + } + + /** + * Update and persist the fake caret preference. + */ + async function setFakeCaretEnabled(enabled: boolean): Promise { + fakeCaretEnabled.value = enabled; + + try { + if (typeof chrome === 'undefined' || !chrome.storage?.local) return; + await chrome.storage.local.set({ [STORAGE_KEY_FAKE_CARET]: enabled }); + } catch (error) { + console.error('[useAgentInputPreferences] Failed to save fake caret preference:', error); + } + } + + return { + fakeCaretEnabled, + ready, + init, + setFakeCaretEnabled, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentProjects.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentProjects.ts new file mode 100644 index 0000000..bd11989 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentProjects.ts @@ -0,0 +1,573 @@ +/** + * Composable for managing Agent Projects. + * Handles project CRUD, selection, and persistence. + */ +import { ref, computed, watch } from 'vue'; +import type { AgentProject, AgentStoredMessage } from 'chrome-mcp-shared'; + +const STORAGE_KEY_SELECTED_PROJECT = 'agent-selected-project-id'; + +interface PathValidationResult { + valid: boolean; + absolute: string; + exists: boolean; + needsCreation: boolean; + error?: string; +} + +/** + * Normalize path for comparison (handle trailing slashes and separators). + */ +function normalizePathForComparison(path: string): string { + // Remove trailing slashes and normalize separators + return path + .trim() + .replace(/[/\\]+$/, '') + .replace(/\\/g, '/') + .toLowerCase(); +} + +export interface UseAgentProjectsOptions { + getServerPort: () => number | null; + ensureServer: () => Promise; + onHistoryLoaded?: (messages: AgentStoredMessage[]) => void; +} + +export function useAgentProjects(options: UseAgentProjectsOptions) { + // State + const projects = ref([]); + const selectedProjectId = ref(''); + const isLoadingProjects = ref(false); + const showCreateProject = ref(false); + const newProjectName = ref(''); + const newProjectRootPath = ref(''); + const isCreatingProject = ref(false); + const projectError = ref(null); + + // Computed + const selectedProject = computed(() => { + return projects.value.find((p) => p.id === selectedProjectId.value) || null; + }); + + const canCreateProject = computed(() => { + return newProjectName.value.trim().length > 0 && newProjectRootPath.value.trim().length > 0; + }); + + // Load selected project from storage + async function loadSelectedProjectId(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_PROJECT); + if (result[STORAGE_KEY_SELECTED_PROJECT]) { + selectedProjectId.value = result[STORAGE_KEY_SELECTED_PROJECT]; + } + } catch (error) { + console.error('Failed to load selected project ID:', error); + } + } + + // Save selected project to storage + async function saveSelectedProjectId(): Promise { + try { + await chrome.storage.local.set({ + [STORAGE_KEY_SELECTED_PROJECT]: selectedProjectId.value, + }); + } catch (error) { + console.error('Failed to save selected project ID:', error); + } + } + + // Fetch projects from server + async function fetchProjects(): Promise { + const serverPort = options.getServerPort(); + if (!serverPort) return; + + isLoadingProjects.value = true; + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects`; + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + projects.value = data.projects || []; + } + } catch (error) { + console.error('Failed to fetch projects:', error); + } finally { + isLoadingProjects.value = false; + } + } + + // Refresh projects + async function refreshProjects(): Promise { + const ready = await options.ensureServer(); + if (!ready) return; + await fetchProjects(); + } + + // Track pending history load with nonce to prevent A→B→A race conditions + let historyLoadNonce = 0; + + /** + * Load chat history for a project with race-condition protection. + * Uses a nonce to handle A→B→A scenarios. + */ + async function loadChatHistory(projectId: string): Promise { + const serverPort = options.getServerPort(); + if (!serverPort || !projectId) return; + + // Increment nonce - any subsequent load will invalidate this one + const myNonce = ++historyLoadNonce; + + const isStillValid = (): boolean => { + return myNonce === historyLoadNonce && selectedProjectId.value === projectId; + }; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(projectId)}/messages?limit=100`; + const response = await fetch(url); + + if (!isStillValid()) return; + + if (response.ok) { + const result = await response.json(); + + if (!isStillValid()) return; + + // Server returns { success, data: messages[], totalCount, pagination } + const stored = result.data || []; + options.onHistoryLoaded?.(stored); + } + } catch (error) { + console.error('Failed to load chat history:', error); + } + } + + // Validate path before creating project + async function validatePath(rootPath: string): Promise { + const serverPort = options.getServerPort(); + if (!serverPort) return null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects/validate-path`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rootPath }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `Validation failed: HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Failed to validate path:', error); + return null; + } + } + + // Create project + async function createProject(): Promise { + const name = newProjectName.value.trim(); + const rootPath = newProjectRootPath.value.trim(); + if (!name || !rootPath) return null; + + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + if (!ready || !serverPort) { + projectError.value = 'Agent server is not available.'; + return null; + } + + isCreatingProject.value = true; + projectError.value = null; + + try { + // Step 1: Validate the path + const validation = await validatePath(rootPath); + if (!validation) { + projectError.value = 'Failed to validate path'; + return null; + } + + if (!validation.valid) { + projectError.value = validation.error || 'Invalid path'; + return null; + } + + // Step 2: If directory doesn't exist, ask user for confirmation + let allowCreate = false; + if (validation.needsCreation) { + const confirmed = confirm( + `目录 "${validation.absolute}" 不存在,是否创建?\n\nThe directory "${validation.absolute}" does not exist. Create it?`, + ); + if (!confirmed) { + return null; + } + allowCreate = true; + } + + // Step 3: Create the project + const url = `http://127.0.0.1:${serverPort}/agent/projects`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, rootPath, allowCreate }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const payload = await response.json(); + const project = payload?.project as AgentProject | undefined; + + if (project?.id) { + // Update local state + const others = projects.value.filter((p) => p.id !== project.id); + projects.value = [...others, project]; + selectedProjectId.value = project.id; + await saveSelectedProjectId(); + await loadChatHistory(project.id); + + // Clear form + newProjectName.value = ''; + newProjectRootPath.value = ''; + showCreateProject.value = false; + + return project; + } else { + projectError.value = 'Project created but response is invalid.'; + return null; + } + } catch (error: unknown) { + console.error('Failed to create project:', error); + projectError.value = error instanceof Error ? error.message : 'Failed to create project.'; + return null; + } finally { + isCreatingProject.value = false; + } + } + + // Toggle create project form + function toggleCreateProject(): void { + showCreateProject.value = !showCreateProject.value; + if (!showCreateProject.value) { + newProjectName.value = ''; + newProjectRootPath.value = ''; + projectError.value = null; + } + } + + // Get default project root path for a project name + async function getDefaultProjectRoot(projectName: string): Promise { + const serverPort = options.getServerPort(); + if (!serverPort || !projectName.trim()) return null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects/default-root`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectName: projectName.trim() }), + }); + if (response.ok) { + const data = await response.json(); + return data.path || null; + } + return null; + } catch (error) { + console.error('Failed to get default project root:', error); + return null; + } + } + + // Open directory picker dialog + async function pickDirectory(): Promise { + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + if (!ready || !serverPort) { + projectError.value = 'Server not available'; + return null; + } + + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects/pick-directory`; + const response = await fetch(url, { method: 'POST' }); + + // Handle HTTP errors (e.g., 404 means server version mismatch) + if (!response.ok) { + if (response.status === 404) { + projectError.value = + 'Directory picker not available. Please rebuild and restart the native server.'; + } else { + projectError.value = `Server error: HTTP ${response.status}`; + } + return null; + } + + const data = await response.json(); + + if (data.success && data.path) { + return data.path; + } else if (data.cancelled) { + return null; // User cancelled, not an error + } else { + projectError.value = data.error || 'Failed to open directory picker'; + return null; + } + } catch (error) { + console.error('Failed to open directory picker:', error); + projectError.value = 'Failed to open directory picker'; + return null; + } + } + + // Ensure default project exists (auto-create if no projects) + async function ensureDefaultProject(): Promise { + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + if (!ready || !serverPort) return null; + + try { + // First fetch current projects + await fetchProjects(); + + // If there are already projects, no need to create default + if (projects.value.length > 0) { + return null; + } + + // Get default workspace directory from server + const defaultRootUrl = `http://127.0.0.1:${serverPort}/agent/projects/default-root`; + const defaultRootResponse = await fetch(defaultRootUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectName: 'default' }), + }); + const defaultRootData = await defaultRootResponse.json(); + const defaultRoot = defaultRootData.path; + + if (!defaultRoot) { + console.error('Failed to get default project root'); + return null; + } + + // Create default project + const createUrl = `http://127.0.0.1:${serverPort}/agent/projects`; + const createResponse = await fetch(createUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Default', + rootPath: defaultRoot, + allowCreate: true, + }), + }); + + if (!createResponse.ok) { + const text = await createResponse.text().catch(() => ''); + console.error('Failed to create default project:', text); + return null; + } + + const payload = await createResponse.json(); + const project = payload?.project as AgentProject | undefined; + + if (project?.id) { + projects.value = [project]; + selectedProjectId.value = project.id; + await saveSelectedProjectId(); + return project; + } + + return null; + } catch (error) { + console.error('Failed to ensure default project:', error); + return null; + } + } + + // Create project from a directory path (used when user picks a directory) + async function createProjectFromPath( + rootPath: string, + name: string, + ): Promise { + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + if (!ready || !serverPort) { + projectError.value = 'Agent server is not available.'; + return null; + } + + projectError.value = null; + + try { + // Validate the path first + const validation = await validatePath(rootPath); + if (!validation) { + projectError.value = 'Failed to validate path'; + return null; + } + + if (!validation.valid) { + projectError.value = validation.error || 'Invalid path'; + return null; + } + + // Check if project with same path already exists + const normalizedPath = normalizePathForComparison(validation.absolute); + const existingProject = projects.value.find( + (p) => normalizePathForComparison(p.rootPath) === normalizedPath, + ); + + if (existingProject) { + // Project already exists - select it instead of creating a new one + const shouldSwitch = confirm( + `目录 "${validation.absolute}" 已存在对应的项目:${existingProject.name}\n\n` + + `是否切换到该项目?\n\n` + + `A project already exists for "${validation.absolute}": ${existingProject.name}\n` + + `Switch to that project?`, + ); + if (shouldSwitch) { + selectedProjectId.value = existingProject.id; + await saveSelectedProjectId(); + await loadChatHistory(existingProject.id); + return existingProject; + } + // User declined to switch, return null to indicate no action taken + return null; + } + + // If directory doesn't exist, ask user for confirmation + let allowCreate = false; + if (validation.needsCreation) { + const confirmed = confirm( + `目录 "${validation.absolute}" 不存在,是否创建?\n\nThe directory "${validation.absolute}" does not exist. Create it?`, + ); + if (!confirmed) { + return null; + } + allowCreate = true; + } + + // Create the project + const url = `http://127.0.0.1:${serverPort}/agent/projects`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, rootPath, allowCreate }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const payload = await response.json(); + const project = payload?.project as AgentProject | undefined; + + if (project?.id) { + // Update local state + const others = projects.value.filter((p) => p.id !== project.id); + projects.value = [...others, project]; + selectedProjectId.value = project.id; + await saveSelectedProjectId(); + await loadChatHistory(project.id); + + return project; + } else { + projectError.value = 'Project created but response is invalid.'; + return null; + } + } catch (error: unknown) { + console.error('Failed to create project from path:', error); + projectError.value = error instanceof Error ? error.message : 'Failed to create project.'; + return null; + } + } + + // Handle project change + async function handleProjectChanged(): Promise { + await saveSelectedProjectId(); + if (selectedProjectId.value) { + await loadChatHistory(selectedProjectId.value); + } + } + + // Save project preference (CLI, model, useCcr, enableChromeMcp) + async function saveProjectPreference( + cli?: string, + model?: string, + useCcr?: boolean, + enableChromeMcp?: boolean, + ): Promise { + const project = selectedProject.value; + const serverPort = options.getServerPort(); + if (!project || !serverPort) return; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: project.id, + name: project.name, + rootPath: project.rootPath, + // Normalize and allow empty string (means "Auto/Default") + preferredCli: cli?.trim() ?? project.preferredCli, + selectedModel: model?.trim() ?? project.selectedModel, + useCcr: useCcr ?? project.useCcr, + enableChromeMcp: enableChromeMcp ?? project.enableChromeMcp, + }), + }); + + // Update local project state if successful + if (response.ok) { + const payload = await response.json(); + const updatedProject = payload?.project as AgentProject | undefined; + if (updatedProject?.id) { + const index = projects.value.findIndex((p) => p.id === updatedProject.id); + if (index !== -1) { + projects.value[index] = updatedProject; + } + } + } + } catch (error) { + console.error('Failed to save project preference:', error); + } + } + + return { + // State + projects, + selectedProjectId, + isLoadingProjects, + showCreateProject, + newProjectName, + newProjectRootPath, + isCreatingProject, + projectError, + + // Computed + selectedProject, + canCreateProject, + + // Methods + loadSelectedProjectId, + saveSelectedProjectId, + fetchProjects, + refreshProjects, + loadChatHistory, + createProject, + toggleCreateProject, + handleProjectChanged, + saveProjectPreference, + getDefaultProjectRoot, + pickDirectory, + ensureDefaultProject, + createProjectFromPath, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentServer.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentServer.ts new file mode 100644 index 0000000..0c2cfca --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentServer.ts @@ -0,0 +1,276 @@ +/** + * Composable for managing Agent Server connection state. + * Handles native host connection, server status, and SSE stream. + */ +import { ref, computed, onUnmounted } from 'vue'; +import { NativeMessageType } from 'chrome-mcp-shared'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import type { AgentEngineInfo, RealtimeEvent } from 'chrome-mcp-shared'; + +interface ServerStatus { + isRunning: boolean; + port?: number; + lastUpdated: number; +} + +export interface UseAgentServerOptions { + /** + * Get the session ID for SSE routing. + * Must be provided by caller (typically DB session ID). + */ + getSessionId?: () => string; + onMessage?: (event: RealtimeEvent) => void; + onError?: (error: string) => void; +} + +export function useAgentServer(options: UseAgentServerOptions = {}) { + // State + const serverPort = ref(null); + const nativeConnected = ref(false); + const serverStatus = ref(null); + const connecting = ref(false); + const engines = ref([]); + const eventSource = ref(null); + + // Reconnection state + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 5; + const BASE_RECONNECT_DELAY = 1000; + + // Track which sessionId the current SSE connection is subscribed to + let currentStreamSessionId: string | null = null; + + // Computed + const isServerReady = computed(() => { + return nativeConnected.value && serverStatus.value?.isRunning && serverPort.value !== null; + }); + + // Check native host connection using existing message type + async function checkNativeHost(): Promise { + try { + const response = await chrome.runtime.sendMessage({ + type: NativeMessageType.PING_NATIVE, + }); + nativeConnected.value = response?.connected ?? false; + return nativeConnected.value; + } catch (error) { + console.error('Failed to check native host:', error); + nativeConnected.value = false; + return false; + } + } + + /** + * Start native host connection. + * @param forceConnect - If true, use CONNECT_NATIVE (re-enables auto-connect). + * If false, use ENSURE_NATIVE (respects current auto-connect setting). + */ + async function startNativeHost(forceConnect = false): Promise { + try { + const response = await chrome.runtime.sendMessage({ + type: forceConnect ? NativeMessageType.CONNECT_NATIVE : NativeMessageType.ENSURE_NATIVE, + }); + // Handle both response formats: { connected: boolean } and { success: boolean } + nativeConnected.value = + typeof response?.connected === 'boolean' + ? response.connected + : (response?.success ?? false); + return nativeConnected.value; + } catch (error) { + console.error('Failed to start native host:', error); + nativeConnected.value = false; + return false; + } + } + + // Get server status using existing message type + async function getServerStatus(): Promise { + try { + const response = await chrome.runtime.sendMessage({ + type: BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS, + }); + if (response?.serverStatus) { + serverStatus.value = response.serverStatus; + if (response.serverStatus.port) { + serverPort.value = response.serverStatus.port; + } + // Also update native connected status from response + if (typeof response.connected === 'boolean') { + nativeConnected.value = response.connected; + } + return response.serverStatus; + } + return null; + } catch (error) { + console.error('Failed to get server status:', error); + return null; + } + } + + interface EnsureNativeServerOptions { + /** If true, use CONNECT_NATIVE to re-enable auto-connect */ + forceConnect?: boolean; + } + + // Ensure native server is ready + async function ensureNativeServer(opts: EnsureNativeServerOptions = {}): Promise { + const { forceConnect = false } = opts; + connecting.value = true; + try { + // Step 1: Check native host connection + let connected = await checkNativeHost(); + if (!connected) { + // Try to start native host + connected = await startNativeHost(forceConnect); + if (!connected) { + console.error('Failed to connect to native host'); + return false; + } + // Wait for connection to stabilize + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + // Step 2: Get server status + const status = await getServerStatus(); + if (!status?.isRunning || !status.port) { + console.error('Server not running or port not available', status); + return false; + } + + // Step 3: Fetch engines + await fetchEngines(); + + return true; + } finally { + connecting.value = false; + } + } + + // Fetch available engines + async function fetchEngines(): Promise { + if (!serverPort.value) return; + try { + const url = `http://127.0.0.1:${serverPort.value}/agent/engines`; + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + engines.value = data.engines || []; + } + } catch (error) { + console.error('Failed to fetch engines:', error); + } + } + + // Check if SSE is connected + function isEventSourceConnected(): boolean { + return eventSource.value !== null && eventSource.value.readyState === EventSource.OPEN; + } + + // Open SSE connection (skip if already connected to same session) + function openEventSource(): void { + const targetSessionId = options.getSessionId?.()?.trim() ?? ''; + if (!serverPort.value || !targetSessionId) return; + + // Skip if already connected to the same session + if (isEventSourceConnected() && currentStreamSessionId === targetSessionId) { + console.log('[AgentServer] SSE already connected to session, skipping reconnect'); + return; + } + + // Close existing connection before subscribing to a new session + closeEventSource(); + + currentStreamSessionId = targetSessionId; + const url = `http://127.0.0.1:${serverPort.value}/agent/chat/${encodeURIComponent(targetSessionId)}/stream`; + const es = new EventSource(url); + + es.onopen = () => { + console.log('[AgentServer] SSE connection opened'); + reconnectAttempts = 0; + }; + + es.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data) as RealtimeEvent; + options.onMessage?.(parsed); + } catch (err) { + console.error('[AgentServer] Failed to parse SSE message:', err); + } + }; + + es.onerror = (error) => { + console.error('[AgentServer] SSE error:', error); + es.close(); + eventSource.value = null; + + // Attempt reconnection with exponential backoff + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const delay = BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts); + reconnectAttempts++; + console.log(`[AgentServer] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); + setTimeout(() => { + if (isServerReady.value) { + openEventSource(); + } + }, delay); + } else { + options.onError?.('SSE connection failed after multiple attempts'); + } + }; + + eventSource.value = es; + } + + // Close SSE connection + function closeEventSource(): void { + if (eventSource.value) { + eventSource.value.close(); + eventSource.value = null; + } + currentStreamSessionId = null; + } + + // Reconnect to server (explicit user action, re-enables auto-connect) + async function reconnect(): Promise { + closeEventSource(); + reconnectAttempts = 0; + // Explicit user reconnect: force connect to re-enable auto-connect in background + await ensureNativeServer({ forceConnect: true }); + if (isServerReady.value) { + openEventSource(); + } + } + + // Initialize + async function initialize(): Promise { + await ensureNativeServer(); + // Note: SSE connection is now opened explicitly when session is ready + } + + // Cleanup on unmount + onUnmounted(() => { + closeEventSource(); + }); + + return { + // State + serverPort, + nativeConnected, + serverStatus, + connecting, + engines, + eventSource, + + // Computed + isServerReady, + + // Methods + ensureNativeServer, + fetchEngines, + openEventSource, + closeEventSource, + isEventSourceConnected, + reconnect, + initialize, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentSessions.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentSessions.ts new file mode 100644 index 0000000..4a3274f --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentSessions.ts @@ -0,0 +1,537 @@ +/** + * Composable for managing Agent Sessions. + * Sessions represent independent conversations within a project. + * Each session has its own engine configuration, chat history, and resume state. + */ +import { ref, computed, watch } from 'vue'; +import type { + AgentSession, + AgentCliPreference, + CreateAgentSessionInput, + UpdateAgentSessionInput, + AgentStoredMessage, + AgentManagementInfo, +} from 'chrome-mcp-shared'; + +const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id'; + +export interface UseAgentSessionsOptions { + getServerPort: () => number | null; + ensureServer: () => Promise; + onSessionChanged?: (sessionId: string) => void; + onHistoryLoaded?: (messages: AgentStoredMessage[]) => void; +} + +export function useAgentSessions(options: UseAgentSessionsOptions) { + // State + const sessions = ref([]); + const allSessions = ref([]); // All sessions across all projects + const selectedSessionId = ref(''); + const isLoadingSessions = ref(false); + const isLoadingAllSessions = ref(false); + const isCreatingSession = ref(false); + const sessionError = ref(null); + + // Computed + const selectedSession = computed(() => { + return sessions.value.find((s) => s.id === selectedSessionId.value) || null; + }); + + const hasSessions = computed(() => sessions.value.length > 0); + + // Load selected session from storage + async function loadSelectedSessionId(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_SESSION); + if (result[STORAGE_KEY_SELECTED_SESSION]) { + selectedSessionId.value = result[STORAGE_KEY_SELECTED_SESSION]; + } + } catch (error) { + console.error('Failed to load selected session ID:', error); + } + } + + // Save selected session to storage + async function saveSelectedSessionId(): Promise { + try { + await chrome.storage.local.set({ + [STORAGE_KEY_SELECTED_SESSION]: selectedSessionId.value, + }); + } catch (error) { + console.error('Failed to save selected session ID:', error); + } + } + + // Track pending session fetch with nonce to prevent A→B→A race conditions + let fetchSessionsNonce = 0; + + /** + * Fetch sessions for a project with race-condition protection. + * Uses a nonce to handle A→B→A scenarios. + */ + async function fetchSessions(projectId: string): Promise { + const serverPort = options.getServerPort(); + if (!serverPort || !projectId) return; + + // Increment nonce - any subsequent fetch will invalidate this one + const myNonce = ++fetchSessionsNonce; + + const isStillValid = (): boolean => { + return myNonce === fetchSessionsNonce; + }; + + isLoadingSessions.value = true; + sessionError.value = null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`; + const response = await fetch(url); + + if (!isStillValid()) return; + + if (response.ok) { + const data = await response.json(); + + if (!isStillValid()) return; + + sessions.value = data.sessions || []; + + // If we have sessions but no selection, select the most recent one + if (sessions.value.length > 0 && !selectedSessionId.value) { + selectedSessionId.value = sessions.value[0].id; + await saveSelectedSessionId(); + } + } else { + const text = await response.text().catch(() => ''); + sessionError.value = text || `HTTP ${response.status}`; + } + } catch (error) { + console.error('Failed to fetch sessions:', error); + sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions'; + } finally { + isLoadingSessions.value = false; + } + } + + // Track pending all sessions fetch with nonce + let fetchAllSessionsNonce = 0; + + /** + * Fetch all sessions across all projects. + * Used for the global sessions list view. + */ + async function fetchAllSessions(): Promise { + const serverPort = options.getServerPort(); + if (!serverPort) return; + + const myNonce = ++fetchAllSessionsNonce; + + const isStillValid = (): boolean => { + return myNonce === fetchAllSessionsNonce; + }; + + isLoadingAllSessions.value = true; + sessionError.value = null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/sessions`; + const response = await fetch(url); + + if (!isStillValid()) return; + + if (response.ok) { + const data = await response.json(); + + if (!isStillValid()) return; + + allSessions.value = data.sessions || []; + } else { + const text = await response.text().catch(() => ''); + sessionError.value = text || `HTTP ${response.status}`; + } + } catch (error) { + console.error('Failed to fetch all sessions:', error); + sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions'; + } finally { + isLoadingAllSessions.value = false; + } + } + + // Track pending create session with nonce to prevent cross-project pollution + let createSessionNonce = 0; + + /** + * Create a new session with race-condition protection. + * Uses a nonce to prevent cross-project state pollution when user switches + * projects during session creation. + */ + async function createSession( + projectId: string, + input: CreateAgentSessionInput, + ): Promise { + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + if (!ready || !serverPort) { + sessionError.value = 'Server not available'; + return null; + } + + // Increment nonce - any subsequent create will invalidate this one + const myNonce = ++createSessionNonce; + + isCreatingSession.value = true; + sessionError.value = null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + + // Guard: check if this is still the expected create operation + if (myNonce !== createSessionNonce) { + // A newer create was initiated - discard this result + return null; + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const data = await response.json(); + + // Re-check after json parsing + if (myNonce !== createSessionNonce) { + return null; + } + + const session = data.session as AgentSession | undefined; + + if (session?.id) { + // Add to local list and select it + sessions.value = [session, ...sessions.value]; + // Also add to allSessions (at front, as it's the newest) + allSessions.value = [session, ...allSessions.value.filter((s) => s.id !== session.id)]; + selectedSessionId.value = session.id; + await saveSelectedSessionId(); + options.onSessionChanged?.(session.id); + return session; + } + + sessionError.value = 'Session created but response is invalid'; + return null; + } catch (error) { + // Guard: only handle error if still valid + if (myNonce !== createSessionNonce) { + return null; + } + console.error('Failed to create session:', error); + sessionError.value = error instanceof Error ? error.message : 'Failed to create session'; + return null; + } finally { + isCreatingSession.value = false; + } + } + + // Get a session by ID + async function getSession(sessionId: string): Promise { + const serverPort = options.getServerPort(); + if (!serverPort || !sessionId) return null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`; + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + return data.session || null; + } + return null; + } catch (error) { + console.error('Failed to get session:', error); + return null; + } + } + + // Update a session + async function updateSession( + sessionId: string, + updates: UpdateAgentSessionInput, + ): Promise { + const serverPort = options.getServerPort(); + if (!serverPort || !sessionId) return null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`; + const response = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const data = await response.json(); + const session = data.session as AgentSession | undefined; + + if (session?.id) { + // Update local list + const index = sessions.value.findIndex((s) => s.id === session.id); + if (index !== -1) { + sessions.value[index] = session; + } + // Also update allSessions (in-place to preserve order) + const allIndex = allSessions.value.findIndex((s) => s.id === session.id); + if (allIndex !== -1) { + allSessions.value[allIndex] = session; + } + return session; + } + + return null; + } catch (error) { + console.error('Failed to update session:', error); + sessionError.value = error instanceof Error ? error.message : 'Failed to update session'; + return null; + } + } + + // Delete a session + async function deleteSession(sessionId: string): Promise { + const serverPort = options.getServerPort(); + if (!serverPort || !sessionId) return false; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`; + const response = await fetch(url, { method: 'DELETE' }); + + if (response.ok || response.status === 204) { + // Remove from local list + sessions.value = sessions.value.filter((s) => s.id !== sessionId); + // Also remove from allSessions + allSessions.value = allSessions.value.filter((s) => s.id !== sessionId); + + // If deleted session was selected, select another one + if (selectedSessionId.value === sessionId) { + selectedSessionId.value = sessions.value[0]?.id || ''; + await saveSelectedSessionId(); + if (selectedSessionId.value) { + options.onSessionChanged?.(selectedSessionId.value); + } + } + return true; + } + + return false; + } catch (error) { + console.error('Failed to delete session:', error); + return false; + } + } + + // Select a session + async function selectSession(sessionId: string): Promise { + if (selectedSessionId.value === sessionId) return; + + selectedSessionId.value = sessionId; + await saveSelectedSessionId(); + options.onSessionChanged?.(sessionId); + } + + // Create a default session for a project if none exist + async function ensureDefaultSession( + projectId: string, + engineName: AgentCliPreference = 'claude', + ): Promise { + await fetchSessions(projectId); + + // If sessions exist, select the first one if none selected + if (sessions.value.length > 0) { + if ( + !selectedSessionId.value || + !sessions.value.find((s) => s.id === selectedSessionId.value) + ) { + await selectSession(sessions.value[0].id); + } + return selectedSession.value; + } + + // Create default session + return createSession(projectId, { + engineName, + name: 'Default Session', + }); + } + + // Rename a session + async function renameSession(sessionId: string, name: string): Promise { + const result = await updateSession(sessionId, { name }); + return result !== null; + } + + // Reset a session conversation (delete messages + clear engineSessionId) + async function resetConversation(sessionId: string): Promise<{ + deletedMessages: number; + clearedEngineSessionId: boolean; + session: AgentSession | null; + } | null> { + const ready = await options.ensureServer(); + const serverPort = options.getServerPort(); + if (!ready || !serverPort || !sessionId) { + sessionError.value = 'Server not available'; + return null; + } + + sessionError.value = null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/reset`; + const response = await fetch(url, { method: 'POST' }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const data = await response.json(); + const session = data.session as AgentSession | null; + + // Update local session state + if (session?.id) { + const index = sessions.value.findIndex((s) => s.id === session.id); + if (index !== -1) { + sessions.value[index] = session; + } + } + + return { + deletedMessages: typeof data.deletedMessages === 'number' ? data.deletedMessages : 0, + clearedEngineSessionId: data.clearedEngineSessionId === true, + session, + }; + } catch (error) { + console.error('Failed to reset conversation:', error); + sessionError.value = error instanceof Error ? error.message : 'Failed to reset conversation'; + return null; + } + } + + // Fetch Claude SDK management info for a session + async function fetchClaudeInfo(sessionId: string): Promise<{ + managementInfo: AgentManagementInfo | null; + sessionId: string; + engineName: string; + } | null> { + const serverPort = options.getServerPort(); + if (!serverPort || !sessionId) return null; + + try { + const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/claude-info`; + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `HTTP ${response.status}`); + } + + const data = await response.json(); + return { + managementInfo: data.managementInfo ?? null, + sessionId: data.sessionId ?? sessionId, + engineName: data.engineName ?? '', + }; + } catch (error) { + console.error('Failed to fetch Claude info:', error); + return null; + } + } + + // Clear sessions when project changes + function clearSessions(): void { + sessions.value = []; + selectedSessionId.value = ''; + } + + /** + * Update session preview and updatedAt locally (without server call). + * Used when sending a message to update the display immediately. + * Always updates updatedAt so the session moves to the top of the list. + * @param sessionId - The session to update + * @param preview - The preview text (user's raw input) + * @param previewMeta - Optional structured metadata for special rendering (e.g., web editor apply chip) + */ + function updateSessionPreview( + sessionId: string, + preview: string, + previewMeta?: AgentSession['previewMeta'], + ): void { + // Truncate to 50 chars with ellipsis + const maxLen = 50; + const trimmed = preview.trim().replace(/\s+/g, ' '); + const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 1) + '…' : trimmed; + + // Always update updatedAt to move session to top of list + const now = new Date().toISOString(); + + // Update in current project sessions + const index = sessions.value.findIndex((s) => s.id === sessionId); + if (index !== -1) { + sessions.value[index] = { + ...sessions.value[index], + // Only update preview if not already set + preview: sessions.value[index].preview || truncated, + previewMeta: sessions.value[index].previewMeta || previewMeta, + // Always update timestamp so session moves to top + updatedAt: now, + }; + } + + // Also update in allSessions for global list view + const allIndex = allSessions.value.findIndex((s) => s.id === sessionId); + if (allIndex !== -1) { + allSessions.value[allIndex] = { + ...allSessions.value[allIndex], + preview: allSessions.value[allIndex].preview || truncated, + previewMeta: allSessions.value[allIndex].previewMeta || previewMeta, + updatedAt: now, + }; + } + } + + return { + // State + sessions, + allSessions, + selectedSessionId, + isLoadingSessions, + isLoadingAllSessions, + isCreatingSession, + sessionError, + + // Computed + selectedSession, + hasSessions, + + // Methods + loadSelectedSessionId, + saveSelectedSessionId, + fetchSessions, + fetchAllSessions, + createSession, + getSession, + updateSession, + deleteSession, + selectSession, + ensureDefaultSession, + renameSession, + resetConversation, + fetchClaudeInfo, + clearSessions, + updateSessionPreview, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts new file mode 100644 index 0000000..7cd1023 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts @@ -0,0 +1,171 @@ +/** + * Composable for managing AgentChat theme. + * Handles theme persistence and application. + */ +import { ref, type Ref } from 'vue'; + +/** Available theme identifiers */ +export type AgentThemeId = + | 'warm-editorial' + | 'blueprint-architect' + | 'zen-journal' + | 'neo-pop' + | 'dark-console' + | 'swiss-grid'; + +/** Storage key for persisting theme preference */ +const STORAGE_KEY_THEME = 'agentTheme'; + +/** Default theme when none is set */ +const DEFAULT_THEME: AgentThemeId = 'warm-editorial'; + +/** Valid theme IDs for validation */ +const VALID_THEMES: AgentThemeId[] = [ + 'warm-editorial', + 'blueprint-architect', + 'zen-journal', + 'neo-pop', + 'dark-console', + 'swiss-grid', +]; + +/** Theme display names for UI */ +export const THEME_LABELS: Record = { + 'warm-editorial': 'Editorial', + 'blueprint-architect': 'Blueprint', + 'zen-journal': 'Zen', + 'neo-pop': 'Neo-Pop', + 'dark-console': 'Console', + 'swiss-grid': 'Swiss', +}; + +export interface UseAgentTheme { + /** Current theme ID */ + theme: Ref; + /** Whether theme has been loaded from storage */ + ready: Ref; + /** Set and persist a new theme */ + setTheme: (id: AgentThemeId) => Promise; + /** Load theme from storage (call on mount) */ + initTheme: () => Promise; + /** Apply theme to a DOM element */ + applyTo: (el: HTMLElement) => void; + /** Get the preloaded theme from document (set by main.ts) */ + getPreloadedTheme: () => AgentThemeId; +} + +/** + * Check if a string is a valid theme ID + */ +function isValidTheme(value: unknown): value is AgentThemeId { + return typeof value === 'string' && VALID_THEMES.includes(value as AgentThemeId); +} + +/** + * Get theme from document element (preloaded by main.ts) + */ +function getThemeFromDocument(): AgentThemeId { + const value = document.documentElement.dataset.agentTheme; + return isValidTheme(value) ? value : DEFAULT_THEME; +} + +/** + * Composable for managing AgentChat theme + */ +export function useAgentTheme(): UseAgentTheme { + // Initialize with preloaded theme (or default) + const theme = ref(getThemeFromDocument()); + const ready = ref(false); + + /** + * Load theme from chrome.storage.local + */ + async function initTheme(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY_THEME); + const stored = result[STORAGE_KEY_THEME]; + + if (isValidTheme(stored)) { + theme.value = stored; + } else { + // Use preloaded or default + theme.value = getThemeFromDocument(); + } + } catch (error) { + console.error('[useAgentTheme] Failed to load theme:', error); + theme.value = getThemeFromDocument(); + } finally { + ready.value = true; + } + } + + /** + * Set and persist a new theme + */ + async function setTheme(id: AgentThemeId): Promise { + if (!isValidTheme(id)) { + console.warn('[useAgentTheme] Invalid theme ID:', id); + return; + } + + // Update immediately for responsive UI + theme.value = id; + + // Also update document element for consistency + document.documentElement.dataset.agentTheme = id; + + // Persist to storage + try { + await chrome.storage.local.set({ [STORAGE_KEY_THEME]: id }); + } catch (error) { + console.error('[useAgentTheme] Failed to save theme:', error); + } + } + + /** + * Apply theme to a DOM element + */ + function applyTo(el: HTMLElement): void { + el.dataset.agentTheme = theme.value; + } + + /** + * Get the preloaded theme from document + */ + function getPreloadedTheme(): AgentThemeId { + return getThemeFromDocument(); + } + + return { + theme, + ready, + setTheme, + initTheme, + applyTo, + getPreloadedTheme, + }; +} + +/** + * Preload theme before Vue mounts (call in main.ts) + * This prevents theme flashing on page load. + */ +export async function preloadAgentTheme(): Promise { + let themeId: AgentThemeId = DEFAULT_THEME; + + try { + const result = await chrome.storage.local.get(STORAGE_KEY_THEME); + const stored = result[STORAGE_KEY_THEME]; + + if (isValidTheme(stored)) { + themeId = stored; + } + } catch (error) { + console.error('[preloadAgentTheme] Failed to load theme:', error); + } + + // Set on document element for immediate application + document.documentElement.dataset.agentTheme = themeId; + + return themeId; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts new file mode 100644 index 0000000..1b16469 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts @@ -0,0 +1,733 @@ +/** + * Composable for grouping messages into conversation threads. + * Transforms flat AgentMessage[] into structured AgentThread[] for UI rendering. + */ +import { computed, type InjectionKey, type Ref } from 'vue'; +import type { + AgentMessage, + AgentMessageAttachmentMetadata, + AttachmentMetadata, +} from 'chrome-mcp-shared'; +import type { RequestState } from './useAgentChat'; + +/** + * Injection key for agent server port. + * Provided by AgentChat.vue for child components to access attachment URLs. + */ +export const AGENT_SERVER_PORT_KEY: InjectionKey> = Symbol('agentServerPort'); + +/** Thread state */ +export type AgentThreadState = + | 'idle' + | 'starting' + | 'running' + | 'completed' + | 'error' + | 'cancelled'; + +/** Tool kinds for presentation */ +export type ToolKind = 'grep' | 'read' | 'edit' | 'run' | 'plan' | 'generic'; + +/** Tool severity for styling */ +export type ToolSeverity = 'info' | 'success' | 'warning' | 'error'; + +/** Diff statistics for edit operations */ +export interface DiffStats { + addedLines?: number; + deletedLines?: number; + totalLines?: number; +} + +/** Structured tool presentation */ +export interface ToolPresentation { + kind: ToolKind; + label: string; + title: string; + subtitle?: string; + details?: string; + files?: string[]; + /** File path for single-file operations */ + filePath?: string; + /** Diff statistics for edit/write operations */ + diffStats?: DiffStats; + command?: string; + /** Command description from bash tool */ + commandDescription?: string; + query?: string; + /** Search pattern for grep/glob */ + pattern?: string; + /** Search path */ + searchPath?: string; + engine?: string; + severity: ToolSeverity; + phase: 'use' | 'result'; + raw: { content: string; metadata?: Record }; +} + +/** Timeline item types */ +export type TimelineItem = + | { + kind: 'user_prompt'; + id: string; + requestId?: string; + createdAt: string; + messageId: string; + text: string; + attachments: AttachmentMetadata[]; + } + | { + kind: 'assistant_text'; + id: string; + requestId?: string; + createdAt: string; + messageId: string; + text: string; + isStreaming: boolean; + } + | { + kind: 'tool_use'; + id: string; + requestId?: string; + createdAt: string; + messageId: string; + tool: ToolPresentation; + isStreaming: boolean; + } + | { + kind: 'tool_result'; + id: string; + requestId?: string; + createdAt: string; + messageId: string; + tool: ToolPresentation; + isError: boolean; + } + | { + kind: 'status'; + id: string; + requestId?: string; + createdAt: string; + status: string; + text?: string; + }; + +/** Client metadata for web editor apply messages */ +export interface WebEditorApplyMeta { + kind: 'web_editor_apply_batch' | 'web_editor_apply_single'; + pageUrl?: string; + elementCount?: number; + elementLabels?: string[]; +} + +/** Thread header data for special message types */ +export interface ThreadHeader { + /** Display text (compact representation) */ + displayText?: string; + /** Full prompt content for hover display */ + fullContent: string; + /** Web editor apply metadata */ + webEditorApply?: WebEditorApplyMeta; +} + +/** A grouped conversation thread */ +export interface AgentThread { + id: string; + requestId?: string; + title: string; + createdAt: string; + state: AgentThreadState; + items: TimelineItem[]; + /** Attachments from the user prompt (for display in thread header) */ + attachments: AttachmentMetadata[]; + /** Thread header data for special message rendering */ + header?: ThreadHeader; +} + +/** Options for useAgentThreads */ +export interface UseAgentThreadsOptions { + messages: Ref; + /** Request lifecycle state (replaces isStreaming for thread state calculation) */ + requestState: Ref; + currentRequestId: Ref; +} + +/** + * Normalize a string for comparison + */ +function normalize(s: string | undefined): string { + return (s ?? '').toLowerCase().trim(); +} + +/** + * Get first string from multiple candidates + */ +function firstString(...args: unknown[]): string | undefined { + for (const arg of args) { + if (typeof arg === 'string' && arg.trim()) { + return arg.trim(); + } + } + return undefined; +} + +/** + * Extract text after a prefix (e.g., "Running: ") + */ +function extractAfterPrefix(content: string, prefix: string): string | undefined { + const idx = content.indexOf(prefix); + if (idx === -1) return undefined; + return content.slice(idx + prefix.length).trim(); +} + +/** + * Summarize content to one line + */ +function summarizeOneLine(content: string): string { + const line = content.split('\n')[0]?.trim() ?? ''; + return line.length > 60 ? line.slice(0, 57) + '...' : line; +} + +/** + * Title case a string + */ +function titleCase(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); +} + +/** + * Extract file name from path + */ +function getFileName(filePath: string): string { + return filePath.split('/').pop() || filePath; +} + +/** + * Build diff stats from metadata + */ +function buildDiffStats(meta: Record): DiffStats | undefined { + const addedLines = typeof meta.addedLines === 'number' ? meta.addedLines : undefined; + const deletedLines = typeof meta.deletedLines === 'number' ? meta.deletedLines : undefined; + const totalLines = typeof meta.totalLines === 'number' ? meta.totalLines : undefined; + + if (addedLines !== undefined || deletedLines !== undefined || totalLines !== undefined) { + return { addedLines, deletedLines, totalLines }; + } + return undefined; +} + +/** + * Present a tool message as ToolPresentation + */ +function presentTool(msg: AgentMessage): ToolPresentation { + const meta = (msg.metadata ?? {}) as Record; + const phase = msg.messageType === 'tool_use' ? 'use' : 'result'; + const engine = msg.cliSource; + + const toolName = + firstString(meta.toolName as string, meta.tool_name as string) ?? + (typeof engine === 'string' ? engine : undefined) ?? + 'tool'; + + const isError = + meta.is_error === true || + meta.isError === true || + (typeof msg.content === 'string' && msg.content.trimStart().startsWith('Error:')); + + // Extract common metadata fields + const filePath = firstString(meta.filePath as string); + const command = firstString(meta.command as string); + const commandDescription = firstString(meta.commandDescription as string); + const pattern = firstString(meta.pattern as string); + const searchPath = firstString(meta.searchPath as string); + const diffStats = buildDiffStats(meta); + + // Rule 1: Plan / TodoWrite + if ( + meta.planPhase || + normalize(toolName) === 'plan' || + normalize(toolName) === 'todo_write' || + normalize(toolName) === 'todowrite' + ) { + const todoCount = typeof meta.todoCount === 'number' ? meta.todoCount : undefined; + return { + kind: 'plan', + label: 'Plan', + title: todoCount ? `${todoCount} tasks` : summarizeOneLine(msg.content) || 'Plan update', + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 2: Edit tool with file path and diff stats + if ( + normalize(toolName).includes('edit') || + normalize(toolName) === 'apply_patch' || + normalize(toolName) === 'patch_file' + ) { + const fileName = filePath ? getFileName(filePath) : undefined; + return { + kind: 'edit', + label: 'Edit', + title: fileName || filePath || 'File', + filePath, + diffStats, + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'success', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 3: Write/Create tool + if (normalize(toolName).includes('write') || normalize(toolName) === 'create_file') { + const fileName = filePath ? getFileName(filePath) : undefined; + return { + kind: 'edit', + label: 'Write', + title: fileName || filePath || 'File', + filePath, + diffStats, + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'success', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 4: File summary (Codex file_change -> metadata.files) + const files = Array.isArray(meta.files) + ? (meta.files as string[]).filter((x) => typeof x === 'string') + : []; + if (files.length > 0) { + const title = files.length === 1 ? getFileName(files[0]) : `${files.length} files`; + return { + kind: 'edit', + label: 'Edit', + title, + subtitle: files.length > 1 ? files.slice(0, 3).map(getFileName).join(', ') : undefined, + files, + filePath: files.length === 1 ? files[0] : undefined, + diffStats, + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'success', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 5: Command (Bash/shell) + if ( + normalize(toolName) === 'bash' || + normalize(toolName).includes('shell') || + typeof command === 'string' || + msg.content.startsWith('Running:') || + msg.content.startsWith('Ran:') + ) { + const extractedCommand = + command ?? + extractAfterPrefix(msg.content, 'Running:') ?? + extractAfterPrefix(msg.content, 'Ran:') ?? + undefined; + + const details = + firstString(meta.output as string) ?? (phase === 'result' ? msg.content : undefined); + + return { + kind: 'run', + label: 'Run', + title: commandDescription || extractedCommand?.trim() || 'Command', + subtitle: commandDescription && extractedCommand ? extractedCommand.trim() : undefined, + command: extractedCommand?.trim(), + commandDescription, + details, + engine, + severity: isError ? 'error' : phase === 'result' ? 'success' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 6: Grep/Search with pattern + if (normalize(toolName) === 'grep' || normalize(toolName).includes('search') || pattern) { + const queryFromContent = extractAfterPrefix(msg.content, 'Searching:'); + const displayPattern = pattern || queryFromContent?.trim(); + return { + kind: 'grep', + label: 'Grep', + title: displayPattern || 'Search', + pattern: displayPattern, + searchPath, + query: displayPattern, + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 7: Glob with pattern + if (normalize(toolName) === 'glob' || normalize(toolName) === 'glob_files') { + return { + kind: 'grep', + label: 'Glob', + title: pattern || 'Pattern search', + pattern, + searchPath, + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 8: Read tool + if (normalize(toolName).includes('read') || filePath) { + const fileName = filePath ? getFileName(filePath) : undefined; + return { + kind: 'read', + label: 'Read', + title: fileName || filePath || 'File', + filePath, + engine, + severity: isError ? 'error' : phase === 'result' ? 'success' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Rule 9: Read / Edit by action (fallback for content-based detection) + const action = firstString(meta.action as string); + const fileFromContent = extractAfterPrefix(msg.content, 'Operating on:')?.trim(); + const inferredKind = + action === 'Read' + ? 'read' + : action === 'Edited' || action === 'Created' || action === 'Deleted' + ? 'edit' + : null; + + if (fileFromContent || inferredKind) { + const kind: ToolKind = inferredKind ?? 'read'; + return { + kind, + label: kind === 'read' ? 'Read' : 'Edit', + title: fileFromContent ? getFileName(fileFromContent) : toolName, + filePath: fileFromContent, + diffStats: kind === 'edit' ? diffStats : undefined, + engine, + severity: isError ? 'error' : phase === 'result' ? 'success' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; + } + + // Fallback: generic tool + return { + kind: 'generic', + label: titleCase(toolName), + title: summarizeOneLine(msg.content) || `Using ${toolName}`, + details: phase === 'result' ? msg.content : undefined, + engine, + severity: isError ? 'error' : 'info', + phase, + raw: { content: msg.content, metadata: meta }, + }; +} + +/** + * Type guard for AttachmentMetadata. + * Validates that an unknown value conforms to the AttachmentMetadata interface. + * Includes semantic validation (non-empty strings, valid numbers). + */ +function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + const index = v.index; + const sizeBytes = v.sizeBytes; + return ( + v.version === 1 && + v.kind === 'image' && + typeof v.projectId === 'string' && + (v.projectId as string).trim().length > 0 && + typeof v.messageId === 'string' && + (v.messageId as string).trim().length > 0 && + typeof index === 'number' && + Number.isInteger(index) && + index >= 0 && + typeof v.filename === 'string' && + (v.filename as string).trim().length > 0 && + typeof v.urlPath === 'string' && + (v.urlPath as string).trim().length > 0 && + typeof v.mimeType === 'string' && + (v.mimeType as string).trim().length > 0 && + typeof sizeBytes === 'number' && + Number.isFinite(sizeBytes) && + sizeBytes >= 0 && + typeof v.originalName === 'string' && + (v.originalName as string).trim().length > 0 && + typeof v.createdAt === 'string' && + (v.createdAt as string).trim().length > 0 + ); +} + +/** + * Extract validated attachments from a message's metadata. + * Returns sorted by index for consistent display order. + */ +function getMessageAttachments(msg: AgentMessage): AttachmentMetadata[] { + const meta = (msg.metadata ?? {}) as AgentMessageAttachmentMetadata; + const attachments = meta.attachments; + if (!Array.isArray(attachments)) return []; + return attachments.filter(isAttachmentMetadata).sort((a, b) => a.index - b.index); +} + +/** + * Map a message to a timeline item + */ +function mapMessageToTimelineItem(msg: AgentMessage): TimelineItem | null { + const createdAt = msg.createdAt; + const requestId = msg.requestId?.trim() || undefined; + + // User chat messages are displayed in thread header (title + attachments), + // so we don't create timeline items for them to avoid duplicate display. + if (msg.role === 'user' && msg.messageType === 'chat') { + return null; + } + + if (msg.role === 'assistant' && msg.messageType === 'chat') { + return { + kind: 'assistant_text', + id: msg.id, + requestId, + createdAt, + messageId: msg.id, + text: msg.content, + isStreaming: msg.isStreaming === true && !msg.isFinal, + }; + } + + if (msg.role === 'tool' && msg.messageType === 'tool_use') { + return { + kind: 'tool_use', + id: msg.id, + requestId, + createdAt, + messageId: msg.id, + tool: presentTool(msg), + isStreaming: msg.isStreaming === true && !msg.isFinal, + }; + } + + if (msg.role === 'tool' && msg.messageType === 'tool_result') { + const tool = presentTool(msg); + return { + kind: 'tool_result', + id: msg.id, + requestId, + createdAt, + messageId: msg.id, + tool, + isError: tool.severity === 'error', + }; + } + + // Status messages + if (msg.messageType === 'status' || msg.role === 'system') { + return { + kind: 'status', + id: `status:${requestId ?? 'legacy'}:${msg.id}`, + requestId, + createdAt, + status: 'ready', + text: msg.content, + }; + } + + return null; +} + +/** + * Build threads from messages + */ +function buildThreads( + messages: AgentMessage[], + requestState: RequestState, + currentRequestId: string | null, +): AgentThread[] { + // Sort messages by createdAt + const sortedMessages = [...messages].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + + // Group messages by requestId or legacy grouping + let legacyCounter = 0; + let currentLegacyKey: string | null = null; + + const groups = new Map< + string, + { + key: string; + requestId?: string; + firstAt: string; + title?: string; + items: TimelineItem[]; + attachments: AttachmentMetadata[]; + /** Thread header for special message types */ + header?: ThreadHeader; + } + >(); + + function ensureGroup(key: string, requestId: string | undefined, createdAt: string) { + if (!groups.has(key)) { + groups.set(key, { key, requestId, firstAt: createdAt, items: [], attachments: [] }); + } + return groups.get(key)!; + } + + for (const msg of sortedMessages) { + const rid = msg.requestId?.trim() || undefined; + + // Determine group key + let key: string; + if (rid) { + key = `rid:${rid}`; + } else { + if (msg.role === 'user') { + currentLegacyKey = `legacy:${legacyCounter++}`; + } + key = currentLegacyKey ?? 'legacy:orphan'; + } + + const group = ensureGroup(key, rid, msg.createdAt); + + // Title, attachments, and header: first user chat message in group wins + if (!group.title && msg.role === 'user' && msg.messageType === 'chat') { + const fullContent = msg.content.trim(); + const attachments = getMessageAttachments(msg); + const meta = (msg.metadata ?? {}) as Record; + + // Extract client metadata for special message types (with runtime validation) + const rawClientMeta = meta.clientMeta; + const rawDisplayText = meta.displayText; + + // Validate clientMeta structure + const clientMeta: WebEditorApplyMeta | undefined = + rawClientMeta && + typeof rawClientMeta === 'object' && + 'kind' in rawClientMeta && + typeof (rawClientMeta as Record).kind === 'string' && + ((rawClientMeta as Record).kind === 'web_editor_apply_batch' || + (rawClientMeta as Record).kind === 'web_editor_apply_single') + ? (rawClientMeta as WebEditorApplyMeta) + : undefined; + + const displayText = typeof rawDisplayText === 'string' ? rawDisplayText : undefined; + + // Store attachments for thread header display + if (attachments.length > 0) { + group.attachments = attachments; + } + + // Build thread header for special message types + if (clientMeta?.kind?.startsWith('web_editor_apply')) { + group.header = { + displayText: displayText || `Apply ${clientMeta.elementCount ?? 0} changes`, + fullContent, + webEditorApply: clientMeta, + }; + // Use display text as title for web editor apply messages + group.title = displayText || `Apply ${clientMeta.elementCount ?? 0} changes`; + } else if (fullContent) { + group.title = fullContent; + } else { + // Image-only message - use attachment count as title + group.title = + attachments.length > 0 + ? `Sent ${attachments.length} image${attachments.length === 1 ? '' : 's'}` + : 'Untitled request'; + } + + group.firstAt = msg.createdAt; + } + + // Map message to timeline item + const item = mapMessageToTimelineItem(msg); + if (item) group.items.push(item); + + // Update earliest timestamp + if (msg.createdAt < group.firstAt) group.firstAt = msg.createdAt; + } + + // Convert groups to threads + const threads: AgentThread[] = []; + + for (const g of groups.values()) { + const requestId = g.requestId; + + // Determine thread state based on requestState (not isStreaming) + // This ensures the thread shows as running even during tool execution + const isActiveRequest = + requestState === 'starting' || requestState === 'ready' || requestState === 'running'; + + let state: AgentThreadState = 'completed'; + if (isActiveRequest && currentRequestId && requestId === currentRequestId) { + // Map requestState to thread state + state = requestState === 'running' ? 'running' : 'starting'; + } else if (g.items.some((item) => item.kind === 'status')) { + state = 'idle'; + } + + // Sort items by createdAt + const items = [...g.items].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + + // Add status item for active requests + // Use stable ID without Date.now() to prevent component remount on each render + if (state === 'running' || state === 'starting') { + const statusText = state === 'running' ? 'Working...' : 'Starting...'; + items.push({ + kind: 'status', + id: `status:streaming:${requestId ?? 'current'}`, + requestId, + createdAt: new Date().toISOString(), + status: state, + text: statusText, + }); + } + + threads.push({ + id: g.key, + requestId, + title: g.title ?? 'Untitled request', + createdAt: g.firstAt, + state, + items, + attachments: g.attachments, + header: g.header, + }); + } + + // Sort threads by createdAt + return threads.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +/** + * Composable for managing agent threads + */ +export function useAgentThreads(options: UseAgentThreadsOptions) { + const threads = computed(() => { + return buildThreads( + options.messages.value, + options.requestState.value, + options.currentRequestId.value, + ); + }); + + return { + threads, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useAttachments.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useAttachments.ts new file mode 100644 index 0000000..b6e1cea --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useAttachments.ts @@ -0,0 +1,257 @@ +/** + * Composable for managing file attachments. + * Handles file selection, drag-drop, paste, conversion, preview, and removal. + */ +import { ref, computed } from 'vue'; +import type { AgentAttachment } from 'chrome-mcp-shared'; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_ATTACHMENTS = 10; // Maximum number of attachments + +// Allowed image MIME types (exclude SVG for security) +const ALLOWED_IMAGE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', +]); + +/** + * Extended attachment type with preview URL support. + */ +export interface AttachmentWithPreview extends AgentAttachment { + /** Data URL for image preview (data:xxx;base64,...) */ + previewUrl?: string; +} + +export function useAttachments() { + const attachments = ref([]); + const fileInputRef = ref(null); + const error = ref(null); + const isDragOver = ref(false); + + // Computed: check if we have any image attachments + const hasImages = computed(() => attachments.value.some((a) => a.type === 'image')); + + // Computed: check if we can add more attachments + const canAddMore = computed(() => attachments.value.length < MAX_ATTACHMENTS); + + /** + * Open file picker for image selection. + */ + function openFilePicker(): void { + fileInputRef.value?.click(); + } + + /** + * Convert file to base64 string. + */ + function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Remove data:xxx;base64, prefix + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + } + + /** + * Generate preview URL for image attachments. + */ + function getPreviewUrl(attachment: AttachmentWithPreview): string { + if (attachment.previewUrl) { + return attachment.previewUrl; + } + // Generate data URL from base64 + return `data:${attachment.mimeType};base64,${attachment.dataBase64}`; + } + + /** + * Process files and add them as attachments. + * This is the core method used by file input, drag-drop, and paste handlers. + */ + async function handleFiles(files: File[]): Promise { + error.value = null; + + // Filter to only allowed image types (exclude SVG for security) + const imageFiles = files.filter((file) => ALLOWED_IMAGE_TYPES.has(file.type)); + if (imageFiles.length === 0) { + error.value = 'Only PNG, JPEG, GIF, and WebP images are supported.'; + return; + } + + // Check attachment limit + const remaining = MAX_ATTACHMENTS - attachments.value.length; + if (remaining <= 0) { + error.value = `Maximum ${MAX_ATTACHMENTS} attachments allowed.`; + return; + } + + const filesToProcess = imageFiles.slice(0, remaining); + if (filesToProcess.length < imageFiles.length) { + error.value = `Only ${remaining} more attachment(s) allowed. Some files were skipped.`; + } + + for (const file of filesToProcess) { + // Validate file size + if (file.size > MAX_FILE_SIZE) { + error.value = `File "${file.name}" is too large. Maximum size is 10MB.`; + continue; + } + + try { + const base64 = await fileToBase64(file); + const previewUrl = `data:${file.type};base64,${base64}`; + + attachments.value.push({ + type: 'image', + name: file.name, + mimeType: file.type || 'image/png', + dataBase64: base64, + previewUrl, + }); + } catch (err) { + console.error('Failed to read file:', err); + error.value = `Failed to read file "${file.name}".`; + } + } + } + + /** + * Handle file selection from input element. + */ + async function handleFileSelect(event: Event): Promise { + const input = event.target as HTMLInputElement; + const files = input.files; + if (!files || files.length === 0) return; + + await handleFiles(Array.from(files)); + + // Clear input to allow selecting the same file again + input.value = ''; + } + + /** + * Handle drag over event - update visual state. + */ + function handleDragOver(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + isDragOver.value = true; + } + + /** + * Handle drag leave event - reset visual state. + */ + function handleDragLeave(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + isDragOver.value = false; + } + + /** + * Handle drop event - process dropped files. + */ + async function handleDrop(event: DragEvent): Promise { + event.preventDefault(); + event.stopPropagation(); + isDragOver.value = false; + + const files = event.dataTransfer?.files; + if (!files || files.length === 0) return; + + await handleFiles(Array.from(files)); + } + + /** + * Handle paste event - extract and process pasted images. + */ + async function handlePaste(event: ClipboardEvent): Promise { + const items = event.clipboardData?.items; + if (!items) return; + + const imageFiles: File[] = []; + for (const item of items) { + // Only allow specific image types (exclude SVG for security) + if (ALLOWED_IMAGE_TYPES.has(item.type)) { + const file = item.getAsFile(); + if (file) { + // Generate a name for pasted images (they don't have one) + const ext = item.type.split('/')[1] || 'png'; + const namedFile = new File([file], `pasted-image-${Date.now()}.${ext}`, { + type: file.type, + }); + imageFiles.push(namedFile); + } + } + } + + if (imageFiles.length > 0) { + // Prevent default paste behavior for images + event.preventDefault(); + await handleFiles(imageFiles); + } + // Let text paste through normally + } + + /** + * Remove attachment by index. + */ + function removeAttachment(index: number): void { + attachments.value.splice(index, 1); + error.value = null; + } + + /** + * Clear all attachments. + */ + function clearAttachments(): void { + attachments.value = []; + error.value = null; + } + + /** + * Get attachments for sending (strips preview URLs). + */ + function getAttachments(): AgentAttachment[] | undefined { + if (attachments.value.length === 0) return undefined; + + return attachments.value.map(({ type, name, mimeType, dataBase64 }) => ({ + type, + name, + mimeType, + dataBase64, + })); + } + + return { + // State + attachments, + fileInputRef, + error, + isDragOver, + + // Computed + hasImages, + canAddMore, + + // Methods + openFilePicker, + handleFileSelect, + handleFiles, + handleDragOver, + handleDragLeave, + handleDrop, + handlePaste, + removeAttachment, + clearAttachments, + getAttachments, + getPreviewUrl, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useFakeCaret.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useFakeCaret.ts new file mode 100644 index 0000000..3f26ada --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useFakeCaret.ts @@ -0,0 +1,614 @@ +/** + * Composable for rendering a "fake" caret overlay on top of a textarea. + * + * Implementation notes: + * - We do NOT intercept input; we only compute caret coordinates. + * - A hidden "mirror" element is used to measure caret position reliably with wrapping. + * - The actual textarea input/IME/selection behavior is preserved. + * - When calculation is unreliable (IME/selection/error), we fall back to native caret. + */ +import { + computed, + onUnmounted, + ref, + watch, + type CSSProperties, + type ComputedRef, + type Ref, +} from 'vue'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface FakeCaretTrailPoint { + x: number; + y: number; + alpha: number; +} + +export interface UseFakeCaretOptions { + /** Reference to the textarea element */ + textareaRef: Ref; + /** + * Feature flag for enabling the fake caret. + * When false, the composable will report showFakeCaret=false + * and the caller should display the native caret. + */ + enabled?: Ref; +} + +export interface UseFakeCaretReturn { + /** Style for the overlay container (position: absolute, inset: 0) */ + overlayStyle: ComputedRef; + /** Whether to show the fake caret (false when degraded) */ + showFakeCaret: ComputedRef; + /** Current X position of caret (animated) */ + caretX: Ref; + /** Current Y position of caret (animated) */ + caretY: Ref; + /** Trail points for comet tail effect */ + trail: Ref; + /** Manually trigger position update */ + updatePosition: () => void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const MAX_TRAIL_POINTS = 24; +const TRAIL_DECAY = 0.86; +const TRAIL_MIN_ALPHA = 0.06; +const TRAIL_MIN_DISTANCE_PX = 0.35; +const SMOOTHING = 0.35; +const SNAP_DISTANCE_PX = 0.2; + +// ============================================================================= +// Helpers +// ============================================================================= + +function isFiniteNumber(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +// ============================================================================= +// Main Composable +// ============================================================================= + +export function useFakeCaret(options: UseFakeCaretOptions): UseFakeCaretReturn { + // Default to disabled (opt-in) for safer rollout + const enabled = options.enabled ?? ref(false); + + // Position state (animated values) + const caretX = ref(0); + const caretY = ref(0); + const trail = ref([]); + + // Internal state + const isFocused = ref(false); + const isComposing = ref(false); + const hasSelection = ref(false); + const hasValidMeasurement = ref(false); + const prefersReducedMotion = ref(false); + + // Target position (raw measurement) + let targetX = 0; + let targetY = 0; + + // Animation state + let scheduled = false; + let rafId: number | null = null; + + // Mirror element for measurement + let mirrorEl: HTMLDivElement | null = null; + let lastMirrorKey = ''; + + // Resize observer + let resizeObserver: ResizeObserver | null = null; + + // Trail tracking + let lastTrailX = 0; + let lastTrailY = 0; + + // Disposed flag to prevent operations after unmount + let disposed = false; + + // --------------------------------------------------------------------------- + // Computed Properties + // --------------------------------------------------------------------------- + + const overlayStyle = computed(() => ({ + position: 'absolute', + inset: 0, + pointerEvents: 'none', + overflow: 'hidden', + })); + + const showFakeCaret = computed(() => { + if (!enabled.value) return false; + const el = options.textareaRef.value; + if (!el) return false; + if (!isFocused.value) return false; + if (isComposing.value) return false; + if (hasSelection.value) return false; + return hasValidMeasurement.value; + }); + + // --------------------------------------------------------------------------- + // Mirror Element Management + // --------------------------------------------------------------------------- + + function ensureMirror(): HTMLDivElement | null { + if (disposed) return null; + if (mirrorEl) return mirrorEl; + if (typeof document === 'undefined' || !document.body) return null; + + const el = document.createElement('div'); + el.setAttribute('data-ac-fake-caret-mirror', 'true'); + el.style.position = 'fixed'; + el.style.top = '0'; + el.style.left = '-10000px'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + el.style.whiteSpace = 'pre-wrap'; + el.style.wordBreak = 'break-word'; + el.style.overflowWrap = 'break-word'; + el.style.overflow = 'auto'; + el.style.contain = 'layout style paint'; + el.style.border = '0'; + el.style.background = 'transparent'; + + document.body.appendChild(el); + mirrorEl = el; + return mirrorEl; + } + + function syncMirrorStyle(textarea: HTMLTextAreaElement, mirror: HTMLDivElement): void { + const cs = window.getComputedStyle(textarea); + + // clientWidth includes padding but excludes scrollbar + const width = `${textarea.clientWidth}px`; + const height = `${textarea.clientHeight}px`; + const tabSize = cs.getPropertyValue('tab-size'); + + // Build cache key to avoid unnecessary style updates + const key = [ + width, + height, + cs.font, + cs.padding, + cs.letterSpacing, + cs.lineHeight, + cs.textTransform, + cs.textIndent, + cs.textAlign, + cs.direction, + tabSize, + ].join('|'); + + if (key === lastMirrorKey) return; + lastMirrorKey = key; + + mirror.style.boxSizing = 'border-box'; + mirror.style.width = width; + mirror.style.height = height; + mirror.style.padding = cs.padding; + mirror.style.font = cs.font; + mirror.style.letterSpacing = cs.letterSpacing; + mirror.style.lineHeight = cs.lineHeight; + mirror.style.textTransform = cs.textTransform; + mirror.style.textIndent = cs.textIndent; + mirror.style.textAlign = cs.textAlign; + mirror.style.direction = cs.direction; + + if (tabSize) { + mirror.style.setProperty('tab-size', tabSize); + } + } + + // --------------------------------------------------------------------------- + // Caret Position Measurement + // --------------------------------------------------------------------------- + + function measureCaret(textarea: HTMLTextAreaElement): { x: number; y: number } | null { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + if (!isFiniteNumber(start) || !isFiniteNumber(end)) { + hasSelection.value = false; + return null; + } + + hasSelection.value = start !== end; + if (hasSelection.value) return null; + if (isComposing.value) return null; + if (textarea.clientWidth <= 0 || textarea.clientHeight <= 0) return null; + + const mirror = ensureMirror(); + if (!mirror) return null; + + syncMirrorStyle(textarea, mirror); + + // Keep mirror scroll in sync + mirror.scrollTop = textarea.scrollTop; + mirror.scrollLeft = textarea.scrollLeft; + + // Build mirror DOM: [beforeText][marker] + mirror.innerHTML = ''; + const beforeText = textarea.value.slice(0, start); + mirror.appendChild(document.createTextNode(beforeText)); + + const marker = document.createElement('span'); + marker.textContent = '\u200b'; // Zero-width space + marker.style.display = 'inline-block'; + marker.style.width = '1px'; + marker.style.height = '1em'; + mirror.appendChild(marker); + + const markerRect = marker.getBoundingClientRect(); + const mirrorRect = mirror.getBoundingClientRect(); + + const x = markerRect.left - mirrorRect.left; + const y = markerRect.top - mirrorRect.top; + + if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null; + + // Clamp to textarea viewport + const clampedX = clamp(x, 0, textarea.clientWidth + 2); + const clampedY = clamp(y, 0, textarea.clientHeight + 2); + + // If wildly off, treat as invalid + if (Math.abs(clampedX - x) > 20 || Math.abs(clampedY - y) > 20) { + return null; + } + + return { x: clampedX, y: clampedY }; + } + + // --------------------------------------------------------------------------- + // Position Updates + // --------------------------------------------------------------------------- + + function applyTarget(x: number, y: number): void { + const positionChanged = targetX !== x || targetY !== y; + targetX = x; + targetY = y; + + // Skip animation if reduced motion preferred + if (prefersReducedMotion.value) { + caretX.value = x; + caretY.value = y; + trail.value = []; + lastTrailX = x; + lastTrailY = y; + return; + } + + // Restart RAF if position changed (may have been stopped when idle) + if (positionChanged && showFakeCaret.value) { + startLoop(); + } + } + + function updateNow(): void { + const textarea = options.textareaRef.value; + if (!textarea) { + hasValidMeasurement.value = false; + return; + } + + // Only measure when we intend to show the fake caret + if (!enabled.value || !isFocused.value || isComposing.value) { + hasValidMeasurement.value = false; + return; + } + + const pos = measureCaret(textarea); + if (!pos) { + hasValidMeasurement.value = false; + return; + } + + hasValidMeasurement.value = true; + applyTarget(pos.x, pos.y); + } + + function scheduleUpdate(): void { + if (disposed) return; + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + if (!disposed) { + updateNow(); + } + }); + } + + function updatePosition(): void { + scheduleUpdate(); + } + + // --------------------------------------------------------------------------- + // Animation Loop + // --------------------------------------------------------------------------- + + function tick(): void { + if (!showFakeCaret.value) return; + if (prefersReducedMotion.value) return; + + // Smooth caret position + const dx = targetX - caretX.value; + const dy = targetY - caretY.value; + + // Check if caret has snapped to target + const isSnapped = Math.abs(dx) < SNAP_DISTANCE_PX && Math.abs(dy) < SNAP_DISTANCE_PX; + + if (isSnapped) { + caretX.value = targetX; + caretY.value = targetY; + } else { + caretX.value = caretX.value + dx * SMOOTHING; + caretY.value = caretY.value + dy * SMOOTHING; + } + + // Update trail (comet tail effect) + const currentTrail = trail.value; + const nextTrail: FakeCaretTrailPoint[] = []; + + // Fade existing points + for (const p of currentTrail) { + const alpha = p.alpha * TRAIL_DECAY; + if (alpha >= TRAIL_MIN_ALPHA) { + nextTrail.push({ ...p, alpha }); + } + } + + // Add new point if moved enough + const moved = + Math.abs(caretX.value - lastTrailX) + Math.abs(caretY.value - lastTrailY) > + TRAIL_MIN_DISTANCE_PX; + + if (moved) { + nextTrail.push({ x: caretX.value, y: caretY.value, alpha: 1 }); + lastTrailX = caretX.value; + lastTrailY = caretY.value; + } + + // Only update trail ref if content changed (avoid triggering watchers) + // Note: must compare alpha too, otherwise fade animation won't work + const trailChanged = + nextTrail.length !== currentTrail.length || + nextTrail.some( + (p, i) => + p.x !== currentTrail[i]?.x || + p.y !== currentTrail[i]?.y || + Math.abs(p.alpha - (currentTrail[i]?.alpha ?? 0)) > 0.001, + ); + + if (trailChanged) { + // Keep only the last N points + if (nextTrail.length > MAX_TRAIL_POINTS) { + trail.value = nextTrail.slice(nextTrail.length - MAX_TRAIL_POINTS); + } else { + trail.value = nextTrail; + } + } + + // Stop RAF when idle: snapped to target and trail has fully faded + if (isSnapped && nextTrail.length === 0) { + stopLoop(); + } + } + + function startLoop(): void { + if (disposed) return; + if (rafId !== null) return; + const loop = () => { + if (disposed) { + rafId = null; + return; + } + rafId = requestAnimationFrame(loop); + tick(); + }; + rafId = requestAnimationFrame(loop); + } + + function stopLoop(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + // --------------------------------------------------------------------------- + // Reduced Motion Preference + // --------------------------------------------------------------------------- + + let media: MediaQueryList | null = null; + let onMediaChange: ((e: MediaQueryListEvent) => void) | null = null; + + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + media = window.matchMedia('(prefers-reduced-motion: reduce)'); + prefersReducedMotion.value = media.matches; + onMediaChange = (e: MediaQueryListEvent) => { + prefersReducedMotion.value = e.matches; + trail.value = []; + scheduleUpdate(); + }; + try { + media.addEventListener('change', onMediaChange); + } catch { + // Safari < 14 fallback + media.addListener(onMediaChange as EventListener); + } + } + + // --------------------------------------------------------------------------- + // Textarea Event Binding + // --------------------------------------------------------------------------- + + watch( + () => options.textareaRef.value, + (el, _prev, onCleanup) => { + if (!el) return; + + const handleFocus = () => { + isFocused.value = true; + scheduleUpdate(); + }; + const handleBlur = () => { + isFocused.value = false; + hasValidMeasurement.value = false; + stopLoop(); + trail.value = []; + }; + const handleInput = () => scheduleUpdate(); + const handleKey = () => scheduleUpdate(); + const handleMouse = () => scheduleUpdate(); + const handleScroll = () => scheduleUpdate(); + const handleSelect = () => scheduleUpdate(); + const handleCompositionStart = () => { + isComposing.value = true; + scheduleUpdate(); + }; + const handleCompositionEnd = () => { + isComposing.value = false; + scheduleUpdate(); + }; + + el.addEventListener('focus', handleFocus); + el.addEventListener('blur', handleBlur); + el.addEventListener('input', handleInput); + el.addEventListener('keydown', handleKey); + el.addEventListener('keyup', handleKey); + el.addEventListener('click', handleMouse); + el.addEventListener('mouseup', handleMouse); + el.addEventListener('scroll', handleScroll, { passive: true }); + el.addEventListener('select', handleSelect); + el.addEventListener('compositionstart', handleCompositionStart); + el.addEventListener('compositionend', handleCompositionEnd); + + // Initialize focus state + isFocused.value = typeof document !== 'undefined' && document.activeElement === el; + + // Observe size changes + if (typeof ResizeObserver !== 'undefined') { + resizeObserver?.disconnect(); + resizeObserver = new ResizeObserver(() => scheduleUpdate()); + resizeObserver.observe(el); + } + + // Initial measurement + scheduleUpdate(); + + onCleanup(() => { + el.removeEventListener('focus', handleFocus); + el.removeEventListener('blur', handleBlur); + el.removeEventListener('input', handleInput); + el.removeEventListener('keydown', handleKey); + el.removeEventListener('keyup', handleKey); + el.removeEventListener('click', handleMouse); + el.removeEventListener('mouseup', handleMouse); + el.removeEventListener('scroll', handleScroll); + el.removeEventListener('select', handleSelect); + el.removeEventListener('compositionstart', handleCompositionStart); + el.removeEventListener('compositionend', handleCompositionEnd); + resizeObserver?.disconnect(); + resizeObserver = null; + }); + }, + { immediate: true }, + ); + + // --------------------------------------------------------------------------- + // Watchers for State Changes + // --------------------------------------------------------------------------- + + watch( + prefersReducedMotion, + (reduced) => { + if (reduced) { + stopLoop(); + trail.value = []; + scheduleUpdate(); + return; + } + if (showFakeCaret.value) { + startLoop(); + } + }, + { immediate: true }, + ); + + watch( + showFakeCaret, + (show) => { + if (!show) { + stopLoop(); + trail.value = []; + return; + } + + // Start animation when showing + scheduleUpdate(); + if (!prefersReducedMotion.value) { + startLoop(); + } + }, + { immediate: true }, + ); + + watch( + enabled, + (v) => { + if (!v) { + stopLoop(); + trail.value = []; + hasValidMeasurement.value = false; + } else { + scheduleUpdate(); + } + }, + { immediate: true }, + ); + + // --------------------------------------------------------------------------- + // Cleanup + // --------------------------------------------------------------------------- + + onUnmounted(() => { + disposed = true; + stopLoop(); + resizeObserver?.disconnect(); + resizeObserver = null; + + if (mirrorEl && mirrorEl.parentNode) { + mirrorEl.parentNode.removeChild(mirrorEl); + } + mirrorEl = null; + + if (media && onMediaChange) { + try { + media.removeEventListener('change', onMediaChange); + } catch { + media.removeListener(onMediaChange as EventListener); + } + } + }); + + return { + overlayStyle, + showFakeCaret, + caretX, + caretY, + trail, + updatePosition, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useFloatingDrag.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useFloatingDrag.ts new file mode 100644 index 0000000..e1a76bd --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useFloatingDrag.ts @@ -0,0 +1,178 @@ +/** + * Vue composable for floating drag functionality. + * Wraps the installFloatingDrag utility for use in Vue components. + */ + +import { ref, onMounted, onUnmounted, type Ref } from 'vue'; +import { + installFloatingDrag, + type FloatingPosition, +} from '@/entrypoints/web-editor-v2/ui/floating-drag'; + +const STORAGE_KEY = 'sidepanel_navigator_position'; + +export interface UseFloatingDragOptions { + /** Storage key for position persistence */ + storageKey?: string; + /** Margin from viewport edges in pixels */ + clampMargin?: number; + /** Threshold for distinguishing click vs drag (ms) */ + clickThresholdMs?: number; + /** Movement threshold for drag activation (px) */ + moveThresholdPx?: number; + /** Default position calculator (called when no saved position exists) */ + getDefaultPosition?: () => FloatingPosition; +} + +export interface UseFloatingDragReturn { + /** Current position (reactive) */ + position: Ref; + /** Whether dragging is in progress */ + isDragging: Ref; + /** Reset position to default */ + resetToDefault: () => void; + /** Computed style object for binding */ + positionStyle: Ref<{ left: string; top: string }>; +} + +/** + * Calculate default position (bottom-right corner with margin) + */ +function getDefaultBottomRightPosition( + buttonSize: number = 40, + margin: number = 12, +): FloatingPosition { + return { + left: window.innerWidth - buttonSize - margin, + top: window.innerHeight - buttonSize - margin, + }; +} + +/** + * Load position from chrome.storage.local + */ +async function loadPosition(storageKey: string): Promise { + try { + const result = await chrome.storage.local.get(storageKey); + const saved = result[storageKey]; + if ( + saved && + typeof saved.left === 'number' && + typeof saved.top === 'number' && + Number.isFinite(saved.left) && + Number.isFinite(saved.top) + ) { + return saved as FloatingPosition; + } + } catch (e) { + console.warn('Failed to load navigator position:', e); + } + return null; +} + +/** + * Save position to chrome.storage.local + */ +async function savePosition(storageKey: string, position: FloatingPosition): Promise { + try { + await chrome.storage.local.set({ [storageKey]: position }); + } catch (e) { + console.warn('Failed to save navigator position:', e); + } +} + +/** + * Vue composable for making an element draggable with position persistence. + */ +export function useFloatingDrag( + handleRef: Ref, + targetRef: Ref, + options: UseFloatingDragOptions = {}, +): UseFloatingDragReturn { + const { + storageKey = STORAGE_KEY, + clampMargin = 12, + clickThresholdMs = 150, + moveThresholdPx = 5, + getDefaultPosition = () => getDefaultBottomRightPosition(40, clampMargin), + } = options; + + const position = ref(getDefaultPosition()); + const isDragging = ref(false); + const positionStyle = ref({ left: `${position.value.left}px`, top: `${position.value.top}px` }); + + let cleanup: (() => void) | null = null; + + function updatePositionStyle(): void { + positionStyle.value = { + left: `${position.value.left}px`, + top: `${position.value.top}px`, + }; + } + + function resetToDefault(): void { + position.value = getDefaultPosition(); + updatePositionStyle(); + savePosition(storageKey, position.value); + } + + async function initPosition(): Promise { + const saved = await loadPosition(storageKey); + if (saved) { + // Validate position is within current viewport + const maxLeft = window.innerWidth - 40 - clampMargin; + const maxTop = window.innerHeight - 40 - clampMargin; + position.value = { + left: Math.min(Math.max(clampMargin, saved.left), maxLeft), + top: Math.min(Math.max(clampMargin, saved.top), maxTop), + }; + } else { + position.value = getDefaultPosition(); + } + updatePositionStyle(); + } + + onMounted(async () => { + await initPosition(); + + // Wait for refs to be available + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (!handleRef.value || !targetRef.value) { + console.warn('useFloatingDrag: handleRef or targetRef is null'); + return; + } + + cleanup = installFloatingDrag({ + handleEl: handleRef.value, + targetEl: targetRef.value, + onPositionChange: (pos) => { + position.value = pos; + updatePositionStyle(); + savePosition(storageKey, pos); + }, + clampMargin, + clickThresholdMs, + moveThresholdPx, + }); + + // Monitor dragging state via data attribute + const observer = new MutationObserver(() => { + isDragging.value = handleRef.value?.dataset.dragging === 'true'; + }); + if (handleRef.value) { + observer.observe(handleRef.value, { attributes: true, attributeFilter: ['data-dragging'] }); + } + }); + + onUnmounted(() => { + cleanup?.(); + }); + + return { + position, + isDragging, + resetToDefault, + positionStyle, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts new file mode 100644 index 0000000..07aa11e --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts @@ -0,0 +1,137 @@ +/** + * Composable for managing user preference for opening project directory. + * Stores the default target (vscode/terminal) in chrome.storage.local. + */ +import { ref, type Ref } from 'vue'; +import type { OpenProjectTarget, OpenProjectResponse } from 'chrome-mcp-shared'; + +// Storage key for default open target +const STORAGE_KEY = 'agent-open-project-default'; + +export interface UseOpenProjectPreferenceOptions { + /** + * Server port for API calls. + * Should be provided from useAgentServer. + */ + getServerPort: () => number | null; +} + +export interface UseOpenProjectPreference { + /** Current default target (null if not set) */ + defaultTarget: Ref; + /** Loading state */ + loading: Ref; + /** Load default target from storage */ + loadDefaultTarget: () => Promise; + /** Save default target to storage */ + saveDefaultTarget: (target: OpenProjectTarget) => Promise; + /** Open project by session ID */ + openBySession: (sessionId: string, target: OpenProjectTarget) => Promise; + /** Open project by project ID */ + openByProject: (projectId: string, target: OpenProjectTarget) => Promise; +} + +export function useOpenProjectPreference( + options: UseOpenProjectPreferenceOptions, +): UseOpenProjectPreference { + const defaultTarget = ref(null); + const loading = ref(false); + + /** + * Load default target from chrome.storage.local. + */ + async function loadDefaultTarget(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY); + const stored = result[STORAGE_KEY]; + if (stored === 'vscode' || stored === 'terminal') { + defaultTarget.value = stored; + } + } catch (error) { + console.error('[OpenProjectPreference] Failed to load default target:', error); + } + } + + /** + * Save default target to chrome.storage.local. + */ + async function saveDefaultTarget(target: OpenProjectTarget): Promise { + try { + await chrome.storage.local.set({ [STORAGE_KEY]: target }); + defaultTarget.value = target; + } catch (error) { + console.error('[OpenProjectPreference] Failed to save default target:', error); + } + } + + /** + * Open project directory by session ID. + */ + async function openBySession( + sessionId: string, + target: OpenProjectTarget, + ): Promise { + const port = options.getServerPort(); + if (!port) { + return { success: false, error: 'Server not connected' }; + } + + loading.value = true; + try { + const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}/open`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target }), + }); + + const data = (await response.json()) as OpenProjectResponse; + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } finally { + loading.value = false; + } + } + + /** + * Open project directory by project ID. + */ + async function openByProject( + projectId: string, + target: OpenProjectTarget, + ): Promise { + const port = options.getServerPort(); + if (!port) { + return { success: false, error: 'Server not connected' }; + } + + loading.value = true; + try { + const url = `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target }), + }); + + const data = (await response.json()) as OpenProjectResponse; + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } finally { + loading.value = false; + } + } + + return { + defaultTarget, + loading, + loadDefaultTarget, + saveDefaultTarget, + openBySession, + openByProject, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Debugger.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Debugger.ts new file mode 100644 index 0000000..f1442e1 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Debugger.ts @@ -0,0 +1,383 @@ +/** + * @fileoverview RR V3 Debugger Composable + * @description Debugger state management, wraps all DebuggerCommand operations + * + * Responsibilities: + * - Send all debug commands via rr_v3.debug RPC method + * - Maintain reactive DebuggerState + * - Provide consistent error handling and response normalization + */ + +import { computed, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'; + +import type { + DebuggerCommand, + DebuggerResponse, + DebuggerState, +} from '@/entrypoints/background/record-replay-v3/domain/debug'; +import type { NodeId, RunId } from '@/entrypoints/background/record-replay-v3/domain/ids'; +import type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json'; +import type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events'; + +import { useRRV3Rpc, type UseRRV3Rpc } from './useRRV3Rpc'; + +// ==================== Types ==================== + +/** Composable configuration */ +export interface UseRRV3DebuggerOptions { + /** Shared RPC client instance, creates new if not provided */ + rpc?: UseRRV3Rpc; + /** Current runId resolver for command defaults */ + getRunId?: () => RunId | null; + /** State update callback */ + onStateChange?: (state: DebuggerState) => void; + /** Error callback */ + onError?: (error: string) => void; + /** + * Auto-refresh DebuggerState when relevant events are received. + * Only effective when attached to a run. + * Events: run.paused, run.resumed, node.started + */ + autoRefreshOnEvents?: boolean; +} + +/** Composable return type */ +export interface UseRRV3Debugger { + /** RPC client instance */ + rpc: UseRRV3Rpc; + + // State + state: Ref; + lastError: Ref; + busy: Ref; + + // Derived state + currentRunId: ComputedRef; + isAttached: ComputedRef; + isPaused: ComputedRef; + + // Connection control + attach: (runId?: RunId) => Promise; + detach: (runId?: RunId) => Promise; + + // Execution control + pause: (runId?: RunId) => Promise; + resume: (runId?: RunId) => Promise; + stepOver: (runId?: RunId) => Promise; + + // Breakpoint management + setBreakpoints: (nodeIds: NodeId[], runId?: RunId) => Promise; + addBreakpoint: (nodeId: NodeId, runId?: RunId) => Promise; + removeBreakpoint: (nodeId: NodeId, runId?: RunId) => Promise; + + // State query + getState: (runId?: RunId) => Promise; + + // Variable operations + getVar: (name: string, runId?: RunId) => Promise; + setVar: (name: string, value: JsonValue, runId?: RunId) => Promise; +} + +// ==================== Helpers ==================== + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Validate breakpoint structure + */ +function isValidBreakpoint(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const bp = value as Record; + return typeof bp.nodeId === 'string' && typeof bp.enabled === 'boolean'; +} + +/** + * Validate DebuggerState structure + */ +function isValidDebuggerState(value: unknown): value is DebuggerState { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + typeof obj.runId === 'string' && + (obj.status === 'attached' || obj.status === 'detached') && + (obj.execution === 'running' || obj.execution === 'paused') && + Array.isArray(obj.breakpoints) && + obj.breakpoints.every(isValidBreakpoint) + ); +} + +/** + * Normalize RPC response to DebuggerResponse + */ +function normalizeResponse(raw: JsonValue): DebuggerResponse { + if (typeof raw !== 'object' || raw === null) { + return { ok: false, error: 'Invalid response format' }; + } + + const obj = raw as Record; + + if (obj.ok === true) { + const responseState = obj.state; + // Validate state if present + if (responseState !== undefined && !isValidDebuggerState(responseState)) { + return { ok: false, error: 'Invalid DebuggerState in response' }; + } + return { + ok: true, + state: responseState as DebuggerState | undefined, + value: obj.value as JsonValue | undefined, + }; + } + + if (obj.ok === false) { + return { + ok: false, + error: typeof obj.error === 'string' ? obj.error : 'Unknown error', + }; + } + + return { ok: false, error: 'Response missing ok field' }; +} + +// ==================== Composable ==================== + +/** Events that trigger state refresh */ +const STATE_REFRESH_EVENTS = new Set(['run.paused', 'run.resumed', 'node.started']); + +/** + * RR V3 Debugger client + */ +export function useRRV3Debugger(options: UseRRV3DebuggerOptions = {}): UseRRV3Debugger { + // RPC client (use provided or create new) + const rpc = options.rpc ?? useRRV3Rpc(); + + // State + const state = ref(null); + const lastError = ref(null); + const busy = ref(false); + + // Derived state + const currentRunId = computed(() => { + // Prefer external resolver + const fromGetter = options.getRunId?.(); + if (fromGetter) return fromGetter; + // Fallback to current state + return state.value?.runId ?? null; + }); + + const isAttached = computed(() => state.value?.status === 'attached'); + const isPaused = computed(() => state.value?.execution === 'paused'); + + // ==================== Internal Methods ==================== + + function setError(message: string | null): void { + lastError.value = message; + if (message) options.onError?.(message); + } + + function updateState(next?: DebuggerState): void { + if (!next) return; + state.value = next; + options.onStateChange?.(next); + } + + function resolveRunId(explicit?: RunId): RunId | null { + if (explicit) return explicit; + return currentRunId.value; + } + + /** + * Send debug command + */ + async function send(cmd: DebuggerCommand): Promise { + busy.value = true; + try { + const raw = await rpc.request('rr_v3.debug', cmd as unknown as JsonObject); + const response = normalizeResponse(raw); + + if (response.ok) { + setError(null); + if (response.state) { + updateState(response.state); + } + } else { + setError(response.error); + } + + return response; + } catch (error) { + const message = toErrorMessage(error); + setError(message); + return { ok: false, error: message }; + } finally { + busy.value = false; + } + } + + /** + * Create error response for missing runId + */ + function missingRunIdError(commandType: string): DebuggerResponse { + const message = `${commandType} requires runId`; + setError(message); + return { ok: false, error: message }; + } + + // ==================== Public Methods ==================== + + async function attach(runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.attach'); + return send({ type: 'debug.attach', runId: resolved }); + } + + async function detach(runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.detach'); + return send({ type: 'debug.detach', runId: resolved }); + } + + async function pause(runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.pause'); + return send({ type: 'debug.pause', runId: resolved }); + } + + async function resume(runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.resume'); + return send({ type: 'debug.resume', runId: resolved }); + } + + async function stepOver(runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.stepOver'); + return send({ type: 'debug.stepOver', runId: resolved }); + } + + async function setBreakpoints(nodeIds: NodeId[], runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.setBreakpoints'); + return send({ type: 'debug.setBreakpoints', runId: resolved, nodeIds }); + } + + async function addBreakpoint(nodeId: NodeId, runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.addBreakpoint'); + return send({ type: 'debug.addBreakpoint', runId: resolved, nodeId }); + } + + async function removeBreakpoint(nodeId: NodeId, runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.removeBreakpoint'); + return send({ type: 'debug.removeBreakpoint', runId: resolved, nodeId }); + } + + async function getState(runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.getState'); + return send({ type: 'debug.getState', runId: resolved }); + } + + async function getVar(name: string, runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.getVar'); + return send({ type: 'debug.getVar', runId: resolved, name }); + } + + async function setVar(name: string, value: JsonValue, runId?: RunId): Promise { + const resolved = resolveRunId(runId); + if (!resolved) return missingRunIdError('debug.setVar'); + return send({ type: 'debug.setVar', runId: resolved, name, value }); + } + + // ==================== Event Auto-Refresh ==================== + + // State refresh scheduling (debounced) + let refreshScheduled = false; + let refreshTimer: ReturnType | null = null; + + /** + * Schedule a debounced state refresh + * Uses microtask to coalesce multiple events in the same tick + */ + function scheduleRefresh(): void { + if (refreshScheduled) return; + refreshScheduled = true; + + // Clear any existing timer + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + + // Use microtask for same-tick debouncing + queueMicrotask(async () => { + refreshScheduled = false; + // Don't update busy state for auto-refresh to avoid UI flicker + try { + const resolved = currentRunId.value; + if (!resolved || !isAttached.value) return; + const raw = await rpc.request('rr_v3.debug', { + type: 'debug.getState', + runId: resolved, + } as unknown as JsonObject); + const response = normalizeResponse(raw); + if (response.ok && response.state) { + updateState(response.state); + } + } catch { + // Ignore errors in auto-refresh + } + }); + } + + /** + * Handle incoming events for auto-refresh + */ + function handleEvent(event: RunEvent): void { + // Only refresh if attached and event is for current run + if (!isAttached.value) return; + if (event.runId !== currentRunId.value) return; + if (!STATE_REFRESH_EVENTS.has(event.type)) return; + + scheduleRefresh(); + } + + // Setup event listener if autoRefreshOnEvents is enabled + let unsubscribeEvents: (() => void) | null = null; + if (options.autoRefreshOnEvents) { + unsubscribeEvents = rpc.onEvent(handleEvent); + } + + // Cleanup on unmount + onUnmounted(() => { + unsubscribeEvents?.(); + if (refreshTimer) { + clearTimeout(refreshTimer); + } + }); + + return { + rpc, + state, + lastError, + busy, + currentRunId, + isAttached, + isPaused, + attach, + detach, + pause, + resume, + stepOver, + setBreakpoints, + addBreakpoint, + removeBreakpoint, + getState, + getVar, + setVar, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Rpc.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Rpc.ts new file mode 100644 index 0000000..ebc81fe --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Rpc.ts @@ -0,0 +1,11 @@ +/** + * @fileoverview Re-export shared useRRV3Rpc composable + * @description This file re-exports the shared composable for backward compatibility + */ + +export { + useRRV3Rpc, + type UseRRV3Rpc, + type UseRRV3RpcOptions, + type RpcRequestOptions, +} from '@/entrypoints/shared/composables/useRRV3Rpc'; diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts new file mode 100644 index 0000000..40d8851 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts @@ -0,0 +1,163 @@ +/** + * Composable for textarea auto-resize functionality. + * Automatically adjusts textarea height based on content while respecting min/max constraints. + */ +import { ref, watch, nextTick, onMounted, onUnmounted, type Ref } from 'vue'; + +export interface UseTextareaAutoResizeOptions { + /** Ref to the textarea element */ + textareaRef: Ref; + /** Ref to the textarea value (for watching changes) */ + value: Ref; + /** Minimum height in pixels */ + minHeight?: number; + /** Maximum height in pixels */ + maxHeight?: number; +} + +export interface UseTextareaAutoResizeReturn { + /** Current calculated height */ + height: Ref; + /** Whether content exceeds max height (textarea is overflowing) */ + isOverflowing: Ref; + /** Manually trigger height recalculation */ + recalculate: () => void; +} + +const DEFAULT_MIN_HEIGHT = 50; +const DEFAULT_MAX_HEIGHT = 200; + +/** + * Composable for auto-resizing textarea based on content. + * + * Features: + * - Automatically adjusts height on input + * - Respects min/max height constraints + * - Handles width changes (line wrapping affects height) + * - Uses requestAnimationFrame for performance + */ +export function useTextareaAutoResize( + options: UseTextareaAutoResizeOptions, +): UseTextareaAutoResizeReturn { + const { + textareaRef, + value, + minHeight = DEFAULT_MIN_HEIGHT, + maxHeight = DEFAULT_MAX_HEIGHT, + } = options; + + const height = ref(minHeight); + const isOverflowing = ref(false); + + let scheduled = false; + let resizeObserver: ResizeObserver | null = null; + let lastWidth = 0; + + /** + * Calculate textarea height based on content. + * Only updates the reactive `height` and `isOverflowing` refs. + * The actual DOM height is controlled via :style binding in the template. + */ + function recalculate(): void { + const el = textareaRef.value; + if (!el) return; + + // Temporarily set height to 'auto' to get accurate scrollHeight + // Save current height to minimize visual flicker + const currentHeight = el.style.height; + el.style.height = 'auto'; + + const contentHeight = el.scrollHeight; + const clampedHeight = Math.min(maxHeight, Math.max(minHeight, contentHeight)); + + // Restore height immediately (the actual height is controlled by Vue binding) + el.style.height = currentHeight; + + // Update reactive state + height.value = clampedHeight; + // Add small tolerance (1px) to account for rounding + isOverflowing.value = contentHeight > maxHeight + 1; + } + + /** + * Schedule height recalculation using requestAnimationFrame. + * Batches multiple calls within the same frame for performance. + */ + function scheduleRecalculate(): void { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + recalculate(); + }); + } + + // Watch value changes + watch( + value, + async () => { + await nextTick(); + scheduleRecalculate(); + }, + { flush: 'post' }, + ); + + // Watch textarea ref changes (in case it's replaced) + watch( + textareaRef, + async (newEl, oldEl) => { + // Cleanup old observer + if (resizeObserver && oldEl) { + resizeObserver.unobserve(oldEl); + } + + if (!newEl) return; + + await nextTick(); + scheduleRecalculate(); + + // Setup new observer for width changes + if (resizeObserver) { + lastWidth = newEl.offsetWidth; + resizeObserver.observe(newEl); + } + }, + { immediate: true }, + ); + + onMounted(() => { + const el = textareaRef.value; + if (!el) return; + + // Initial calculation + scheduleRecalculate(); + + // Setup ResizeObserver for width changes + // Width changes affect line wrapping, which affects scrollHeight + if (typeof ResizeObserver !== 'undefined') { + lastWidth = el.offsetWidth; + resizeObserver = new ResizeObserver(() => { + const current = textareaRef.value; + if (!current) return; + + const currentWidth = current.offsetWidth; + if (currentWidth !== lastWidth) { + lastWidth = currentWidth; + scheduleRecalculate(); + } + }); + resizeObserver.observe(el); + } + }); + + onUnmounted(() => { + resizeObserver?.disconnect(); + resizeObserver = null; + }); + + return { + height, + isOverflowing, + recalculate, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useWebEditorTxState.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useWebEditorTxState.ts new file mode 100644 index 0000000..120dd9d --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useWebEditorTxState.ts @@ -0,0 +1,679 @@ +/** + * Composable for managing Web Editor TX (Transaction) state in Sidepanel. + * + * Responsibilities: + * - Listen to WEB_EDITOR_TX_CHANGED messages from background + * - Persist and recover state from chrome.storage.session + * - Manage excluded element keys for selective Apply + * - Provide reactive state for AgentChat chips UI + * + * Architecture: + * - The composable should be initialized ONCE at the AgentChat.vue level + * - It is then provided via Vue's provide/inject to child components + * - This prevents duplicate event listener registration + */ +import { computed, onMounted, onUnmounted, ref, type InjectionKey } from 'vue'; +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; +import type { + ElementChangeSummary, + SelectedElementSummary, + WebEditorElementKey, + WebEditorSelectionChangedPayload, + WebEditorTxChangedPayload, + WebEditorTxChangeAction, +} from '@/common/web-editor-types'; + +// ============================================================================= +// Constants +// ============================================================================= + +const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-'; +const WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-'; +const WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-'; + +const VALID_TX_ACTIONS = new Set([ + 'push', + 'merge', + 'undo', + 'redo', + 'clear', + 'rollback', +]); + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +function isValidTabId(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function buildTxSessionKey(tabId: number): string { + return `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`; +} + +function buildExcludedKeysSessionKey(tabId: number): string { + return `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`; +} + +function buildSelectionSessionKey(tabId: number): string { + return `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`; +} + +/** + * Normalize and validate selection changed payload from storage or message. + * Returns null if the payload is invalid. + */ +function normalizeSelectionPayload(raw: unknown): WebEditorSelectionChangedPayload | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Record; + + const tabId = Number(obj.tabId); + if (!Number.isFinite(tabId) || tabId <= 0) return null; + + // Selected can be null (deselection) or an object + const selectedRaw = obj.selected; + let selected: SelectedElementSummary | null = null; + + if (selectedRaw && typeof selectedRaw === 'object') { + const sel = selectedRaw as Record; + const elementKey = typeof sel.elementKey === 'string' ? sel.elementKey.trim() : ''; + if (!elementKey) return null; // Invalid selection + + selected = { + elementKey, + locator: sel.locator as SelectedElementSummary['locator'], + label: typeof sel.label === 'string' ? sel.label : '', + fullLabel: typeof sel.fullLabel === 'string' ? sel.fullLabel : '', + tagName: typeof sel.tagName === 'string' ? sel.tagName : '', + updatedAt: typeof sel.updatedAt === 'number' ? sel.updatedAt : Date.now(), + }; + } + + return { + tabId, + selected, + pageUrl: typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined, + }; +} + +/** + * Normalize and validate TX changed payload from storage or message. + * Returns null if the payload is invalid. + */ +function normalizeTxChangedPayload(raw: unknown): WebEditorTxChangedPayload | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Record; + + const tabId = Number(obj.tabId); + if (!Number.isFinite(tabId) || tabId <= 0) return null; + + const actionRaw = typeof obj.action === 'string' ? obj.action : ''; + if (!VALID_TX_ACTIONS.has(actionRaw as WebEditorTxChangeAction)) return null; + const action = actionRaw as WebEditorTxChangeAction; + + // Filter elements to ensure minimal validity (elementKey must be a non-empty string) + const rawElements = Array.isArray(obj.elements) ? obj.elements : []; + const elements = rawElements.filter( + (e): e is ElementChangeSummary => + e && + typeof e === 'object' && + typeof (e as any).elementKey === 'string' && + (e as any).elementKey, + ); + + const undoCountRaw = Number(obj.undoCount); + const redoCountRaw = Number(obj.redoCount); + const undoCount = Number.isFinite(undoCountRaw) && undoCountRaw >= 0 ? undoCountRaw : 0; + const redoCount = Number.isFinite(redoCountRaw) && redoCountRaw >= 0 ? redoCountRaw : 0; + + const hasApplicableChanges = Boolean(obj.hasApplicableChanges); + const pageUrl = typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined; + + return { + tabId, + action, + elements, + undoCount, + redoCount, + hasApplicableChanges, + pageUrl, + }; +} + +/** + * Normalize and deduplicate excluded keys array from storage. + * Filters out invalid entries and removes duplicates. + */ +function normalizeExcludedKeys(raw: unknown): WebEditorElementKey[] { + if (!Array.isArray(raw)) return []; + + const result: WebEditorElementKey[] = []; + const seen = new Set(); + + for (const item of raw) { + const key = String(item ?? '').trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push(key); + } + + return result; +} + +/** + * Persist excluded keys to session storage (per-tab). + * Best-effort: silently ignores failures. + */ +async function persistExcludedKeys( + tabId: number, + keys: readonly WebEditorElementKey[], +): Promise { + if (!isValidTabId(tabId)) return; + + try { + if (typeof chrome === 'undefined' || !chrome.storage?.session?.set) return; + const storageKey = buildExcludedKeysSessionKey(tabId); + await chrome.storage.session.set({ [storageKey]: [...keys] }); + } catch (error) { + console.error('[useWebEditorTxState] Failed to persist excluded keys:', error); + } +} + +/** + * Default implementation for getting active tab ID. + */ +async function getActiveTabIdDefault(): Promise { + try { + if (typeof chrome === 'undefined' || !chrome.tabs?.query) return null; + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + return typeof tabId === 'number' ? tabId : null; + } catch { + return null; + } +} + +/** + * Get current window ID for filtering tab activation events. + * This prevents processing tab switches from other Chrome windows. + */ +async function getCurrentWindowId(): Promise { + try { + if (typeof chrome === 'undefined' || !chrome.windows?.getCurrent) return null; + const win = await chrome.windows.getCurrent(); + return typeof win?.id === 'number' ? win.id : null; + } catch { + return null; + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +export interface UseWebEditorTxStateOptions { + /** + * Optional override for resolving the "current tab" in sidepanel. + * Defaults to chrome.tabs.query({ active: true, currentWindow: true }). + */ + getActiveTabId?: () => Promise; + /** + * If provided, skips querying the active tab on mount. + */ + initialTabId?: number | null; +} + +export function useWebEditorTxState(options: UseWebEditorTxStateOptions = {}) { + // ========================================================================== + // State + // ========================================================================== + + /** Current tab ID being tracked */ + const tabId = ref( + isValidTabId(options.initialTabId) ? options.initialTabId : null, + ); + + /** Current TX state from web-editor */ + const txState = ref(null); + + /** Currently selected element (for context, may not have edits) */ + const selectedElement = ref(null); + + /** Page URL from selection (may differ from txState.pageUrl if selection is newer) */ + const selectionPageUrl = ref(null); + + /** Excluded element keys (user-deselected elements) */ + const excludedKeys = ref([]); + + // ========================================================================== + // Computed + // ========================================================================== + + /** All elements from TX state */ + const allElements = computed(() => txState.value?.elements ?? []); + + /** Set of excluded keys for O(1) lookup */ + const excludedKeySet = computed(() => new Set(excludedKeys.value)); + + /** Elements that will be applied (not excluded) */ + const applicableElements = computed(() => { + const set = excludedKeySet.value; + return allElements.value.filter((e) => !set.has(e.elementKey)); + }); + + /** Elements that are excluded by user */ + const excludedElements = computed(() => { + const set = excludedKeySet.value; + return allElements.value.filter((e) => set.has(e.elementKey)); + }); + + /** Whether there are applicable changes to send to Agent */ + const hasChanges = computed(() => applicableElements.value.length > 0); + + /** Whether there is a selected element */ + const hasSelection = computed(() => selectedElement.value !== null); + + /** + * Whether the selected element is also in the edits list. + * Used to decide if we need a separate "selection-only" chip. + */ + const isSelectionInEdits = computed(() => { + const sel = selectedElement.value; + if (!sel) return false; + return allElements.value.some((e) => e.elementKey === sel.elementKey); + }); + + /** Whether to show the web editor section (has edits OR has selection) */ + const hasContent = computed( + () => hasChanges.value || hasSelection.value || allElements.value.length > 0, + ); + + // ========================================================================== + // Actions + // ========================================================================== + + /** + * Toggle an element's excluded state. + * Automatically persists to session storage. + */ + function toggleExclude(elementKey: WebEditorElementKey): void { + const key = String(elementKey ?? '').trim(); + if (!key) return; + + const current = excludedKeys.value; + const idx = current.indexOf(key); + if (idx >= 0) { + // Remove from excluded list + excludedKeys.value = [...current.slice(0, idx), ...current.slice(idx + 1)]; + } else { + // Add to excluded list + excludedKeys.value = [...current, key]; + } + + // Persist to session storage + if (isValidTabId(tabId.value)) { + void persistExcludedKeys(tabId.value, excludedKeys.value); + } + } + + /** + * Clear all excluded elements. + * Automatically persists to session storage. + */ + function clearExcluded(): void { + excludedKeys.value = []; + + // Persist to session storage + if (isValidTabId(tabId.value)) { + void persistExcludedKeys(tabId.value, excludedKeys.value); + } + } + + /** + * Remove excluded keys that no longer exist in the current TX state. + * This prevents stale keys when elements are undone/cleared. + */ + function pruneStaleExcludedKeys(elements: readonly ElementChangeSummary[] | null): void { + if (!elements || !isValidTabId(tabId.value)) return; + + const validKeys = new Set(elements.map((e) => e.elementKey)); + const prunedKeys = excludedKeys.value.filter((k) => validKeys.has(k)); + + // Only update if there are stale keys to remove + if (prunedKeys.length === excludedKeys.value.length) return; + + excludedKeys.value = prunedKeys; + void persistExcludedKeys(tabId.value, prunedKeys); + } + + /** Sequence counter to prevent stale async updates */ + let refreshSeq = 0; + + /** + * Refresh TX state from session storage for a specific tab. + * Also restores excluded keys from storage. + * On tab change, immediately clears state to prevent cross-tab pollution. + */ + async function refreshFromStorage(targetTabId: number): Promise { + if (!isValidTabId(targetTabId)) { + tabId.value = null; + txState.value = null; + excludedKeys.value = []; + selectedElement.value = null; + selectionPageUrl.value = null; + return; + } + + // On tab change, immediately clear state to prevent UI showing stale data + const isTabChange = tabId.value !== targetTabId; + if (isTabChange) { + txState.value = null; + excludedKeys.value = []; + selectedElement.value = null; + selectionPageUrl.value = null; + } + tabId.value = targetTabId; + + const seq = ++refreshSeq; + const txKey = buildTxSessionKey(targetTabId); + const excludedKey = buildExcludedKeysSessionKey(targetTabId); + const selectionKey = buildSelectionSessionKey(targetTabId); + + try { + if (typeof chrome === 'undefined' || !chrome.storage?.session?.get) { + txState.value = null; + excludedKeys.value = []; + selectedElement.value = null; + selectionPageUrl.value = null; + return; + } + + // Fetch TX state, excluded keys, and selection in one call + const result = (await chrome.storage.session.get([ + txKey, + excludedKey, + selectionKey, + ])) as Record; + + // Check for stale async response + if (seq !== refreshSeq) return; + + // Update TX state + const nextTxState = normalizeTxChangedPayload(result?.[txKey]); + txState.value = nextTxState; + + // Restore excluded keys from storage + excludedKeys.value = normalizeExcludedKeys(result?.[excludedKey]); + + // Restore selection from storage + const nextSelection = normalizeSelectionPayload(result?.[selectionKey]); + selectedElement.value = nextSelection?.selected ?? null; + selectionPageUrl.value = nextSelection?.pageUrl ?? null; + + // Prune stale excluded keys based on current elements + pruneStaleExcludedKeys(nextTxState?.elements ?? null); + } catch (error) { + console.error('[useWebEditorTxState] Failed to refresh from session storage:', error); + // On error, ensure clean state to prevent showing stale data + txState.value = null; + excludedKeys.value = []; + selectedElement.value = null; + selectionPageUrl.value = null; + } + } + + // ========================================================================== + // Message Listeners + // ========================================================================== + + /** + * Handle runtime messages from background. + */ + const onRuntimeMessage = ( + message: unknown, + _sender: chrome.runtime.MessageSender, + _sendResponse: (response?: unknown) => void, + ): void => { + const msg = + message && typeof message === 'object' ? (message as Record) : null; + if (!msg) return; + + // Handle TX changed messages + if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) { + const next = normalizeTxChangedPayload(msg.payload); + if (!next) return; + + // Only process messages for the current tab + if (!isValidTabId(tabId.value)) return; + if (next.tabId !== tabId.value) return; + + txState.value = next; + + // Prune excluded keys that no longer exist (e.g., after undo/clear) + pruneStaleExcludedKeys(next.elements); + return; + } + + // Handle selection changed messages + if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) { + const next = normalizeSelectionPayload(msg.payload); + if (!next) return; + + // Only process messages for the current tab + if (!isValidTabId(tabId.value)) return; + if (next.tabId !== tabId.value) return; + + selectedElement.value = next.selected; + // Store pageUrl from selection for context building + selectionPageUrl.value = next.pageUrl ?? null; + return; + } + }; + + /** + * Handle session storage changes (fallback for cold start). + * Only handles TX state changes; excluded keys are managed explicitly. + */ + const onSessionChanged = (changes: { [key: string]: chrome.storage.StorageChange }): void => { + if (!isValidTabId(tabId.value)) return; + const txKey = buildTxSessionKey(tabId.value); + + const change = changes?.[txKey]; + if (!change) return; + + if (change.newValue === undefined) { + txState.value = null; + // Clear excluded keys when TX state is cleared + pruneStaleExcludedKeys([]); + return; + } + + const next = normalizeTxChangedPayload(change.newValue); + txState.value = next; + + // Prune stale excluded keys + pruneStaleExcludedKeys(next?.elements ?? []); + }; + + /** Cleanup function for storage listener */ + let removeStorageListener: (() => void) | null = null; + + /** Cleanup function for tab activated listener */ + let removeTabActivatedListener: (() => void) | null = null; + + /** Cached window ID to filter tab activation events from other windows */ + let currentWindowId: number | null = null; + + /** + * Handle tab activation events. + * Updates tabId and loads TX state when user switches to a different tab. + * + * Note: currentWindowId filtering is best-effort. If getCurrentWindowId() fails, + * events from all windows will be processed (acceptable fallback behavior). + */ + const onTabActivated = (activeInfo: chrome.tabs.TabActiveInfo): void => { + try { + // Ignore events from other windows (best-effort filter) + if (currentWindowId !== null && activeInfo.windowId !== currentWindowId) return; + + const nextTabId = activeInfo.tabId; + if (!isValidTabId(nextTabId)) return; + + // Skip if already tracking this tab + if (nextTabId === tabId.value) return; + + // Load TX state for the newly activated tab + void refreshFromStorage(nextTabId); + } catch (error) { + console.error('[useWebEditorTxState] Failed to handle tab activation:', error); + } + }; + + // ========================================================================== + // Lifecycle + // ========================================================================== + + onMounted(async () => { + // Register runtime message listener + try { + if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.addListener) { + chrome.runtime.onMessage.addListener(onRuntimeMessage); + } + } catch (error) { + console.error('Failed to register WebEditor TX runtime listener:', error); + } + + // Register session storage listener + try { + if (typeof chrome !== 'undefined' && chrome.storage?.session?.onChanged?.addListener) { + // Prefer session-specific listener if available + chrome.storage.session.onChanged.addListener(onSessionChanged); + removeStorageListener = () => { + try { + chrome.storage.session.onChanged.removeListener(onSessionChanged); + } catch {} + }; + } else if (typeof chrome !== 'undefined' && chrome.storage?.onChanged?.addListener) { + // Fallback to generic storage listener with area filter + const onChanged = ( + changes: { [key: string]: chrome.storage.StorageChange }, + areaName: chrome.storage.AreaName, + ) => { + if (areaName !== 'session') return; + onSessionChanged(changes); + }; + + chrome.storage.onChanged.addListener(onChanged); + removeStorageListener = () => { + try { + chrome.storage.onChanged.removeListener(onChanged); + } catch {} + }; + } + } catch (error) { + console.error('Failed to register WebEditor TX storage listener:', error); + } + + // Cache current window ID for filtering tab activation events + currentWindowId = await getCurrentWindowId(); + + // Register tab activation listener to track tab switches + try { + if (typeof chrome !== 'undefined' && chrome.tabs?.onActivated?.addListener) { + chrome.tabs.onActivated.addListener(onTabActivated); + removeTabActivatedListener = () => { + try { + chrome.tabs.onActivated.removeListener(onTabActivated); + } catch {} + }; + } + } catch (error) { + console.error('[useWebEditorTxState] Failed to register tab activation listener:', error); + } + + // Initialize tab ID if not provided + const getActiveTabId = options.getActiveTabId ?? getActiveTabIdDefault; + + if (!isValidTabId(tabId.value)) { + const active = await getActiveTabId().catch(() => null); + if (isValidTabId(active)) { + tabId.value = active; + } + } + + // Load initial state from storage + if (isValidTabId(tabId.value)) { + await refreshFromStorage(tabId.value); + } + }); + + onUnmounted(() => { + // Clean up runtime message listener + try { + if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.removeListener) { + chrome.runtime.onMessage.removeListener(onRuntimeMessage); + } + } catch {} + + // Clean up storage listener + removeStorageListener?.(); + removeStorageListener = null; + + // Clean up tab activation listener + removeTabActivatedListener?.(); + removeTabActivatedListener = null; + }); + + // ========================================================================== + // Return + // ========================================================================== + + return { + // State + tabId, + txState, + excludedKeys, + selectedElement, + selectionPageUrl, + + // UI State (computed) + allElements, + hasChanges, + hasSelection, + isSelectionInEdits, + hasContent, + applicableElements, + excludedElements, + + // Actions + toggleExclude, + clearExcluded, + refreshFromStorage, + }; +} + +// ============================================================================= +// Type Exports & Injection Key +// ============================================================================= + +/** + * Return type of useWebEditorTxState composable. + * Used for type-safe provide/inject. + */ +export type WebEditorTxStateReturn = ReturnType; + +/** + * Injection key for providing WebEditorTxState to child components. + * Use this with Vue's provide/inject pattern to avoid duplicate listener registration. + * + * @example + * // In AgentChat.vue (parent) + * const webEditorTx = useWebEditorTxState(); + * provide(WEB_EDITOR_TX_STATE_INJECTION_KEY, webEditorTx); + * + * // In WebEditorChanges.vue (child) + * const tx = inject(WEB_EDITOR_TX_STATE_INJECTION_KEY); + */ +export const WEB_EDITOR_TX_STATE_INJECTION_KEY: InjectionKey = + Symbol('web-editor-tx-state'); diff --git a/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts b/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts new file mode 100644 index 0000000..68e174b --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts @@ -0,0 +1,364 @@ +/** + * @fileoverview V3 Workflows Data Layer Composable + * @description Provides V3 workflows data management for Sidepanel UI + * + * This composable wraps the V3 RPC client and provides: + * - Flow listing, running, and deletion + * - Run listing and event subscription + * - Trigger management + * - Data mapping from V3 types to UI types + */ + +import { onMounted, onUnmounted, ref, type Ref } from 'vue'; + +import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow'; +import type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events'; +import type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers'; +import type { FlowId, RunId } from '@/entrypoints/background/record-replay-v3/domain/ids'; +import { useRRV3Rpc } from './useRRV3Rpc'; + +// ==================== UI Types ==================== + +/** Flow type for UI display (compatible with existing WorkflowsView) */ +export interface FlowLite { + id: string; + name: string; + description?: string; + meta?: { + domain?: string; + tags?: string[]; + bindings?: Array<{ + kind?: string; // V3 uses 'kind' + type?: string; // V2 uses 'type' + value: string; + }>; + }; +} + +/** Run type for UI display (compatible with existing WorkflowsView) */ +export interface RunLite { + id: string; + flowId: string; + startedAt: string; + finishedAt?: string; + /** + * Terminal success status: true=succeeded, false=failed/canceled, undefined=in progress + * UI should check `isInProgress` first to distinguish in-progress from failed + */ + success?: boolean; + /** Whether the run is still in progress (queued/running/paused) */ + isInProgress: boolean; + status: RunRecordV3['status']; + entries: unknown[]; +} + +/** Trigger type for UI display */ +export interface TriggerLite { + id: string; + type: string; // UI uses 'type', V3 uses 'kind' + kind: string; // V3 uses 'kind' + flowId: string; + enabled?: boolean; + match?: Array<{ kind: string; value: string }>; // For URL triggers + [key: string]: unknown; +} + +// ==================== Mappers ==================== + +/** Convert V3 FlowV3 to UI FlowLite */ +function mapFlowV3ToLite(flow: FlowV3): FlowLite { + return { + id: flow.id, + name: flow.name, + description: flow.description, + meta: { + tags: flow.meta?.tags, + bindings: flow.meta?.bindings?.map((b) => ({ + kind: b.kind, + type: b.kind, // For V2 compatibility + value: b.value, + })), + }, + }; +} + +/** Convert V3 RunRecordV3 to UI RunLite */ +function mapRunV3ToLite(run: RunRecordV3): RunLite { + // Determine if run is in progress + const inProgressStatuses = ['queued', 'running', 'paused']; + const isInProgress = inProgressStatuses.includes(run.status); + + // Map V3 status to success boolean for terminal states only + let success: boolean | undefined; + if (run.status === 'succeeded') success = true; + else if (run.status === 'failed' || run.status === 'canceled') success = false; + // For in-progress states, success remains undefined + + return { + id: run.id, + flowId: run.flowId, + startedAt: run.startedAt + ? new Date(run.startedAt).toISOString() + : new Date(run.createdAt).toISOString(), + finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined, + success, + isInProgress, + status: run.status, + entries: [], // V3 doesn't have entries in RunRecord, use getEvents for details + }; +} + +/** Convert V3 TriggerSpec to UI TriggerLite */ +function mapTriggerV3ToLite(trigger: TriggerSpec): TriggerLite { + return { + ...trigger, + type: trigger.kind, // Map 'kind' to 'type' for UI compatibility + kind: trigger.kind, + } as TriggerLite; +} + +// ==================== Composable ==================== + +export interface UseWorkflowsV3Options { + /** Auto-refresh interval in ms (0 = disabled) */ + autoRefreshMs?: number; + /** Auto-connect on mount */ + autoConnect?: boolean; +} + +export interface UseWorkflowsV3Return { + // Connection state + connected: Ref; + loading: Ref; + error: Ref; + + // Data + flows: Ref; + runs: Ref; + triggers: Ref; + + // Actions + refresh: () => Promise; + refreshFlows: () => Promise; + refreshRuns: () => Promise; + refreshTriggers: () => Promise; + runFlow: (flowId: string) => Promise<{ runId: string } | null>; + deleteFlow: (flowId: string) => Promise; + exportFlow: (flowId: string) => Promise; + deleteTrigger: (triggerId: string) => Promise; + + // V3-specific + getFlowById: (flowId: string) => Promise; + getRunEvents: (runId: string) => Promise; +} + +/** + * V3 Workflows data layer composable + */ +export function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflowsV3Return { + const { autoRefreshMs = 0, autoConnect = true } = options; + + // RPC client + const rpc = useRRV3Rpc({ autoConnect }); + + // State + const loading = ref(false); + const error = ref(null); + const flows = ref([]); + const runs = ref([]); + const triggers = ref([]); + + // Auto-refresh timer + let refreshTimer: ReturnType | null = null; + // Event subscription cleanup function + let eventUnsubscribe: (() => void) | null = null; + + // ==================== Actions ==================== + + async function refreshFlows(): Promise { + try { + const result = (await rpc.request('rr_v3.listFlows')) as FlowV3[] | null; + flows.value = (result || []).map(mapFlowV3ToLite); + } catch (e) { + console.warn('[useWorkflowsV3] Failed to refresh flows:', e); + error.value = e instanceof Error ? e.message : String(e); + } + } + + async function refreshRuns(): Promise { + try { + const result = (await rpc.request('rr_v3.listRuns')) as RunRecordV3[] | null; + // Sort by createdAt descending (newest first) + const sorted = (result || []).slice().sort((a, b) => b.createdAt - a.createdAt); + runs.value = sorted.map(mapRunV3ToLite); + } catch (e) { + console.warn('[useWorkflowsV3] Failed to refresh runs:', e); + error.value = e instanceof Error ? e.message : String(e); + } + } + + async function refreshTriggers(): Promise { + try { + const result = (await rpc.request('rr_v3.listTriggers')) as TriggerSpec[] | null; + triggers.value = (result || []).map(mapTriggerV3ToLite); + } catch (e) { + console.warn('[useWorkflowsV3] Failed to refresh triggers:', e); + error.value = e instanceof Error ? e.message : String(e); + } + } + + async function refresh(): Promise { + loading.value = true; + error.value = null; + try { + await Promise.all([refreshFlows(), refreshRuns(), refreshTriggers()]); + } finally { + loading.value = false; + } + } + + async function runFlow(flowId: string): Promise<{ runId: string } | null> { + try { + const result = (await rpc.request('rr_v3.enqueueRun', { + flowId: flowId as FlowId, + })) as { runId: RunId; position: number } | null; + // Refresh runs to show the new run + void refreshRuns(); + return result ? { runId: result.runId } : null; + } catch (e) { + console.warn('[useWorkflowsV3] Failed to run flow:', e); + error.value = e instanceof Error ? e.message : String(e); + return null; + } + } + + async function deleteFlow(flowId: string): Promise { + try { + await rpc.request('rr_v3.deleteFlow', { flowId: flowId as FlowId }); + // Refresh flows after deletion + void refreshFlows(); + return true; + } catch (e) { + console.warn('[useWorkflowsV3] Failed to delete flow:', e); + error.value = e instanceof Error ? e.message : String(e); + return false; + } + } + + async function exportFlow(flowId: string): Promise { + try { + const result = (await rpc.request('rr_v3.getFlow', { + flowId: flowId as FlowId, + })) as FlowV3 | null; + return result; + } catch (e) { + console.warn('[useWorkflowsV3] Failed to export flow:', e); + error.value = e instanceof Error ? e.message : String(e); + return null; + } + } + + async function deleteTrigger(triggerId: string): Promise { + try { + await rpc.request('rr_v3.deleteTrigger', { triggerId }); + // Refresh triggers after deletion + void refreshTriggers(); + return true; + } catch (e) { + console.warn('[useWorkflowsV3] Failed to delete trigger:', e); + error.value = e instanceof Error ? e.message : String(e); + return false; + } + } + + async function getFlowById(flowId: string): Promise { + try { + return (await rpc.request('rr_v3.getFlow', { + flowId: flowId as FlowId, + })) as FlowV3 | null; + } catch (e) { + console.warn('[useWorkflowsV3] Failed to get flow:', e); + return null; + } + } + + async function getRunEvents(runId: string): Promise { + try { + return (await rpc.request('rr_v3.getEvents', { + runId: runId as RunId, + })) as unknown[]; + } catch (e) { + console.warn('[useWorkflowsV3] Failed to get run events:', e); + return []; + } + } + + // ==================== Lifecycle ==================== + + onMounted(async () => { + if (autoConnect) { + await rpc.ensureConnected(); + await refresh(); + } + + // Setup auto-refresh + if (autoRefreshMs > 0) { + refreshTimer = setInterval(() => { + void refresh(); + }, autoRefreshMs); + } + + // Subscribe to all run events for real-time updates + void rpc.subscribe(null); + eventUnsubscribe = rpc.onEvent((event) => { + // Refresh runs when run status changes + const runStatusEvents = [ + 'run.queued', + 'run.started', + 'run.succeeded', + 'run.failed', + 'run.canceled', + 'run.paused', + 'run.resumed', + 'run.recovered', + ]; + if (runStatusEvents.includes(event.type)) { + void refreshRuns(); + } + }); + }); + + onUnmounted(() => { + // Cleanup auto-refresh timer + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + // Cleanup event subscription + if (eventUnsubscribe) { + eventUnsubscribe(); + eventUnsubscribe = null; + } + // Unsubscribe from run events + void rpc.unsubscribe(null); + }); + + return { + connected: rpc.connected, + loading, + error, + flows, + runs, + triggers, + refresh, + refreshFlows, + refreshRuns, + refreshTriggers, + runFlow, + deleteFlow, + exportFlow, + deleteTrigger, + getFlowById, + getRunEvents, + }; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/index.html b/app/chrome-extension/entrypoints/sidepanel/index.html new file mode 100644 index 0000000..b0adef4 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/index.html @@ -0,0 +1,13 @@ + + + + + + 工作流管理 + + + +
+ + + diff --git a/app/chrome-extension/entrypoints/sidepanel/main.ts b/app/chrome-extension/entrypoints/sidepanel/main.ts new file mode 100644 index 0000000..b61c847 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/main.ts @@ -0,0 +1,30 @@ +import { createApp } from 'vue'; +import { NativeMessageType } from 'chrome-mcp-shared'; +import App from './App.vue'; + +// Tailwind first, then custom tokens +import '../styles/tailwind.css'; +// AgentChat theme tokens +import './styles/agent-chat.css'; + +import { preloadAgentTheme } from './composables'; + +/** + * Initialize and mount the Vue app. + * Preloads theme before mounting to prevent flash. + */ +async function init(): Promise { + // Preload theme from storage and apply to document + // This happens before Vue mounts, preventing theme flash + await preloadAgentTheme(); + + // Trigger ensure native connection (fire-and-forget, don't block UI mounting) + void chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => { + // Silent failure - background will handle reconnection + }); + + // Mount Vue app + createApp(App).mount('#app'); +} + +init(); diff --git a/app/chrome-extension/entrypoints/sidepanel/styles/agent-chat.css b/app/chrome-extension/entrypoints/sidepanel/styles/agent-chat.css new file mode 100644 index 0000000..6d60eb7 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/styles/agent-chat.css @@ -0,0 +1,836 @@ +/** + * AgentChat Theme System + * + * This file defines the CSS variable tokens for the AgentChat component. + * All components must only use these tokens - never hardcode colors. + * + * Themes: + * - warm-editorial (default): Warm, editorial style from agent-ux.html + * - blueprint-architect: Blueprint grid with technical aesthetic + * - zen-journal: Calm journal / graphite accent (Muji style) + * - neo-pop: Thick borders + hard shadow (Brutalist) + * - dark-console: Dark terminal/console style + * - swiss-grid: High-contrast brutalist/swiss style + */ + +@layer base { + .agent-theme { + /* ======================================== + Font Stacks (system font fallbacks) + ======================================== */ + --ac-font-sans: + 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, + 'Apple Color Emoji', 'Segoe UI Emoji'; + --ac-font-serif: 'Newsreader', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --ac-font-mono: + 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + --ac-font-grotesk: + 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial; + + /* Semantic font tokens */ + --ac-font-body: var(--ac-font-sans); + --ac-font-heading: var(--ac-font-serif); + --ac-font-code: var(--ac-font-mono); + + /* ======================================== + Geometry (Shape & Spacing) + ======================================== */ + --ac-border-width: 1px; + --ac-border-width-strong: 2px; + --ac-radius-container: 0px; + --ac-radius-card: 12px; + --ac-radius-inner: 8px; + --ac-radius-button: 8px; + + /* Motion */ + --ac-motion-fast: 120ms; + --ac-motion-normal: 180ms; + + /* Timeline sizing */ + --ac-timeline-line-width: 1px; + --ac-timeline-node-size: 8px; + --ac-timeline-indent: 24px; + + /* Scrollbar sizing */ + --ac-scrollbar-size: 4px; + + /* ======================================== + WARM EDITORIAL (Default Theme) + ======================================== */ + + /* Background */ + --ac-bg: #fdfcf8; + --ac-bg-pattern: none; + --ac-bg-pattern-size: 16px 16px; + + /* Header */ + --ac-header-bg: rgba(253, 252, 248, 0.95); + --ac-header-border: rgba(245, 245, 244, 0.5); + + /* Surfaces */ + --ac-surface: #ffffff; + --ac-surface-muted: #f2f0eb; + --ac-surface-inset: #f2f0eb; + + /* Text */ + --ac-text: #1a1a1a; + --ac-text-muted: #6e6e6e; + --ac-text-subtle: #a8a29e; + --ac-text-inverse: #ffffff; + --ac-text-placeholder: #a8a29e; + + /* Borders */ + --ac-border: #e7e5e4; + --ac-border-strong: #d6d3d1; + + /* Hover states */ + --ac-hover-bg: #f5f5f4; + --ac-hover-bg-subtle: #fafaf9; + + /* Accent (terracotta) */ + --ac-accent: #d97757; + --ac-accent-hover: #c4664a; + --ac-accent-subtle: rgba(217, 119, 87, 0.12); + --ac-accent-contrast: #ffffff; + --ac-accent-2: var(--ac-accent); + + /* Links */ + --ac-link: var(--ac-accent); + --ac-link-hover: var(--ac-accent-hover); + + /* Selection */ + --ac-selection-bg: #ffedd5; + --ac-selection-text: #7c2d12; + + /* Shadows */ + --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); + --ac-shadow-float: 0 4px 20px -2px rgba(0, 0, 0, 0.05); + + /* Focus */ + --ac-focus-ring: rgba(214, 211, 209, 0.9); + + /* Timeline */ + --ac-timeline-line: #e7e5e4; + --ac-timeline-node: #d6d3d1; + --ac-timeline-node-hover: #a8a29e; + --ac-timeline-node-active: var(--ac-accent); + --ac-timeline-node-active-border: var(--ac-accent); + --ac-timeline-node-tool: #94a3b8; + --ac-timeline-node-pulse-shadow: + 0 0 0 2px rgba(217, 119, 87, 0.25), 0 0 12px rgba(217, 119, 87, 0.2); + + /* Chips/Pills */ + --ac-chip-bg: #f2f0eb; + --ac-chip-text: #1a1a1a; + --ac-chip-border: #e7e5e4; + + /* Code blocks */ + --ac-code-bg: #ffffff; + --ac-code-text: #1a1a1a; + --ac-code-border: #e7e5e4; + + /* Diff colors */ + --ac-diff-add-bg: rgba(240, 253, 244, 0.6); + --ac-diff-add-text: #15803d; + --ac-diff-add-border: #4ade80; + --ac-diff-del-bg: rgba(254, 242, 242, 0.6); + --ac-diff-del-text: #b91c1c; + --ac-diff-del-border: #fca5a5; + + /* Status colors */ + --ac-success: #22c55e; + --ac-warning: #f59e0b; + --ac-danger: #ef4444; + + /* Scrollbar */ + --ac-scrollbar-thumb: #e5e5e5; + --ac-scrollbar-thumb-hover: #d4d4d4; + } + + /* ======================================== + WARM EDITORIAL (Explicit for clarity) + ======================================== */ + .agent-theme[data-agent-theme='warm-editorial'] { + --ac-font-body: var(--ac-font-sans); + --ac-font-heading: var(--ac-font-serif); + --ac-font-code: var(--ac-font-mono); + + --ac-bg: #fdfcf8; + --ac-bg-pattern: none; + --ac-header-bg: rgba(253, 252, 248, 0.95); + --ac-header-border: rgba(245, 245, 244, 0.5); + --ac-surface: #ffffff; + --ac-surface-muted: #f2f0eb; + --ac-text: #1a1a1a; + --ac-text-muted: #6e6e6e; + --ac-text-subtle: #a8a29e; + --ac-border: #e7e5e4; + --ac-accent: #d97757; + --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); + --ac-radius-card: 12px; + --ac-border-width: 1px; + } + + /* ======================================== + BLUEPRINT ARCHITECT + ======================================== */ + .agent-theme[data-agent-theme='blueprint-architect'] { + --ac-font-body: var(--ac-font-grotesk); + --ac-font-heading: var(--ac-font-grotesk); + --ac-font-code: var(--ac-font-mono); + + --ac-bg: #f7fbff; + --ac-bg-pattern: + linear-gradient(to right, rgba(37, 99, 235, 0.14) 1px, transparent 1px), + linear-gradient(to bottom, rgba(37, 99, 235, 0.14) 1px, transparent 1px); + --ac-bg-pattern-size: 24px 24px; + + --ac-header-bg: rgba(247, 251, 255, 0.86); + --ac-header-border: rgba(37, 99, 235, 0.25); + + --ac-surface: rgba(255, 255, 255, 0.92); + --ac-surface-muted: rgba(239, 246, 255, 0.9); + --ac-surface-inset: rgba(239, 246, 255, 0.9); + + --ac-text: #0b1220; + --ac-text-muted: #1f2a44; + --ac-text-subtle: #475569; + --ac-text-inverse: #ffffff; + --ac-text-placeholder: #64748b; + + --ac-border: rgba(37, 99, 235, 0.25); + --ac-border-strong: rgba(37, 99, 235, 0.45); + + --ac-hover-bg: rgba(37, 99, 235, 0.08); + --ac-hover-bg-subtle: rgba(37, 99, 235, 0.05); + + --ac-accent: #2563eb; + --ac-accent-hover: #1d4ed8; + --ac-accent-subtle: rgba(37, 99, 235, 0.12); + --ac-accent-contrast: #ffffff; + + --ac-accent-2: #0ea5e9; + --ac-link: var(--ac-accent); + --ac-link-hover: var(--ac-accent-hover); + + --ac-selection-bg: rgba(37, 99, 235, 0.16); + --ac-selection-text: #0b1220; + + --ac-shadow-card: 0 1px 3px rgba(2, 6, 23, 0.12); + --ac-shadow-float: 0 10px 28px -10px rgba(2, 6, 23, 0.22); + --ac-focus-ring: rgba(37, 99, 235, 0.4); + + --ac-timeline-line: rgba(37, 99, 235, 0.35); + --ac-timeline-node: rgba(37, 99, 235, 0.35); + --ac-timeline-node-hover: rgba(37, 99, 235, 0.55); + --ac-timeline-node-active: var(--ac-accent); + --ac-timeline-node-active-border: var(--ac-accent); + --ac-timeline-node-tool: rgba(37, 99, 235, 0.5); + --ac-timeline-node-pulse-shadow: + 0 0 0 2px rgba(37, 99, 235, 0.25), 0 0 12px rgba(37, 99, 235, 0.2); + + --ac-chip-bg: rgba(239, 246, 255, 0.9); + --ac-chip-text: #0b1220; + --ac-chip-border: rgba(37, 99, 235, 0.25); + + --ac-code-bg: rgba(255, 255, 255, 0.92); + --ac-code-text: #0b1220; + --ac-code-border: rgba(37, 99, 235, 0.25); + + --ac-diff-add-bg: rgba(34, 197, 94, 0.12); + --ac-diff-add-text: #15803d; + --ac-diff-add-border: rgba(34, 197, 94, 0.4); + --ac-diff-del-bg: rgba(239, 68, 68, 0.12); + --ac-diff-del-text: #b91c1c; + --ac-diff-del-border: rgba(239, 68, 68, 0.4); + + --ac-scrollbar-thumb: rgba(37, 99, 235, 0.2); + --ac-scrollbar-thumb-hover: rgba(37, 99, 235, 0.35); + } + + /* ======================================== + ZEN JOURNAL + ======================================== */ + .agent-theme[data-agent-theme='zen-journal'] { + --ac-font-body: var(--ac-font-serif); + --ac-font-heading: var(--ac-font-serif); + --ac-font-code: var(--ac-font-mono); + + --ac-bg: #fafaf9; + --ac-bg-pattern: linear-gradient(to bottom, rgba(120, 113, 108, 0.07) 1px, transparent 1px); + --ac-bg-pattern-size: 100% 28px; + + --ac-header-bg: rgba(250, 250, 249, 0.92); + --ac-header-border: rgba(231, 229, 228, 0.9); + + --ac-surface: rgba(255, 255, 255, 0.92); + --ac-surface-muted: rgba(245, 245, 244, 0.92); + --ac-surface-inset: rgba(245, 245, 244, 0.92); + + --ac-text: #1c1917; + --ac-text-muted: #44403c; + --ac-text-subtle: #78716c; + --ac-text-inverse: #ffffff; + --ac-text-placeholder: #a8a29e; + + --ac-border: #e7e5e4; + --ac-border-strong: #d6d3d1; + + --ac-hover-bg: rgba(120, 113, 108, 0.08); + --ac-hover-bg-subtle: rgba(120, 113, 108, 0.05); + + --ac-accent: #57534e; + --ac-accent-hover: #44403c; + --ac-accent-subtle: rgba(87, 83, 78, 0.12); + --ac-accent-contrast: #ffffff; + --ac-accent-2: var(--ac-accent); + + --ac-link: var(--ac-accent); + --ac-link-hover: var(--ac-accent-hover); + + --ac-selection-bg: rgba(87, 83, 78, 0.18); + --ac-selection-text: #1c1917; + + --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + --ac-shadow-float: 0 14px 34px -18px rgba(0, 0, 0, 0.18); + --ac-focus-ring: rgba(87, 83, 78, 0.35); + + --ac-timeline-line: #e7e5e4; + --ac-timeline-node: #d6d3d1; + --ac-timeline-node-hover: #a8a29e; + --ac-timeline-node-active: var(--ac-accent); + --ac-timeline-node-active-border: var(--ac-accent); + --ac-timeline-node-tool: #94a3b8; + --ac-timeline-node-pulse-shadow: + 0 0 0 2px rgba(87, 83, 78, 0.25), 0 0 12px rgba(87, 83, 78, 0.2); + + --ac-chip-bg: rgba(245, 245, 244, 0.92); + --ac-chip-text: #1c1917; + --ac-chip-border: #e7e5e4; + + --ac-code-bg: rgba(255, 255, 255, 0.92); + --ac-code-text: #1c1917; + --ac-code-border: #e7e5e4; + + --ac-diff-add-bg: rgba(34, 197, 94, 0.1); + --ac-diff-add-text: #15803d; + --ac-diff-add-border: rgba(34, 197, 94, 0.35); + --ac-diff-del-bg: rgba(239, 68, 68, 0.1); + --ac-diff-del-text: #b91c1c; + --ac-diff-del-border: rgba(239, 68, 68, 0.35); + + --ac-scrollbar-thumb: rgba(120, 113, 108, 0.15); + --ac-scrollbar-thumb-hover: rgba(120, 113, 108, 0.25); + } + + /* ======================================== + NEO POP + ======================================== */ + .agent-theme[data-agent-theme='neo-pop'] { + --ac-font-body: var(--ac-font-sans); + --ac-font-heading: var(--ac-font-grotesk); + --ac-font-code: var(--ac-font-mono); + + --ac-border-width: 4px; + --ac-border-width-strong: 4px; + --ac-radius-card: 0px; + --ac-radius-inner: 0px; + --ac-radius-button: 0px; + + --ac-bg: #fff7ed; + --ac-bg-pattern: radial-gradient(rgba(17, 24, 39, 0.12) 1px, transparent 1px); + --ac-bg-pattern-size: 18px 18px; + + --ac-header-bg: rgba(255, 247, 237, 0.92); + --ac-header-border: #111827; + + --ac-surface: #ffffff; + --ac-surface-muted: #ffedd5; + --ac-surface-inset: #ffffff; + + --ac-text: #111827; + --ac-text-muted: #374151; + --ac-text-subtle: #6b7280; + --ac-text-inverse: #ffffff; + --ac-text-placeholder: #9ca3af; + + --ac-border: #111827; + --ac-border-strong: #111827; + + --ac-hover-bg: rgba(17, 24, 39, 0.06); + --ac-hover-bg-subtle: rgba(17, 24, 39, 0.04); + + --ac-accent: #ff3d7f; + --ac-accent-hover: #ff1f6a; + --ac-accent-subtle: rgba(255, 61, 127, 0.14); + --ac-accent-contrast: #ffffff; + + --ac-accent-2: #22d3ee; + --ac-link: var(--ac-accent-2); + --ac-link-hover: #06b6d4; + + --ac-selection-bg: rgba(255, 61, 127, 0.25); + --ac-selection-text: #111827; + + --ac-shadow-card: 6px 6px 0 0 var(--ac-border); + --ac-shadow-float: 8px 8px 0 0 var(--ac-border); + --ac-focus-ring: rgba(17, 24, 39, 0.35); + + --ac-timeline-line-width: 4px; + --ac-timeline-line: #111827; + --ac-timeline-node: #111827; + --ac-timeline-node-hover: #374151; + --ac-timeline-node-active: var(--ac-accent); + --ac-timeline-node-active-border: #111827; + --ac-timeline-node-tool: #6b7280; + --ac-timeline-node-pulse-shadow: 0 0 0 2px rgba(17, 24, 39, 1); + + --ac-chip-bg: #ffffff; + --ac-chip-text: #111827; + --ac-chip-border: #111827; + + --ac-code-bg: #ffffff; + --ac-code-text: #111827; + --ac-code-border: #111827; + + --ac-diff-add-bg: rgba(34, 197, 94, 0.18); + --ac-diff-add-text: #15803d; + --ac-diff-add-border: #111827; + --ac-diff-del-bg: rgba(239, 68, 68, 0.18); + --ac-diff-del-text: #b91c1c; + --ac-diff-del-border: #111827; + + --ac-scrollbar-thumb: rgba(17, 24, 39, 0.25); + --ac-scrollbar-thumb-hover: rgba(17, 24, 39, 0.4); + } + + /* ======================================== + DARK CONSOLE + ======================================== */ + .agent-theme[data-agent-theme='dark-console'] { + --ac-font-body: var(--ac-font-mono); + --ac-font-heading: var(--ac-font-mono); + --ac-font-code: var(--ac-font-mono); + + --ac-bg: #0f1117; + --ac-bg-pattern: none; + --ac-bg-pattern-size: 16px 16px; + + --ac-header-bg: #0f1117; + --ac-header-border: #1f2937; + + --ac-surface: #0f1117; + --ac-surface-muted: #0a0c10; + --ac-surface-inset: #1a1d26; + + --ac-text: #e5e7eb; + --ac-text-muted: #9ca3af; + --ac-text-subtle: #6b7280; + --ac-text-inverse: #0a0c10; + --ac-text-placeholder: #4b5563; + + --ac-border: #1f2937; + --ac-border-strong: #374151; + + --ac-hover-bg: rgba(255, 255, 255, 0.06); + --ac-hover-bg-subtle: rgba(255, 255, 255, 0.04); + + --ac-accent: #c084fc; + --ac-accent-hover: #d8b4fe; + --ac-accent-subtle: rgba(192, 132, 252, 0.14); + --ac-accent-contrast: #0a0c10; + + --ac-accent-2: #60a5fa; + --ac-link: var(--ac-accent-2); + --ac-link-hover: #93c5fd; + + --ac-selection-bg: rgba(192, 132, 252, 0.25); + --ac-selection-text: #ffffff; + + --ac-shadow-card: none; + --ac-shadow-float: none; + --ac-focus-ring: rgba(192, 132, 252, 0.35); + + --ac-timeline-line-width: 2px; + --ac-timeline-line: #1f2937; + --ac-timeline-node: #374151; + --ac-timeline-node-hover: #6b7280; + --ac-timeline-node-active: var(--ac-accent); + --ac-timeline-node-active-border: var(--ac-accent); + --ac-timeline-node-tool: #64748b; + --ac-timeline-node-pulse-shadow: + 0 0 0 2px rgba(192, 132, 252, 0.35), 0 0 14px rgba(192, 132, 252, 0.25); + + --ac-chip-bg: rgba(255, 255, 255, 0.06); + --ac-chip-text: #e5e7eb; + --ac-chip-border: rgba(255, 255, 255, 0.1); + + --ac-code-bg: #0a0c10; + --ac-code-text: #e5e7eb; + --ac-code-border: #1f2937; + + --ac-diff-add-bg: rgba(74, 222, 128, 0.1); + --ac-diff-add-text: #4ade80; + --ac-diff-add-border: rgba(74, 222, 128, 0.35); + + --ac-diff-del-bg: rgba(248, 113, 113, 0.1); + --ac-diff-del-text: #f87171; + --ac-diff-del-border: rgba(248, 113, 113, 0.35); + + --ac-scrollbar-thumb: rgba(255, 255, 255, 0.12); + --ac-scrollbar-thumb-hover: rgba(255, 255, 255, 0.22); + } + + /* ======================================== + SWISS GRID + ======================================== */ + .agent-theme[data-agent-theme='swiss-grid'] { + --ac-font-body: var(--ac-font-grotesk); + --ac-font-heading: var(--ac-font-grotesk); + --ac-font-code: var(--ac-font-mono); + + --ac-bg: #ffffff; + --ac-bg-pattern: radial-gradient(#e5e7eb 1px, transparent 1px); + --ac-bg-pattern-size: 16px 16px; + + --ac-header-bg: #ffffff; + --ac-header-border: #000000; + + --ac-surface: #ffffff; + --ac-surface-muted: #f3f4f6; + --ac-surface-inset: #ffffff; + + --ac-text: #000000; + --ac-text-muted: #374151; + --ac-text-subtle: #6b7280; + --ac-text-inverse: #ffffff; + --ac-text-placeholder: #9ca3af; + + --ac-border: #000000; + --ac-border-strong: #000000; + + --ac-hover-bg: #f3f4f6; + --ac-hover-bg-subtle: #f9fafb; + + --ac-accent: #000000; + --ac-accent-hover: #111827; + --ac-accent-subtle: rgba(0, 0, 0, 0.06); + --ac-accent-contrast: #ffffff; + --ac-accent-2: #000000; + + --ac-link: #000000; + --ac-link-hover: #111827; + + --ac-selection-bg: #000000; + --ac-selection-text: #ffffff; + + --ac-border-width: 2px; + --ac-border-width-strong: 2px; + --ac-radius-card: 0px; + --ac-radius-inner: 0px; + --ac-radius-button: 0px; + + --ac-shadow-card: 4px 4px 0 0 rgba(0, 0, 0, 1); + --ac-shadow-float: 4px 4px 0 0 rgba(0, 0, 0, 1); + --ac-focus-ring: rgba(0, 0, 0, 0.5); + + --ac-timeline-line-width: 2px; + --ac-timeline-line: #000000; + --ac-timeline-node: #000000; + --ac-timeline-node-hover: #111827; + --ac-timeline-node-active: #000000; + --ac-timeline-node-active-border: #000000; + --ac-timeline-node-tool: #4b5563; + --ac-timeline-node-pulse-shadow: 0 0 0 2px rgba(0, 0, 0, 1); + + --ac-chip-bg: #ffffff; + --ac-chip-text: #000000; + --ac-chip-border: #000000; + + --ac-code-bg: #ffffff; + --ac-code-text: #000000; + --ac-code-border: #000000; + + --ac-diff-add-bg: #000000; + --ac-diff-add-text: #ffffff; + --ac-diff-add-border: #000000; + + --ac-diff-del-bg: #f3f4f6; + --ac-diff-del-text: #6b7280; + --ac-diff-del-border: #000000; + + --ac-scrollbar-thumb: rgba(0, 0, 0, 0.25); + --ac-scrollbar-thumb-hover: rgba(0, 0, 0, 0.4); + } + + /* ======================================== + Base Styles (apply to .agent-theme) + ======================================== */ + .agent-theme { + color: var(--ac-text); + background: var(--ac-bg); + background-image: var(--ac-bg-pattern); + background-size: var(--ac-bg-pattern-size); + font-family: var(--ac-font-body); + } + + .agent-theme ::selection { + background: var(--ac-selection-bg); + color: var(--ac-selection-text); + } + + /* ======================================== + Scrollbar Styles + ======================================== */ + .agent-theme .ac-scroll { + scrollbar-width: thin; + scrollbar-color: var(--ac-scrollbar-thumb) transparent; + } + + .agent-theme .ac-scroll::-webkit-scrollbar { + width: var(--ac-scrollbar-size); + height: var(--ac-scrollbar-size); + } + + .agent-theme .ac-scroll::-webkit-scrollbar-track { + background: transparent; + } + + .agent-theme .ac-scroll::-webkit-scrollbar-thumb { + background-color: var(--ac-scrollbar-thumb); + border-radius: 999px; + } + + .agent-theme .ac-scroll::-webkit-scrollbar-thumb:hover { + background-color: var(--ac-scrollbar-thumb-hover); + } + + /* Hide scrollbar but keep functionality */ + .agent-theme .ac-scroll-hidden { + scrollbar-width: none; + -ms-overflow-style: none; + } + + .agent-theme .ac-scroll-hidden::-webkit-scrollbar { + display: none; + } + + /* ======================================== + Utility Classes + ======================================== */ + + /* Focus ring */ + .agent-theme .ac-focus-ring:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--ac-focus-ring); + } + + /* Hover utilities */ + .agent-theme .ac-hover-bg:hover { + background-color: var(--ac-hover-bg); + } + + .agent-theme .ac-hover-text:hover { + color: var(--ac-text); + } + + .agent-theme .ac-hover-link:hover { + color: var(--ac-link); + } + + .agent-theme .ac-hover-accent:hover { + color: var(--ac-accent); + } + + /* Button/interactive element base */ + .agent-theme .ac-btn { + cursor: pointer; + transition: + background-color var(--ac-motion-fast), + color var(--ac-motion-fast); + } + + .agent-theme .ac-btn:hover { + background-color: var(--ac-hover-bg); + } + + /* Menu item */ + .agent-theme .ac-menu-item { + cursor: pointer; + transition: background-color var(--ac-motion-fast); + } + + .agent-theme .ac-menu-item:hover { + background-color: var(--ac-hover-bg); + } + + /* Chip/pill hover */ + .agent-theme .ac-chip-hover:hover { + color: var(--ac-link); + } + + /* Pulse animation for streaming indicator */ + @keyframes ac-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + + .agent-theme .ac-pulse { + animation: ac-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + + /* Respect reduced motion preference */ + @media (prefers-reduced-motion: reduce) { + .agent-theme .ac-pulse { + animation: none; + } + } + + /* ============================================================ + Loading Animation - Shimmer Text & Scribble Icon + ============================================================ */ + + /* 文案 shimmer 渐变动画 - 光从左到右扫过效果 */ + .agent-theme .text-shimmer { + display: inline-block; + background: linear-gradient( + 90deg, + var(--ac-accent, #d97757) 0%, + var(--ac-accent, #d97757) 40%, + #ffe0d0 50%, + var(--ac-accent, #d97757) 60%, + var(--ac-accent, #d97757) 100% + ); + background-size: 250% 100%; + background-repeat: no-repeat; + color: transparent; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: ac-shimmer 1.8s ease-in-out infinite; + } + + @keyframes ac-shimmer { + 0% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + } + + /* 螺旋图标 - 笔迹重绘动画 */ + .agent-theme .loading-scribble path { + stroke-dasharray: 300; + stroke-dashoffset: 300; + animation: ac-scribble-draw 2s ease-in-out infinite; + } + + .agent-theme .loading-scribble { + animation: ac-slight-rotate 8s linear infinite; + } + + @keyframes ac-scribble-draw { + 0% { + stroke-dashoffset: 300; + } + 50% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -300; + } + } + + @keyframes ac-slight-rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + /* Respect reduced motion preference for loading animations */ + @media (prefers-reduced-motion: reduce) { + .agent-theme .text-shimmer { + animation: none; + background: none; + color: var(--ac-accent); + } + + .agent-theme .loading-scribble, + .agent-theme .loading-scribble path { + animation: none; + } + + .agent-theme .loading-scribble path { + stroke-dashoffset: 0; + } + } + + /* ============================================================ + Tooltip System - CSS-only tooltips using data-tooltip attribute + ============================================================ */ + .agent-theme [data-tooltip] { + position: relative; + } + + .agent-theme [data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + font-size: 11px; + font-family: var(--ac-font-sans); + font-weight: 400; + line-height: 1.3; + white-space: nowrap; + color: var(--ac-text-inverse); + background-color: var(--ac-text); + border-radius: var(--ac-radius-button); + opacity: 0; + visibility: hidden; + transition: + opacity 150ms ease, + visibility 150ms ease; + pointer-events: none; + z-index: 99999; + } + + .agent-theme [data-tooltip]:hover::after { + opacity: 1; + visibility: visible; + } + + /* Tooltip arrow */ + .agent-theme [data-tooltip]::before { + content: ''; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: var(--ac-text); + opacity: 0; + visibility: hidden; + transition: + opacity 150ms ease, + visibility 150ms ease; + pointer-events: none; + z-index: 99999; + } + + .agent-theme [data-tooltip]:hover::before { + opacity: 1; + visibility: visible; + } +} diff --git a/app/chrome-extension/entrypoints/sidepanel/utils/loading-texts.ts b/app/chrome-extension/entrypoints/sidepanel/utils/loading-texts.ts new file mode 100644 index 0000000..4b6c0d2 --- /dev/null +++ b/app/chrome-extension/entrypoints/sidepanel/utils/loading-texts.ts @@ -0,0 +1,60 @@ +/** + * 随机 Loading 文案 + * 用于 TimelineStatusStep 组件展示趣味等待提示 + */ + +const loadingTexts = [ + // 必选神梗 + '本来应该从从容容游刃有余', + '现在是匆匆忙忙连滚带爬', + '我知道你很急,但是先别急', + '在知识的海洋里狗刨', + '让子弹再飞一会儿', + '正在为您手搓答案', + '浪浪山小妖怪集结中', + '别催,已经在写了(新建文件夹)', + '正在汗流浃背地思考中', + 'CPU 都要给我干烧了', + // 生活气息 + '村咖慢焙,精华需要时间', + '知识煎饼翻面中', + '敬自己一杯,马上好', + '正在把灵感放入烤箱', + '让答案再泡一会儿', + '情绪价值拉满中', + '正在为您编织语言的毛衣', + // 脑洞大开 + '神经元蹦迪中', + '熬夜的猫头鹰在思考', + '给答案上色中', + '正在疯狂翻阅知识库', + '大脑马戏团开演', + '正在把 0 和 1 捏在一起', + '正在憋个大招', + '放大镜有点起雾,擦擦', + '试图理解这个离谱的需求', + // 玄幻 + '正在施法,莫打扰', + '唤醒硅基朋友', + '正在连接赛博空间的智慧', + '道友请留步,正在推演', + '穿越知识黑洞', + '正在反向解析人类意图', + '水晶球有点模糊,拍两下', + // 职场 + '代码跑得比记者还快', + '主理人已上线,请稍候', + '快马加鞭赶来中', + '正在光速搬运知识', + '拼图最后一块', + '答案即将杀青', + '发射倒计时', + '目标锁定中', +]; + +/** + * 获取随机 Loading 文案 + */ +export function getRandomLoadingText(): string { + return loadingTexts[Math.floor(Math.random() * loadingTexts.length)]; +} diff --git a/app/chrome-extension/entrypoints/styles/tailwind.css b/app/chrome-extension/entrypoints/styles/tailwind.css new file mode 100644 index 0000000..7ddba5c --- /dev/null +++ b/app/chrome-extension/entrypoints/styles/tailwind.css @@ -0,0 +1,151 @@ +@import 'tailwindcss'; + +/* App background and card helpers */ +@layer base { + html, + body, + #app { + height: 100%; + } + body { + @apply bg-slate-50 text-slate-800; + } + + /* Record&Replay builder design tokens */ + .rr-theme { + --rr-bg: #f8fafc; + --rr-topbar: rgba(255, 255, 255, 0.9); + --rr-card: #ffffff; + --rr-elevated: #ffffff; + --rr-border: #e5e7eb; + --rr-subtle: #f3f4f6; + --rr-text: #0f172a; + --rr-text-weak: #475569; + --rr-muted: #64748b; + --rr-brand: #7c3aed; + --rr-brand-strong: #5b21b6; + --rr-accent: #0ea5e9; + --rr-success: #10b981; + --rr-warn: #f59e0b; + --rr-danger: #ef4444; + --rr-dot: rgba(2, 6, 23, 0.08); + } + .rr-theme[data-theme='dark'] { + --rr-bg: #0b1020; + --rr-topbar: rgba(12, 15, 24, 0.8); + --rr-card: #0f1528; + --rr-elevated: #121a33; + --rr-border: rgba(255, 255, 255, 0.08); + --rr-subtle: rgba(255, 255, 255, 0.04); + --rr-text: #e5e7eb; + --rr-text-weak: #cbd5e1; + --rr-muted: #94a3b8; + --rr-brand: #a78bfa; + --rr-brand-strong: #7c3aed; + --rr-accent: #38bdf8; + --rr-success: #34d399; + --rr-warn: #fbbf24; + --rr-danger: #f87171; + --rr-dot: rgba(226, 232, 240, 0.08); + } +} + +@layer components { + .card { + @apply rounded-xl shadow-md border; + background: var(--rr-card); + border-color: var(--rr-border); + } + /* Generic buttons used across builder */ + .btn { + @apply inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition; + background: var(--rr-card); + color: var(--rr-text); + border: 1px solid var(--rr-border); + } + .btn:hover { + @apply shadow-sm; + background: var(--rr-subtle); + } + .btn[disabled] { + @apply opacity-60 cursor-not-allowed; + } + .btn.primary { + color: #fff; + background: var(--rr-brand-strong); + border-color: var(--rr-brand-strong); + } + .btn.primary:hover { + filter: brightness(1.05); + } + .btn.ghost { + background: transparent; + border-color: transparent; + } + + .mini { + @apply inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium; + background: var(--rr-card); + color: var(--rr-text); + border: 1px solid var(--rr-border); + } + .mini:hover { + background: var(--rr-subtle); + } + .mini.danger { + background: color-mix(in oklab, var(--rr-danger) 8%, transparent); + border-color: color-mix(in oklab, var(--rr-danger) 24%, var(--rr-border)); + color: var(--rr-text); + } + + .input { + @apply w-full px-3 py-2 rounded-lg text-sm; + background: var(--rr-card); + color: var(--rr-text); + border: 1px solid var(--rr-border); + outline: none; + } + .input:focus { + box-shadow: 0 0 0 3px color-mix(in oklab, var(--rr-brand) 26%, transparent); + border-color: var(--rr-brand); + } + .select { + @apply w-full px-3 py-2 rounded-lg text-sm; + background: var(--rr-card); + color: var(--rr-text); + border: 1px solid var(--rr-border); + outline: none; + } + .textarea { + @apply w-full rounded-lg text-sm; + padding: 10px 12px; + background: var(--rr-card); + color: var(--rr-text); + border: 1px solid var(--rr-border); + outline: none; + } + .label { + @apply text-sm; + color: var(--rr-muted); + } + .badge { + @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium; + } + .badge-purple { + background: color-mix(in oklab, var(--rr-brand) 14%, transparent); + color: var(--rr-brand); + } + + /* Builder topbar */ + .rr-topbar { + height: 56px; + border-bottom: 1px solid var(--rr-border); + background: var(--rr-topbar); + } + + /* Dot grid background utility for canvas container */ + .rr-dot-grid { + background-image: radial-gradient(var(--rr-dot) 1px, transparent 1px); + background-size: 20px 20px; + } +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2.ts b/app/chrome-extension/entrypoints/web-editor-v2.ts new file mode 100644 index 0000000..4d1757d --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2.ts @@ -0,0 +1,47 @@ +/** + * Web Editor V2 - Inject Script Entry Point + * + * This is the main entry point for the visual editor, injected into web pages + * via chrome.scripting.executeScript from the background script. + * + * Architecture: + * - Uses WXT's defineUnlistedScript for TypeScript compilation + * - Exposes API on window.__MCP_WEB_EDITOR_V2__ + * - Communicates with background via chrome.runtime.onMessage + * + * Module structure: + * - web-editor-v2/constants.ts - Configuration values + * - web-editor-v2/utils/disposables.ts - Resource cleanup + * - web-editor-v2/ui/shadow-host.ts - Shadow DOM isolation + * - web-editor-v2/core/editor.ts - Main orchestrator + * - web-editor-v2/core/message-listener.ts - Background communication + * + * Build output: .output/chrome-mv3/web-editor-v2.js + */ + +import { WEB_EDITOR_V2_LOG_PREFIX } from './web-editor-v2/constants'; +import { createWebEditorV2 } from './web-editor-v2/core/editor'; +import { installMessageListener } from './web-editor-v2/core/message-listener'; + +export default defineUnlistedScript(() => { + // Phase 1: Only support top frame + // Phase 4 will add iframe support via content injection + if (window !== window.top) { + return; + } + + // Singleton guard: prevent multiple instances + if (window.__MCP_WEB_EDITOR_V2__) { + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Already installed, skipping initialization`); + return; + } + + // Create and expose the API + const api = createWebEditorV2(); + window.__MCP_WEB_EDITOR_V2__ = api; + + // Install message listener for background communication + installMessageListener(api); + + console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Installed successfully`); +}); diff --git a/app/chrome-extension/entrypoints/web-editor-v2/attr-ui-refactor.md b/app/chrome-extension/entrypoints/web-editor-v2/attr-ui-refactor.md new file mode 100644 index 0000000..6f7a79a --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/attr-ui-refactor.md @@ -0,0 +1,383 @@ +# Property Panel UI 重构计划 + +## 背景 + +当前属性面板的 UI 实现与设计稿 `attr-ui.html` 存在较大差异。本文档详细规划了重构任务,按照优先级从高到低排列,目标是让属性面板的视觉效果和交互体验与设计稿一致。 + +### 参考文件 + +- **设计稿**:`/attr-ui.html` +- **当前样式**:`ui/shadow-host.ts` +- **面板结构**:`ui/property-panel/property-panel.ts` +- **控件组件**:`ui/property-panel/controls/*.ts` + +--- + +## 前置任务(已完成) + +### 0.1 最小化 Bug 修复 ✅ + +**问题**:toolbar 和属性面板最小化时,只是背景消失了,里面的内容实际上还在 + +**根因**:CSS 中 `display: flex/inline-flex` 覆盖了 `[hidden]` 属性的默认 `display: none` + +**解决方案**: + +- [x] 在 `shadow-host.ts` 末尾添加全局 `[hidden] { display: none !important; }` 规则 + +### 0.2 输入框优化 ✅ + +**问题**: + +1. 输入框显示 placeholder 而非真实值 +2. Number 类型输入框不支持键盘上下键调整 + +**解决方案**: + +- [x] 创建 `ui/property-panel/controls/number-stepping.ts` 工具模块 + - 支持 ArrowUp/ArrowDown 键盘步进 + - 支持 Shift (10x)、Alt (0.1x) 修饰键 + - 支持多种 CSS 单位 (px, %, rem, em, vh, vw, vmin, vmax) +- [x] 修改所有 control 显示真实值(inline 优先,fallback 到 computed) +- [x] 为所有数值输入框添加 keyboard stepping 支持: + - `size-control.ts` - Width/Height + - `spacing-control.ts` - Margin/Padding + - `position-control.ts` - Top/Right/Bottom/Left/Z-Index + - `layout-control.ts` - Gap + - `typography-control.ts` - Font Size/Line Height + - `appearance-control.ts` - Opacity/Border Radius/Border Width + +--- + +## 阶段一:基础视觉系统对齐 ✅ 已完成 + +### 1.1 颜色方案重构 ✅ + +**目标**:将颜色系统从当前的灰色调整为设计稿的白底+灰输入框风格 + +| 属性 | 旧值 | 新值 | 状态 | +| ------------ | ----------------- | --------------------------------- | ---- | +| 面板背景 | `#f8f8f8` | `#ffffff` | ✅ | +| 输入框背景 | `#f0f0f0` | `#f3f3f3` | ✅ | +| 输入框 hover | `#e8e8e8` (bg) | `border #e0e0e0` (inset) | ✅ | +| 输入框 focus | `box-shadow` 外圈 | `inset 2px border #3b82f6` + 白底 | ✅ | +| 边框色 | `#e8e8e8` | `#e5e5e5` | ✅ | + +**完成的任务**: + +- [x] 更新 CSS 变量定义 (`shadow-host.ts:56-97`) +- [x] 修改输入框 hover/focus 样式为 inset border 模式 +- [x] 面板背景改为纯白 + +### 1.2 字体与字号调整 ✅ + +| 属性 | 旧值 | 新值 | 状态 | +| ------------ | -------- | ------------------------- | ---- | +| 面板基础字号 | `13px` | `11px` | ✅ | +| 标签字号 | `11px` | `10px` | ✅ | +| 输入框字号 | `12px` | `11px` | ✅ | +| 字体家族 | 系统字体 | Inter + 系统字体 fallback | ✅ | + +**完成的任务**: + +- [x] 添加 Inter 字体声明(使用系统字体 fallback) +- [x] 调整面板、标签、输入框的字号 +- [x] 移除标签的大写样式 + +### 1.3 间距与边距调整 ✅ + +| 属性 | 旧值 | 新值 | 状态 | +| ------------- | ----------- | ---------- | ---- | +| 面板宽度 | `320px` | `280px` | ✅ | +| Header 内边距 | `10px 14px` | `8px 12px` | ✅ | +| Body gap | `10px` | `12px` | ✅ | + +**完成的任务**: + +- [x] 调整 `.we-panel`, `.we-prop-body`, `.we-field-group` 的 padding/gap +- [x] 调整 header 的 padding + +### 1.4 圆角与阴影 ✅ + +| 属性 | 旧值 | 新值 | 状态 | +| ---------- | ----------- | ------------------ | ---- | +| 面板阴影 | `0 1px 2px` | Tailwind shadow-xl | ✅ | +| 输入框圆角 | `6px` | `4px` | ✅ | +| Tab 阴影 | 无 | `shadow-sm` | ✅ | + +**完成的任务**: + +- [x] 增强面板阴影效果(双层阴影模拟 shadow-xl) +- [x] 调整输入框圆角为 4px +- [x] 为激活的 Tab 添加阴影 + +### 1.5 Group/Section 样式重构 ✅ + +| 属性 | 旧样式 | 新样式 | 状态 | +| ------------ | ----------- | ----------- | ---- | +| Group 边框 | 卡片边框 | 无边框 | ✅ | +| Section 分隔 | 无 | 顶部分隔线 | ✅ | +| Header 样式 | 粗体 + 大字 | 11px + #333 | ✅ | + +**完成的任务**: + +- [x] 移除 `.we-group` 的边框和背景 +- [x] 添加 Section 间的分隔线 (`border-top`) +- [x] 调整 Group header 样式 + +--- + +## 阶段二:输入容器组件重构 ✅ 基础完成 + +### 2.1 建立输入容器系统 ✅ + +**背景**:设计稿的输入框不是单体 input,而是一个容器系统,支持: + +- 前缀(prefix):标签、图标 +- 后缀(suffix):单位、图标 +- 容器驱动的 hover/focus 样式 + +**当前结构**: + +```html +
+ Width + +
+``` + +**目标结构**: + +```html +
+ Position +
+ + X + + + px + +
+
+``` + +**已完成**: + +- [x] 在 `shadow-host.ts` 中定义 `.we-input-container` 样式 +- [x] 定义 `.we-input-container__prefix` 和 `.we-input-container__suffix` 样式 +- [x] 创建 `ui/property-panel/components/input-container.ts` 组件 +- [x] 将 hover/focus 样式移到容器级别(使用 `:focus-within`) + +### 2.2 更新各 Control 使用新容器 ✅ 已完成 + +**需要更新的控件**: + +- [x] `size-control.ts` - Width/Height(2列布局 + W/H 前缀 + 动态单位后缀) +- [x] `spacing-control.ts` - Margin/Padding(重构为 2x2 网格 + 方向图标 + 动态单位后缀) +- [x] `position-control.ts` - Top/Right/Bottom/Left/Z-Index(T/R/B/L 前缀 + 动态单位后缀) +- [x] `layout-control.ts` - Gap(图标前缀 + 动态单位后缀) +- [x] `typography-control.ts` - Font Size/Line Height(动态单位后缀,line-height 智能显示) +- [ ] `appearance-control.ts` - Opacity/Border Radius/Border Width(待实施) + +**已完成的共享模块**: + +- [x] 创建 `css-helpers.ts` 共享模块(extractUnitSuffix, hasExplicitUnit, normalizeLength) +- [x] 所有控件使用共享 helper,消除重复代码 + +--- + +## 阶段三:Section 结构重构(待实施) + +### 3.1 Tab 信息架构调整 + +**当前**:4 个 Tab(Design/CSS/Props/DOM) +**设计稿**:2 个 Tab(Design/CSS) + +**方案选择**: + +- **方案 A**:保留 4 个 Tab,调整为溢出菜单 +- **方案 B**:将 Props/DOM 移到其他入口 +- **方案 C**:保持 4 个 Tab,调整样式适应 + +**任务**: + +- [ ] 确定 Tab 数量的产品决策 +- [ ] 实现选定方案 + +--- + +## 阶段四:功能组件实现(待实施) + +### 4.1 Flow 布局图标组 ✅ 已完成 + +**设计稿位置**:`attr-ui.html:133-156` +**功能**:4 个图标按钮控制 `flex-direction` + +``` +[→] Row +[↓] Column +[←] Row Reverse +[↑] Column Reverse +``` + +**已完成**: + +- [x] 创建 `ui/property-panel/components/icon-button-group.ts` 通用组件 +- [x] 在 `shadow-host.ts` 中添加 `.we-icon-button-group` 样式 +- [x] 在 `layout-control.ts` 中用图标组替换 Direction select +- [x] 添加对应的 SVG 箭头图标(row/column/row-reverse/column-reverse) + +### 4.2 Alignment 九宫格 ✅ 已完成 + +**设计稿位置**:`attr-ui.html:166-208` +**功能**:3x3 网格控制 `justify-content` + `align-items` + +``` +[↖][↑][↗] +[←][·][→] +[↙][↓][↘] +``` + +**已完成**: + +- [x] 创建 `ui/property-panel/components/alignment-grid.ts` 组件 +- [x] 在 `shadow-host.ts` 中添加 `.we-alignment-grid` 样式 +- [x] 替换 `layout-control.ts` 中的 Justify/Align select +- [x] 使用 `beginMultiStyle` 实现两个属性的原子提交 + +### 4.3 修复 Color Picker ✅ 部分完成 + +**当前问题**: + +- `showPicker()` 无 try/catch,可能抛错 +- alpha 通道被丢弃 +- token 值 `var(--xxx)` 显示不正确 + +**已完成**: + +- [x] 添加 `showPicker()` 的错误处理(try/catch + fallback to click) +- [x] 改进 `var()` 值的解析和显示(通过 placeholder 传入 computed value) + +**待实施**: + +- [ ] 支持 alpha 通道(RGBA/HSLA)- 需要引入第三方 color picker +- [ ] 考虑引入第三方 color picker(如 `@simonwep/pickr`) + +--- + +## 阶段五:新功能模块(待实施) + +### 5.1 Shadow & Blur 控制 + +**设计稿位置**:`attr-ui.html:396-425` +**功能**: + +- 启用/禁用开关 +- 类型选择(Drop shadow/Inner shadow/Layer Blur/Backdrop Blur) +- 可见性控制 + +**CSS 属性**: + +- `box-shadow` +- `filter: blur()` +- `backdrop-filter: blur()` + +**任务**: + +- [x] 创建 `ui/property-panel/controls/effects-control.ts` +- [x] 实现 `box-shadow` 值解析和编辑 +- [x] 实现 `filter` 值解析和编辑 +- [x] 实现 `backdrop-filter` 值解析和编辑 +- [x] 添加类型切换 UI +- [ ] 添加启用/禁用开关(可选,后续实现) + +### 5.2 渐变编辑器 + +**设计稿位置**:`attr-ui.html:269-325` +**功能**: + +- Linear/Radial 渐变类型 +- 颜色停止点(color stops) +- 角度控制 +- 翻转按钮 + +**CSS 属性**: + +- `background-image: linear-gradient(...)` +- `background-image: radial-gradient(...)` + +**任务**: + +- [x] 创建 `ui/property-panel/controls/gradient-control.ts` +- [x] 实现渐变值解析(CSS gradient → 数据结构) +- [x] 实现角度/位置输入 +- [x] 实现 2 个颜色停止点的编辑 +- [x] 集成到 property-panel(作为独立的 Gradient 控制组) +- [ ] 实现渐变预览 slider(可选,后续优化) +- [ ] 实现 color stop 添加/删除/拖拽(可选,后续优化) + +### 5.3 Token/变量 Pill 显示 + +**设计稿位置**:`attr-ui.html:374-384` +**功能**:当值为 CSS 变量时,显示为可点击的 pill + +**任务**: + +- [ ] 检测 `var(--xxx)` 值 +- [ ] 渲染为 pill 样式 +- [ ] 点击打开 token picker + +--- + +## 阶段六:代码质量(贯穿始终) + +### 6.1 样式系统统一 + +- [x] 所有颜色使用 CSS 变量(阶段一完成) +- [ ] 所有尺寸使用一致的 token +- [ ] 移除 inline style,统一到 `shadow-host.ts` + +### 6.2 组件复用 + +- [ ] 提取通用组件到 `ui/property-panel/components/` +- [ ] 统一事件处理模式 +- [ ] 统一 disabled/enabled 状态处理 + +### 6.3 类型安全 + +- [ ] 所有组件使用 TypeScript 严格类型 +- [ ] 定义清晰的接口和类型 +- [ ] 移除 any 类型断言 + +--- + +## 实施进度 + +| 阶段 | 任务 | 状态 | 备注 | +| ---- | ------------------ | ------- | -------------------------------------------- | +| 0.1 | 最小化 Bug 修复 | ✅ | 添加全局 `[hidden]` 规则 | +| 0.2 | 输入框优化 | ✅ | number-stepping + 真实值显示 | +| 1.1 | 颜色方案重构 | ✅ | 白底 + 灰输入框 + inset focus | +| 1.2 | 字体与字号调整 | ✅ | 11px 基准 + Inter 字体 | +| 1.3 | 间距与边距调整 | ✅ | 更紧凑的布局 | +| 1.4 | 圆角与阴影 | ✅ | shadow-xl + 4px 圆角 | +| 1.5 | Group/Section 样式 | ✅ | 分隔线风格 | +| 2.1 | 输入容器系统 | ✅ | 组件 + CSS 样式 | +| 2.2 | 更新 Controls | ✅ | 所有主要控件已迁移,共享 css-helpers.ts | +| 3.1 | Tab 信息架构 | 待实施 | | +| 4.1 | Flow 图标组 | ✅ | icon-button-group.ts + 集成到 layout-control | +| 4.2 | Alignment 九宫格 | ✅ | alignment-grid.ts + 集成到 layout-control | +| 4.3 | 修复 Color Picker | ✅ 部分 | showPicker 异常处理 + var() 解析 | +| 5.1 | Shadow & Blur | ✅ | effects-control.ts + 集成到 property-panel | +| 5.2 | 渐变编辑器 | ✅ | gradient-control.ts + 集成到 property-panel | +| 5.3 | Token Pill | 待实施 | | + +--- + +## 注意事项 + +1. **渐进式实施**:每个 Phase 完成后应可独立测试和发布 +2. **保持向后兼容**:重构过程中不应破坏现有功能 +3. **设计决策记录**:遇到设计稿与实际需求冲突时,记录决策原因 +4. **性能考虑**:新增组件需考虑渲染性能,避免不必要的 DOM 操作 diff --git a/app/chrome-extension/entrypoints/web-editor-v2/constants.ts b/app/chrome-extension/entrypoints/web-editor-v2/constants.ts new file mode 100644 index 0000000..ea9b86e --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/constants.ts @@ -0,0 +1,124 @@ +/** + * Web Editor V2 Constants + * + * Centralized configuration values for the visual editor. + * All magic strings/numbers should be defined here. + */ + +/** Editor version number */ +export const WEB_EDITOR_V2_VERSION = 2 as const; + +/** Log prefix for console messages */ +export const WEB_EDITOR_V2_LOG_PREFIX = '[WebEditorV2]' as const; + +// ============================================================================= +// DOM Element IDs +// ============================================================================= + +/** Shadow host element ID */ +export const WEB_EDITOR_V2_HOST_ID = '__mcp_web_editor_v2_host__'; + +/** Overlay container ID (for Canvas and visual feedback) */ +export const WEB_EDITOR_V2_OVERLAY_ID = '__mcp_web_editor_v2_overlay__'; + +/** UI container ID (for panels and controls) */ +export const WEB_EDITOR_V2_UI_ID = '__mcp_web_editor_v2_ui__'; + +// ============================================================================= +// Styling +// ============================================================================= + +/** Maximum z-index to ensure editor is always on top */ +export const WEB_EDITOR_V2_Z_INDEX = 2147483647; + +/** Default panel width */ +export const WEB_EDITOR_V2_PANEL_WIDTH = 320; + +// ============================================================================= +// Colors (Design System) +// ============================================================================= + +export const WEB_EDITOR_V2_COLORS = { + /** Hover highlight color */ + hover: '#3b82f6', // blue-500 + /** Selected element color */ + selected: '#22c55e', // green-500 + /** Selection box border */ + selectionBorder: '#6366f1', // indigo-500 + /** Drag ghost color */ + dragGhost: 'rgba(99, 102, 241, 0.3)', + /** Insertion line color */ + insertionLine: '#f59e0b', // amber-500 + /** Alignment guide line color (snap guides) */ + guideLine: '#ec4899', // pink-500 + /** Distance label background (Phase 4.3) */ + distanceLabelBg: 'rgba(15, 23, 42, 0.92)', // slate-900 @ 92% + /** Distance label border (Phase 4.3) */ + distanceLabelBorder: 'rgba(51, 65, 85, 0.5)', // slate-600 @ 50% + /** Distance label text (Phase 4.3) */ + distanceLabelText: 'rgba(255, 255, 255, 0.98)', +} as const; + +// ============================================================================= +// Drag Reorder (Phase 2.4-2.6) +// ============================================================================= + +/** Minimum pointer movement (px) to start dragging */ +export const WEB_EDITOR_V2_DRAG_THRESHOLD_PX = 5; + +/** Hysteresis (px) for stable before/after decision to avoid flip-flop */ +export const WEB_EDITOR_V2_DRAG_HYSTERESIS_PX = 6; + +/** Max elements to inspect per hit-test (elementsFromPoint) */ +export const WEB_EDITOR_V2_DRAG_MAX_HIT_ELEMENTS = 8; + +/** Insertion indicator line width in CSS pixels */ +export const WEB_EDITOR_V2_INSERTION_LINE_WIDTH = 3; + +// ============================================================================= +// Snapping & Alignment Guides (Phase 4.2) +// ============================================================================= + +/** Snap threshold in CSS pixels - distance at which snapping activates */ +export const WEB_EDITOR_V2_SNAP_THRESHOLD_PX = 6; + +/** Hysteresis in CSS pixels - keeps snap stable near boundary to prevent flicker */ +export const WEB_EDITOR_V2_SNAP_HYSTERESIS_PX = 2; + +/** Maximum sibling elements to consider for snapping (nearest first) */ +export const WEB_EDITOR_V2_SNAP_MAX_ANCHOR_ELEMENTS = 30; + +/** Maximum siblings to scan before applying distance filter */ +export const WEB_EDITOR_V2_SNAP_MAX_SIBLINGS_SCAN = 300; + +/** Alignment guide line width in CSS pixels */ +export const WEB_EDITOR_V2_GUIDE_LINE_WIDTH = 1; + +// ============================================================================= +// Distance Labels (Phase 4.3) +// ============================================================================= + +/** Minimum distance (px) to display a label - hides 0 and sub-pixel gaps */ +export const WEB_EDITOR_V2_DISTANCE_LABEL_MIN_PX = 1; + +/** Measurement line width in CSS pixels */ +export const WEB_EDITOR_V2_DISTANCE_LINE_WIDTH = 1; + +/** Tick size at the ends of measurement lines (CSS pixels) */ +export const WEB_EDITOR_V2_DISTANCE_TICK_SIZE = 4; + +/** Font used for distance label pills */ +export const WEB_EDITOR_V2_DISTANCE_LABEL_FONT = + '600 11px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; + +/** Horizontal padding inside distance label pill (CSS pixels) */ +export const WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X = 6; + +/** Vertical padding inside distance label pill (CSS pixels) */ +export const WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y = 3; + +/** Border radius for distance label pill (CSS pixels) */ +export const WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS = 4; + +/** Offset from the measurement line to place the pill (CSS pixels) */ +export const WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET = 8; diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/css-compare.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/css-compare.ts new file mode 100644 index 0000000..666a5a1 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/css-compare.ts @@ -0,0 +1,327 @@ +/** + * CSS Compare Utilities (Phase 4.8) + * + * Provides robust CSS value comparison for HMR consistency verification. + * + * Design goals: + * - Compare computed style values (format-agnostic: "1rem" vs "16px" both resolve to same computed value) + * - Handle numeric tolerance for px-based values and transform matrices + * - Provide detailed diff information for UI feedback + * + * Why computed styles? + * - Editor mutates live DOM via inline styles for immediate preview + * - Agent may persist changes via classes/CSS modules/Tailwind, not inline styles + * - Comparing computed values avoids false mismatches from authoring format differences + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** Detailed diff for a single CSS property */ +export interface ComputedDiffItem { + /** CSS property name */ + readonly property: string; + /** Expected value (from baseline) */ + readonly expected: string; + /** Actual value (from current DOM) */ + readonly actual: string; + /** Whether values match */ + readonly match: boolean; + /** How comparison was determined */ + readonly reason?: 'exact' | 'px_epsilon' | 'matrix_epsilon' | 'string'; +} + +/** Result of comparing two computed style maps */ +export interface CompareComputedResult { + /** Overall match status */ + readonly matches: boolean; + /** Per-property diff details */ + readonly diffs: readonly ComputedDiffItem[]; +} + +/** Options for CSS value comparison */ +export interface CompareComputedOptions { + /** + * Epsilon for px-based numeric comparison. + * Defaults to 0.5 to tolerate sub-pixel jitter from rounding. + */ + readonly pxEpsilon?: number; + /** + * Epsilon for matrix()/matrix3d() numeric comparison. + * Defaults to 1e-3 for floating-point precision tolerance. + */ + readonly matrixEpsilon?: number; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const DEFAULT_PX_EPSILON = 0.5; +const DEFAULT_MATRIX_EPSILON = 1e-3; + +// Regex patterns (defined once for performance) +const PX_VALUE_REGEX = /(-?\d*\.?\d+(?:e[+-]?\d+)?)px/gi; +const MATRIX_NUMBER_REGEX = /-?\d*\.?\d+(?:e[+-]?\d+)?/gi; + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Normalize text content for robust comparison. + * Collapses whitespace and trims edges. + */ +export function normalizeText(text: string): string { + return String(text ?? '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Read computed style values for specified CSS properties. + * + * @param element - Target element + * @param properties - CSS property names to read + * @returns Map of property name to computed value (normalized) + */ +export function readComputedMap( + element: Element, + properties: readonly string[], +): Record { + const result: Record = {}; + + // Deduplicate and filter empty property names + const uniqueProps: string[] = []; + const seen = new Set(); + for (const raw of properties) { + const prop = String(raw ?? '').trim(); + if (!prop || seen.has(prop)) continue; + seen.add(prop); + uniqueProps.push(prop); + } + + // Safely get computed style declaration + let computed: CSSStyleDeclaration | null = null; + try { + computed = window.getComputedStyle(element); + } catch { + // Element may not be attached to DOM or other edge cases + computed = null; + } + + // Read each property + for (const property of uniqueProps) { + let value = ''; + try { + value = computed?.getPropertyValue(property) ?? ''; + } catch { + value = ''; + } + result[property] = normalizeCssValue(value); + } + + return result; +} + +/** + * Compare two computed style maps with numeric tolerance. + * + * Comparison strategy: + * 1. Exact string match → pass + * 2. matrix()/matrix3d() numeric tolerance → pass if within epsilon + * 3. px-based numeric tolerance → pass if same shape and within epsilon + * 4. Otherwise → fail + * + * @param expected - Baseline computed values + * @param actual - Current computed values + * @param options - Comparison options + * @returns Comparison result with per-property diffs + */ +export function compareComputed( + expected: Readonly>, + actual: Readonly>, + options: CompareComputedOptions = {}, +): CompareComputedResult { + const pxEps = Number.isFinite(options.pxEpsilon) ? options.pxEpsilon! : DEFAULT_PX_EPSILON; + const matrixEps = Number.isFinite(options.matrixEpsilon) + ? options.matrixEpsilon! + : DEFAULT_MATRIX_EPSILON; + + const diffs: ComputedDiffItem[] = []; + + for (const property of Object.keys(expected)) { + const exp = normalizeCssValue(expected[property] ?? ''); + const act = normalizeCssValue(actual[property] ?? ''); + + const { match, reason } = compareSingleValue(exp, act, pxEps, matrixEps); + diffs.push({ property, expected: exp, actual: act, match, reason }); + } + + const matches = diffs.every((d) => d.match); + return { matches, diffs }; +} + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +/** + * Normalize a CSS value string for consistent comparison. + * Collapses whitespace and normalizes spacing around punctuation. + */ +function normalizeCssValue(raw: string): string { + return String(raw ?? '') + .replace(/\s+/g, ' ') // Collapse whitespace + .replace(/,\s+/g, ',') // Remove space after commas + .replace(/\(\s+/g, '(') // Remove space after open paren + .replace(/\s+\)/g, ')') // Remove space before close paren + .trim(); +} + +/** + * Check if two numbers are approximately equal within epsilon. + */ +function approximatelyEqual(a: number, b: number, epsilon: number): boolean { + return Math.abs(a - b) <= epsilon; +} + +/** + * Check if value looks like a CSS matrix transform. + */ +function isMatrixValue(value: string): boolean { + const lower = value.toLowerCase(); + return lower.startsWith('matrix(') || lower.startsWith('matrix3d('); +} + +/** + * Extract numeric components from a matrix() or matrix3d() value. + * Returns null if not a valid matrix or contains invalid numbers. + */ +function extractMatrixNumbers(value: string): number[] | null { + if (!isMatrixValue(value)) return null; + + const matches = value.match(MATRIX_NUMBER_REGEX); + if (!matches || matches.length === 0) return null; + + const nums: number[] = []; + for (const m of matches) { + const n = Number(m); + if (!Number.isFinite(n)) return null; + nums.push(n); + } + + return nums.length > 0 ? nums : null; +} + +/** + * Extract px numeric values from a CSS value string. + * Returns null if no px values found or contains invalid numbers. + */ +function extractPxNumbers(value: string): number[] | null { + const nums: number[] = []; + + // Reset regex state (global flag requires this) + PX_VALUE_REGEX.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = PX_VALUE_REGEX.exec(value)) !== null) { + const n = Number(match[1]); + if (!Number.isFinite(n)) return null; + nums.push(n); + } + + return nums.length > 0 ? nums : null; +} + +/** + * Get the "shape" of a px-based value by replacing numeric values with placeholders. + * Used to ensure we're comparing structurally similar values. + */ +function pxValueShape(value: string): string { + // Reset regex state + PX_VALUE_REGEX.lastIndex = 0; + return normalizeCssValue(value).replace(PX_VALUE_REGEX, '#px'); +} + +/** + * Compare two matrix values with numeric tolerance. + */ +function compareMatrixWithEpsilon(expected: string, actual: string, epsilon: number): boolean { + const expNums = extractMatrixNumbers(expected); + const actNums = extractMatrixNumbers(actual); + + if (!expNums || !actNums) return false; + if (expNums.length !== actNums.length) return false; + + // Ensure both are same type (matrix vs matrix3d) + const expKind = expected.toLowerCase().startsWith('matrix3d(') ? 'matrix3d' : 'matrix'; + const actKind = actual.toLowerCase().startsWith('matrix3d(') ? 'matrix3d' : 'matrix'; + if (expKind !== actKind) return false; + + // Compare each component with epsilon + for (let i = 0; i < expNums.length; i++) { + if (!approximatelyEqual(expNums[i]!, actNums[i]!, epsilon)) return false; + } + + return true; +} + +/** + * Compare two px-based values with numeric tolerance. + */ +function comparePxWithEpsilon(expected: string, actual: string, epsilon: number): boolean { + const expNums = extractPxNumbers(expected); + const actNums = extractPxNumbers(actual); + + if (!expNums || !actNums) return false; + if (expNums.length !== actNums.length) return false; + + // Ensure values have same structure (e.g., "10px 20px" vs "10px 20px", not "10px" vs "10px solid") + if (pxValueShape(expected) !== pxValueShape(actual)) return false; + + // Compare each px value with epsilon + for (let i = 0; i < expNums.length; i++) { + if (!approximatelyEqual(expNums[i]!, actNums[i]!, epsilon)) return false; + } + + return true; +} + +/** + * Compare a single CSS value pair with all available strategies. + */ +function compareSingleValue( + expected: string, + actual: string, + pxEpsilon: number, + matrixEpsilon: number, +): { match: boolean; reason: ComputedDiffItem['reason'] } { + // 1. Exact string match (fastest path) + if (expected === actual) { + return { match: true, reason: 'exact' }; + } + + // 2. Matrix tolerance comparison + if (isMatrixValue(expected) && isMatrixValue(actual)) { + if (compareMatrixWithEpsilon(expected, actual, matrixEpsilon)) { + return { match: true, reason: 'matrix_epsilon' }; + } + } + + // 3. Px tolerance comparison + const expHasPx = PX_VALUE_REGEX.test(expected); + PX_VALUE_REGEX.lastIndex = 0; // Reset after test + const actHasPx = PX_VALUE_REGEX.test(actual); + PX_VALUE_REGEX.lastIndex = 0; // Reset after test + + if (expHasPx && actHasPx) { + if (comparePxWithEpsilon(expected, actual, pxEpsilon)) { + return { match: true, reason: 'px_epsilon' }; + } + } + + // 4. No match + return { match: false, reason: 'string' }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/core/cssom-styles-collector.ts b/app/chrome-extension/entrypoints/web-editor-v2/core/cssom-styles-collector.ts new file mode 100644 index 0000000..09053b0 --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/core/cssom-styles-collector.ts @@ -0,0 +1,1552 @@ +/** + * CSSOM Styles Collector (Phase 4.6) + * + * Provides CSS rule collection and cascade computation using CSSOM. + * Used for the CSS panel's style source tracking feature. + * + * Design goals: + * - Collect matched CSS rules for an element via CSSOM + * - Compute cascade (specificity + source order + !important) + * - Track inherited styles from ancestor elements + * - Handle Shadow DOM stylesheets + * - Produce UI-ready snapshot for rendering + * + * Limitations (CSSOM-only approach): + * - No reliable file:line info (only href/label available) + * - @container/@scope rules are not evaluated + * - @layer ordering is approximated via source order + */ + +// ============================================================================= +// Public Types (UI-ready snapshot) +// ============================================================================= + +export type Specificity = readonly [inline: number, ids: number, classes: number, types: number]; + +export type DeclStatus = 'active' | 'overridden'; + +export interface CssRuleSource { + url?: string; + label: string; +} + +export interface CssDeclView { + id: string; + name: string; + value: string; + important: boolean; + affects: readonly string[]; + status: DeclStatus; +} + +export interface CssRuleView { + id: string; + origin: 'inline' | 'rule'; + selector: string; + matchedSelector?: string; + specificity?: Specificity; + source?: CssRuleSource; + order: number; + decls: CssDeclView[]; +} + +export interface CssSectionView { + kind: 'inline' | 'matched' | 'inherited'; + title: string; + inheritedFrom?: { label: string }; + rules: CssRuleView[]; +} + +export interface CssPanelSnapshot { + target: { + label: string; + root: 'document' | 'shadow'; + }; + warnings: string[]; + stats: { + roots: number; + styleSheets: number; + rulesScanned: number; + matchedRules: number; + }; + sections: CssSectionView[]; +} + +// ============================================================================= +// Internal Types (cascade + collection) +// ============================================================================= + +interface DeclCandidate { + id: string; + important: boolean; + specificity: Specificity; + sourceOrder: readonly [sheetIndex: number, ruleOrder: number, declIndex: number]; + property: string; + value: string; + affects: readonly string[]; + ownerRuleId: string; + ownerElementId: number; +} + +interface FlatStyleRule { + sheetIndex: number; + order: number; + selectorText: string; + style: CSSStyleDeclaration; + source: CssRuleSource; +} + +interface RuleIndex { + root: Document | ShadowRoot; + rootId: number; + flatRules: FlatStyleRule[]; + warnings: string[]; + stats: { styleSheets: number; rulesScanned: number }; +} + +interface CollectElementOptions { + includeInline: boolean; + declFilter: (decl: { property: string; affects: readonly string[] }) => boolean; +} + +interface CollectedElementRules { + element: Element; + elementId: number; + root: Document | ShadowRoot; + rootType: 'document' | 'shadow'; + inlineRule: CssRuleView | null; + matchedRules: CssRuleView[]; + candidates: DeclCandidate[]; + warnings: string[]; + stats: { matchedRules: number }; +} + +// ============================================================================= +// Specificity (Selectors Level 4) +// ============================================================================= + +const ZERO_SPEC: Specificity = [0, 0, 0, 0] as const; + +export function compareSpecificity(a: Specificity, b: Specificity): number { + for (let i = 0; i < 4; i++) { + if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1; + } + return 0; +} + +function splitSelectorList(input: string): string[] { + const out: string[] = []; + let start = 0; + let depthParen = 0; + let depthBrack = 0; + let quote: "'" | '"' | null = null; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + + if (quote) { + if (ch === '\\') { + i += 1; + continue; + } + if (ch === quote) quote = null; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (ch === '\\') { + i += 1; + continue; + } + + if (ch === '[') depthBrack += 1; + else if (ch === ']' && depthBrack > 0) depthBrack -= 1; + else if (ch === '(') depthParen += 1; + else if (ch === ')' && depthParen > 0) depthParen -= 1; + + if (ch === ',' && depthParen === 0 && depthBrack === 0) { + const part = input.slice(start, i).trim(); + if (part) out.push(part); + start = i + 1; + } + } + + const tail = input.slice(start).trim(); + if (tail) out.push(tail); + return out; +} + +function maxSpecificity(list: readonly Specificity[]): Specificity { + let best: Specificity = ZERO_SPEC; + for (const s of list) if (compareSpecificity(s, best) > 0) best = s; + return best; +} + +function computeSelectorSpecificity(selector: string): Specificity { + let ids = 0; + let classes = 0; + let types = 0; + + let expectType = true; + + for (let i = 0; i < selector.length; i++) { + const ch = selector[i]; + + if (ch === '\\') { + i += 1; + continue; + } + + if (ch === '[') { + classes += 1; + i = consumeBracket(selector, i); + expectType = false; + continue; + } + + if (isCombinatorOrWhitespace(selector, i)) { + i = consumeWhitespaceAndCombinators(selector, i); + expectType = true; + continue; + } + + if (ch === '#') { + ids += 1; + i = consumeIdent(selector, i + 1) - 1; + expectType = false; + continue; + } + + if (ch === '.') { + classes += 1; + i = consumeIdent(selector, i + 1) - 1; + expectType = false; + continue; + } + + if (ch === ':') { + const isPseudoEl = selector[i + 1] === ':'; + if (isPseudoEl) { + types += 1; + const nameStart = i + 2; + const nameEnd = consumeIdent(selector, nameStart); + const name = selector.slice(nameStart, nameEnd).toLowerCase(); + i = nameEnd - 1; + + if (selector[i + 1] === '(' && name === 'slotted') { + const { content, endIndex } = consumeParenFunction(selector, i + 1); + const maxArg = maxSpecificity(splitSelectorList(content).map(computeSelectorSpecificity)); + ids += maxArg[1]; + classes += maxArg[2]; + types += maxArg[3]; + i = endIndex; + } + + expectType = false; + continue; + } + + const nameStart = i + 1; + const nameEnd = consumeIdent(selector, nameStart); + const name = selector.slice(nameStart, nameEnd).toLowerCase(); + + if (LEGACY_PSEUDO_ELEMENTS.has(name)) { + types += 1; + i = nameEnd - 1; + expectType = false; + continue; + } + + if (selector[nameEnd] === '(') { + const { content, endIndex } = consumeParenFunction(selector, nameEnd); + i = endIndex; + + if (name === 'where') { + expectType = false; + continue; + } + + if (name === 'is' || name === 'not' || name === 'has') { + const maxArg = maxSpecificity(splitSelectorList(content).map(computeSelectorSpecificity)); + ids += maxArg[1]; + classes += maxArg[2]; + types += maxArg[3]; + expectType = false; + continue; + } + + if (name === 'nth-child' || name === 'nth-last-child') { + classes += 1; + const ofSelectors = extractNthOfSelectorList(content); + if (ofSelectors) { + const maxArg = maxSpecificity( + splitSelectorList(ofSelectors).map(computeSelectorSpecificity), + ); + ids += maxArg[1]; + classes += maxArg[2]; + types += maxArg[3]; + } + expectType = false; + continue; + } + + // Other functional pseudo-classes count as class specificity (+1). + classes += 1; + expectType = false; + continue; + } + + classes += 1; + i = nameEnd - 1; + expectType = false; + continue; + } + + if (expectType) { + if (ch === '*') { + expectType = false; + continue; + } + if (isIdentStart(ch)) { + types += 1; + i = consumeIdent(selector, i + 1) - 1; + expectType = false; + continue; + } + } + } + + return [0, ids, classes, types] as const; +} + +/** + * For a selector list, returns the matched selector with max specificity among matches. + */ +function computeMatchedRuleSpecificity( + element: Element, + selectorText: string, +): { matchedSelector: string; specificity: Specificity } | null { + const selectors = splitSelectorList(selectorText); + let bestSel: string | null = null; + let bestSpec: Specificity = ZERO_SPEC; + + for (const sel of selectors) { + try { + if (!element.matches(sel)) continue; + const spec = computeSelectorSpecificity(sel); + if (!bestSel || compareSpecificity(spec, bestSpec) > 0) { + bestSel = sel; + bestSpec = spec; + } + } catch { + // Invalid selector for matches() (e.g. pseudo-elements) => ignore. + } + } + + return bestSel ? { matchedSelector: bestSel, specificity: bestSpec } : null; +} + +const LEGACY_PSEUDO_ELEMENTS = new Set([ + 'before', + 'after', + 'first-line', + 'first-letter', + 'selection', + 'backdrop', + 'placeholder', +]); + +function isIdentStart(ch: string): boolean { + return /[a-zA-Z_]/.test(ch) || ch.charCodeAt(0) >= 0x80; +} + +function consumeIdent(s: string, start: number): number { + let i = start; + for (; i < s.length; i++) { + const ch = s[i]; + if (ch === '\\') { + i += 1; + continue; + } + if (/[a-zA-Z0-9_-]/.test(ch) || ch.charCodeAt(0) >= 0x80) continue; + break; + } + return i; +} + +function consumeBracket(s: string, openIndex: number): number { + let depth = 1; + let quote: "'" | '"' | null = null; + + for (let i = openIndex + 1; i < s.length; i++) { + const ch = s[i]; + if (quote) { + if (ch === '\\') { + i += 1; + continue; + } + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '[') depth += 1; + else if (ch === ']') { + depth -= 1; + if (depth === 0) return i; + } + } + return s.length - 1; +} + +function consumeParenFunction( + s: string, + openParenIndex: number, +): { content: string; endIndex: number } { + let depth = 1; + let quote: "'" | '"' | null = null; + + for (let i = openParenIndex + 1; i < s.length; i++) { + const ch = s[i]; + if (quote) { + if (ch === '\\') { + i += 1; + continue; + } + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '\\') { + i += 1; + continue; + } + if (ch === '[') i = consumeBracket(s, i); + else if (ch === '(') depth += 1; + else if (ch === ')') { + depth -= 1; + if (depth === 0) return { content: s.slice(openParenIndex + 1, i), endIndex: i }; + } + } + return { content: s.slice(openParenIndex + 1), endIndex: s.length - 1 }; +} + +function isCombinatorOrWhitespace(s: string, i: number): boolean { + const ch = s[i]; + return /\s/.test(ch) || ch === '>' || ch === '+' || ch === '~' || ch === '|'; +} + +function consumeWhitespaceAndCombinators(s: string, i: number): number { + let j = i; + while (j < s.length && /\s/.test(s[j])) j++; + if (s[j] === '|' && s[j + 1] === '|') return j + 1; + if (s[j] === '>' || s[j] === '+' || s[j] === '~' || s[j] === '|') return j; + return j - 1; +} + +function extractNthOfSelectorList(content: string): string | null { + let depthParen = 0; + let depthBrack = 0; + let quote: "'" | '"' | null = null; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + + if (quote) { + if (ch === '\\') { + i += 1; + continue; + } + if (ch === quote) quote = null; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (ch === '\\') { + i += 1; + continue; + } + + if (ch === '[') depthBrack += 1; + else if (ch === ']' && depthBrack > 0) depthBrack -= 1; + else if (ch === '(') depthParen += 1; + else if (ch === ')' && depthParen > 0) depthParen -= 1; + + if (depthParen === 0 && depthBrack === 0) { + if (isOfTokenAt(content, i)) return content.slice(i + 2).trimStart(); + } + } + + return null; +} + +function isOfTokenAt(s: string, i: number): boolean { + if (s[i] !== 'o' || s[i + 1] !== 'f') return false; + const prev = s[i - 1]; + const next = s[i + 2]; + const prevOk = prev === undefined || /\s/.test(prev); + const nextOk = next === undefined || /\s/.test(next); + return prevOk && nextOk; +} + +// ============================================================================= +// Inherited properties +// ============================================================================= + +export const INHERITED_PROPERTIES = new Set([ + // Color & appearance + 'color', + 'color-scheme', + 'caret-color', + 'accent-color', + + // Typography / fonts + 'font', + 'font-family', + 'font-feature-settings', + 'font-kerning', + 'font-language-override', + 'font-optical-sizing', + 'font-palette', + 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-synthesis', + 'font-synthesis-small-caps', + 'font-synthesis-style', + 'font-synthesis-weight', + 'font-variant', + 'font-variant-alternates', + 'font-variant-caps', + 'font-variant-east-asian', + 'font-variant-emoji', + 'font-variant-ligatures', + 'font-variant-numeric', + 'font-variant-position', + 'font-variation-settings', + 'font-weight', + 'letter-spacing', + 'line-height', + 'text-rendering', + 'text-size-adjust', + 'text-transform', + 'text-indent', + 'text-align', + 'text-align-last', + 'text-justify', + 'text-shadow', + 'text-emphasis-color', + 'text-emphasis-position', + 'text-emphasis-style', + 'text-underline-position', + 'tab-size', + 'white-space', + 'word-break', + 'overflow-wrap', + 'word-spacing', + 'hyphens', + 'line-break', + + // Writing / bidi + 'direction', + 'unicode-bidi', + 'writing-mode', + 'text-orientation', + 'text-combine-upright', + + // Lists + 'list-style', + 'list-style-image', + 'list-style-position', + 'list-style-type', + + // Tables + 'border-collapse', + 'border-spacing', + 'caption-side', + 'empty-cells', + + // Visibility / interaction + 'cursor', + 'visibility', + 'pointer-events', + 'user-select', + + // Quotes & pagination + 'quotes', + 'orphans', + 'widows', + + // SVG + 'fill', + 'fill-opacity', + 'fill-rule', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-opacity', + 'paint-order', + 'shape-rendering', + 'image-rendering', + 'color-interpolation', + 'color-interpolation-filters', + 'color-rendering', + 'dominant-baseline', + 'alignment-baseline', + 'baseline-shift', + 'text-anchor', + 'stop-color', + 'stop-opacity', + 'flood-color', + 'flood-opacity', + 'lighting-color', + 'marker', + 'marker-start', + 'marker-mid', + 'marker-end', +]); + +export function isInheritableProperty(property: string): boolean { + const p = String(property || '').trim(); + if (!p) return false; + if (p.startsWith('--')) return true; + return INHERITED_PROPERTIES.has(p.toLowerCase()); +} + +// ============================================================================= +// Shorthand expansion +// ============================================================================= + +export const SHORTHAND_TO_LONGHANDS: Record = { + // Spacing + margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'], + padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'], + inset: ['top', 'right', 'bottom', 'left'], + + // Border + border: [ + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + 'border-top-style', + 'border-right-style', + 'border-bottom-style', + 'border-left-style', + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + ], + 'border-width': [ + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + ], + 'border-style': [ + 'border-top-style', + 'border-right-style', + 'border-bottom-style', + 'border-left-style', + ], + 'border-color': [ + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + ], + + 'border-top': ['border-top-width', 'border-top-style', 'border-top-color'], + 'border-right': ['border-right-width', 'border-right-style', 'border-right-color'], + 'border-bottom': ['border-bottom-width', 'border-bottom-style', 'border-bottom-color'], + 'border-left': ['border-left-width', 'border-left-style', 'border-left-color'], + + 'border-radius': [ + 'border-top-left-radius', + 'border-top-right-radius', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + ], + + outline: ['outline-color', 'outline-style', 'outline-width'], + + // Background + background: [ + 'background-attachment', + 'background-clip', + 'background-color', + 'background-image', + 'background-origin', + 'background-position', + 'background-repeat', + 'background-size', + ], + + // Font + font: [ + 'font-style', + 'font-variant', + 'font-weight', + 'font-stretch', + 'font-size', + 'line-height', + 'font-family', + ], + + // Flexbox + flex: ['flex-grow', 'flex-shrink', 'flex-basis'], + 'flex-flow': ['flex-direction', 'flex-wrap'], + + // Alignment + 'place-content': ['align-content', 'justify-content'], + 'place-items': ['align-items', 'justify-items'], + 'place-self': ['align-self', 'justify-self'], + + // Gaps + gap: ['row-gap', 'column-gap'], + 'grid-gap': ['row-gap', 'column-gap'], + + // Overflow + overflow: ['overflow-x', 'overflow-y'], + + // Grid + 'grid-area': ['grid-row-start', 'grid-column-start', 'grid-row-end', 'grid-column-end'], + 'grid-row': ['grid-row-start', 'grid-row-end'], + 'grid-column': ['grid-column-start', 'grid-column-end'], + 'grid-template': ['grid-template-rows', 'grid-template-columns', 'grid-template-areas'], + + // Text + 'text-emphasis': ['text-emphasis-style', 'text-emphasis-color'], + 'text-decoration': [ + 'text-decoration-line', + 'text-decoration-style', + 'text-decoration-color', + 'text-decoration-thickness', + ], + + // Animations / transitions + transition: [ + 'transition-property', + 'transition-duration', + 'transition-timing-function', + 'transition-delay', + ], + animation: [ + 'animation-name', + 'animation-duration', + 'animation-timing-function', + 'animation-delay', + 'animation-iteration-count', + 'animation-direction', + 'animation-fill-mode', + 'animation-play-state', + ], + + // Multi-column + columns: ['column-width', 'column-count'], + 'column-rule': ['column-rule-width', 'column-rule-style', 'column-rule-color'], + + // Lists + 'list-style': ['list-style-position', 'list-style-image', 'list-style-type'], +}; + +export function expandToLonghands(property: string): readonly string[] { + const raw = String(property || '').trim(); + if (!raw) return []; + if (raw.startsWith('--')) return [raw]; + const p = raw.toLowerCase(); + return SHORTHAND_TO_LONGHANDS[p] ?? [p]; +} + +function normalizePropertyName(property: string): string { + const raw = String(property || '').trim(); + if (!raw) return ''; + if (raw.startsWith('--')) return raw; + return raw.toLowerCase(); +} + +// ============================================================================= +// Cascade / override +// ============================================================================= + +function compareSourceOrder( + a: readonly [number, number, number], + b: readonly [number, number, number], +): number { + if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1; + if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1; + if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1; + return 0; +} + +function compareCascade(a: DeclCandidate, b: DeclCandidate): number { + if (a.important !== b.important) return a.important ? 1 : -1; + const spec = compareSpecificity(a.specificity, b.specificity); + if (spec !== 0) return spec; + return compareSourceOrder(a.sourceOrder, b.sourceOrder); +} + +function computeOverrides(candidates: readonly DeclCandidate[]): { + winners: Map; + declStatus: Map; +} { + const winners = new Map(); + + for (const cand of candidates) { + for (const longhand of cand.affects) { + const cur = winners.get(longhand); + if (!cur || compareCascade(cand, cur) > 0) winners.set(longhand, cand); + } + } + + const declStatus = new Map(); + for (const cand of candidates) declStatus.set(cand.id, 'overridden'); + for (const [, winner] of winners) declStatus.set(winner.id, 'active'); + + return { winners, declStatus }; +} + +// ============================================================================= +// CSSOM Rule Index +// ============================================================================= + +const CONTAINER_RULE = (globalThis as unknown as { CSSRule?: { CONTAINER_RULE?: number } }).CSSRule + ?.CONTAINER_RULE; +const SCOPE_RULE = (globalThis as unknown as { CSSRule?: { SCOPE_RULE?: number } }).CSSRule + ?.SCOPE_RULE; + +function isSheetApplicable(sheet: CSSStyleSheet): boolean { + if ((sheet as { disabled?: boolean }).disabled) return false; + + try { + const mediaText = sheet.media?.mediaText?.trim() ?? ''; + if (!mediaText || mediaText.toLowerCase() === 'all') return true; + return window.matchMedia(mediaText).matches; + } catch { + return true; + } +} + +function describeStyleSheet(sheet: CSSStyleSheet, fallbackIndex: number): CssRuleSource { + const href = typeof sheet.href === 'string' ? sheet.href : undefined; + + if (href) { + const file = href.split('/').pop()?.split('?')[0] ?? href; + return { url: href, label: file }; + } + + const ownerNode = sheet.ownerNode as Node | null | undefined; + if (ownerNode && ownerNode.nodeType === Node.ELEMENT_NODE) { + const el = ownerNode as Element; + if (el.tagName === 'STYLE') return { label: ` diff --git a/app/chrome-extension/entrypoints/welcome/index.html b/app/chrome-extension/entrypoints/welcome/index.html new file mode 100644 index 0000000..e6bdf5e --- /dev/null +++ b/app/chrome-extension/entrypoints/welcome/index.html @@ -0,0 +1,13 @@ + + + + + + Welcome to Chrome MCP Server + + + +
+ + + diff --git a/app/chrome-extension/entrypoints/welcome/main.ts b/app/chrome-extension/entrypoints/welcome/main.ts new file mode 100644 index 0000000..486851f --- /dev/null +++ b/app/chrome-extension/entrypoints/welcome/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +// Tailwind first, then custom tokens +import '../styles/tailwind.css'; + +createApp(App).mount('#app'); diff --git a/app/chrome-extension/env.d.ts b/app/chrome-extension/env.d.ts new file mode 100644 index 0000000..f386e50 --- /dev/null +++ b/app/chrome-extension/env.d.ts @@ -0,0 +1,8 @@ +/// +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + type Props = Record; + type RawBindings = Record; + const component: DefineComponent; + export default component; +} diff --git a/app/chrome-extension/eslint.config.js b/app/chrome-extension/eslint.config.js new file mode 100644 index 0000000..352348b --- /dev/null +++ b/app/chrome-extension/eslint.config.js @@ -0,0 +1,56 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import pluginVue from 'eslint-plugin-vue'; +import { defineConfig } from 'eslint/config'; +import prettierConfig from 'eslint-config-prettier'; + +export default defineConfig([ + // Global ignores - these apply to all configurations + { + ignores: [ + 'dist/**', + '.output/**', + '.wxt/**', + 'node_modules/**', + 'logs/**', + '*.log', + '.cache/**', + '.temp/**', + '.vscode/**', + '!.vscode/extensions.json', + '.idea/**', + '.DS_Store', + 'Thumbs.db', + '*.zip', + '*.tar.gz', + 'stats.html', + 'stats-*.json', + 'libs/**', + 'workers/**', + 'public/libs/**', + ], + }, + js.configs.recommended, + { + files: ['**/*.{js,mjs,cjs,ts,vue}'], + languageOptions: { + globals: { + ...globals.browser, + chrome: 'readonly', + }, + }, + }, + ...tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-empty': 'off', + }, + }, + pluginVue.configs['flat/essential'], + { files: ['**/*.vue'], languageOptions: { parserOptions: { parser: tseslint.parser } } }, + // Prettier configuration - must be placed last to override previous rules + prettierConfig, +]); diff --git a/app/chrome-extension/inject-scripts/accessibility-tree-helper.js b/app/chrome-extension/inject-scripts/accessibility-tree-helper.js new file mode 100644 index 0000000..b812a4f --- /dev/null +++ b/app/chrome-extension/inject-scripts/accessibility-tree-helper.js @@ -0,0 +1,1855 @@ +/* eslint-disable */ +// accessibility-tree-helper.js +// Injected script to generate an accessibility-like tree of the visible page +// Elements receive stable refs (ref_*) via WeakRef mapping for later reference. + +(function () { + if (window.__ACCESSIBILITY_TREE_HELPER_INITIALIZED__) return; + window.__ACCESSIBILITY_TREE_HELPER_INITIALIZED__ = true; + + // Traversal and output limits to ensure stability on very large/complex pages + const MAX_DEPTH = 30; // maximum DOM depth to traverse + const MAX_NODES = 4000; // hard limit to avoid long blocking on huge DOMs + const MAX_LINE_LABEL = 100; // max characters for a single label in output + const REF_MAP_LIMIT = 1000; // limit size of the ref map to keep payload small + + // Keep a weak map from ref id to elements + if (!window.__claudeElementMap) window.__claudeElementMap = {}; + if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; + + /** + * Infer ARIA-like role from element + * @param {Element} el + * @returns {string} + */ + function inferRole(el) { + const role = el.getAttribute('role'); + if (role) return role; + const tag = el.tagName.toLowerCase(); + const type = el.getAttribute('type') || ''; + const map = { + a: 'link', + button: 'button', + input: + type === 'submit' || type === 'button' + ? 'button' + : type === 'checkbox' + ? 'checkbox' + : type === 'radio' + ? 'radio' + : type === 'file' + ? 'button' + : 'textbox', + select: 'combobox', + textarea: 'textbox', + h1: 'heading', + h2: 'heading', + h3: 'heading', + h4: 'heading', + h5: 'heading', + h6: 'heading', + img: 'image', + nav: 'navigation', + main: 'main', + header: 'banner', + footer: 'contentinfo', + section: 'region', + article: 'article', + aside: 'complementary', + form: 'form', + table: 'table', + ul: 'list', + ol: 'list', + li: 'listitem', + label: 'label', + }; + return map[tag] || 'generic'; + } + + /** + * Derive readable label for element + * @param {Element} el + * @returns {string} + */ + function inferLabel(el) { + const tag = el.tagName.toLowerCase(); + if (tag === 'select') { + const sel = /** @type {HTMLSelectElement} */ (el); + const opt = sel.querySelector('option[selected]') || sel.options[sel.selectedIndex]; + if (opt && opt.textContent) return opt.textContent.trim(); + } + const aria = el.getAttribute('aria-label'); + if (aria && aria.trim()) return aria.trim(); + const placeholder = el.getAttribute('placeholder'); + if (placeholder && placeholder.trim()) return placeholder.trim(); + const title = el.getAttribute('title'); + if (title && title.trim()) return title.trim(); + const alt = el.getAttribute('alt'); + if (alt && alt.trim()) return alt.trim(); + if (/** @type {HTMLElement} */ (el).id) { + const lab = document.querySelector(`label[for="${/** @type {HTMLElement} */ (el).id}"]`); + if (lab && lab.textContent && lab.textContent.trim()) return lab.textContent.trim(); + } + if (tag === 'input') { + const input = /** @type {HTMLInputElement} */ (el); + const type = input.getAttribute('type') || ''; + const val = input.getAttribute('value'); + if (type === 'submit' && val && val.trim()) return val.trim(); + if (input.value && input.value.length < 50 && input.value.trim()) return input.value.trim(); + } + if (['button', 'a', 'summary'].includes(tag)) { + let text = ''; + for (let i = 0; i < el.childNodes.length; i++) { + const n = el.childNodes[i]; + if (n.nodeType === Node.TEXT_NODE) text += n.textContent || ''; + } + if (text.trim()) return text.trim(); + } + if (/^h[1-6]$/.test(tag)) { + const t = el.textContent; + if (t && t.trim()) return t.trim().substring(0, MAX_LINE_LABEL); + } + if (tag === 'img') { + const src = el.getAttribute('src'); + if (src) { + const file = src.split('/').pop()?.split('?')[0]; + return `Image: ${file}`; + } + } + let agg = ''; + for (let i = 0; i < el.childNodes.length; i++) { + const n = el.childNodes[i]; + if (n.nodeType === Node.TEXT_NODE) agg += n.textContent || ''; + } + if (agg && agg.trim() && agg.trim().length >= 3) { + const v = agg.trim(); + return v.length > 50 ? v.substring(0, 50) + '...' : v; + } + return ''; + } + + /** + * Check if element is visible in DOM + * @param {Element} el + */ + function isVisible(el) { + const cs = window.getComputedStyle(/** @type {HTMLElement} */ (el)); + if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false; + const he = /** @type {HTMLElement} */ (el); + return he.offsetWidth > 0 && he.offsetHeight > 0; + } + + /** + * Whether the element is interactive + * @param {Element} el + */ + function isInteractive(el) { + // Native interactive tags + const tag = el.tagName.toLowerCase(); + if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary'].includes(tag)) + return true; + + // Generic interactive hints + if (el.getAttribute('onclick') != null) return true; + if ( + el.getAttribute('tabindex') != null && + String(el.getAttribute('tabindex')).trim() !== '' && + !String(el.getAttribute('tabindex')).trim().startsWith('-') + ) + return true; + if (el.getAttribute('contenteditable') === 'true') return true; + + // ARIA roles commonly used by custom elements + const role = (el.getAttribute && el.getAttribute('role')) || ''; + const interactiveRoles = new Set([ + 'button', + 'link', + 'checkbox', + 'radio', + 'switch', + 'slider', + 'option', + 'menuitem', + 'textbox', + 'searchbox', + 'combobox', + 'spinbutton', + 'tab', + 'treeitem', + ]); + if (role && interactiveRoles.has(role.toLowerCase())) return true; + + // Shadow host case: treat host as interactive if its open shadow root contains + // an interactive control (textarea/input/select/button/a or contenteditable). + try { + const anyEl = /** @type {any} */ (el); + const sr = anyEl && anyEl.shadowRoot ? anyEl.shadowRoot : null; + if (sr) { + const inner = sr.querySelector( + 'input, textarea, select, button, a[href], [contenteditable="true"], [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="searchbox"], [role="menuitem"], [role="option"], [role="switch"], [role="radio"], [role="checkbox"], [role="tab"], [role="slider"]', + ); + if (inner) return true; + } + } catch (_) { + /* ignore */ + } + return false; + } + + /** + * Structural containers useful to include + * @param {Element} el + */ + function isStructural(el) { + const tag = el.tagName.toLowerCase(); + if ( + [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'nav', + 'main', + 'header', + 'footer', + 'section', + 'article', + 'aside', + ].includes(tag) + ) + return true; + return el.getAttribute('role') != null; + } + + /** + * Form-ish containers to keep + * @param {Element} el + */ + function isFormishContainer(el) { + const tag = el.tagName.toLowerCase(); + const role = (el.getAttribute && el.getAttribute('role')) || ''; + const id = /** @type {HTMLElement} */ (el).id || ''; + // Normalize className for HTML/SVG elements + let cls = ''; + try { + const attr = el.getAttribute && el.getAttribute('class'); + if (typeof attr === 'string') cls = attr; + else { + const cn = /** @type {any} */ (el).className; + if (typeof cn === 'string') cls = cn; + else if (cn && typeof cn.baseVal === 'string') cls = cn.baseVal; + } + } catch (e) { + /* ignore */ + } + return ( + role === 'search' || + role === 'form' || + role === 'group' || + role === 'toolbar' || + role === 'navigation' || + tag === 'form' || + tag === 'fieldset' || + tag === 'nav' || + tag === 'legend' || + id.includes('search') || + cls.includes('search') || + id.includes('form') || + cls.includes('form') || + id.includes('menu') || + cls.includes('menu') || + id.includes('nav') || + cls.includes('nav') + ); + } + + // Utility: query CSS across open shadow roots (best-effort) + function querySelectorDeepFirst(selector) { + try { + // Fast path + const direct = document.querySelector(selector); + if (direct) return direct; + } catch (_) {} + const visited = new Set(); + const stack = [document.documentElement]; + while (stack.length) { + const node = stack.pop(); + if (!node || visited.has(node)) continue; + visited.add(node); + try { + const root = /** @type {any} */ (node).shadowRoot || (node.nodeType === 9 ? node : null); + if (root) { + try { + const hit = root.querySelector(selector); + if (hit) return hit; + } catch (_) {} + } + } catch (_) {} + // Traverse DOM and shadow roots + try { + const children = /** @type {Element} */ (node).children || []; + for (let i = 0; i < children.length; i++) stack.push(children[i]); + const sr = /** @type {any} */ (node).shadowRoot; + if (sr && sr.children) { + for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]); + } + } catch (_) {} + } + return null; + } + + /** + * Query CSS selector and return match info including uniqueness check. + * @param {string} selector - CSS selector to query + * @param {boolean} allowMultiple - If true, skip uniqueness check and return first match + * @returns {{element: Element | null, matchCount: number, error?: string}} + * Note: matchCount is capped at 2 (where 2 means "2 or more") for performance + */ + function querySelectorWithUniquenessCheck(selector, allowMultiple = false) { + const seen = new Set(); + let firstMatch = null; + let matchCount = 0; + + const recordMatch = (el) => { + if (!(el instanceof Element) || seen.has(el)) return false; + seen.add(el); + matchCount++; + if (!firstMatch) firstMatch = el; + // Short-circuit if: + // - allowMultiple is true and we found first match (no need to continue) + // - allowMultiple is false and we found multiple matches + if (allowMultiple && firstMatch) return true; + if (!allowMultiple && matchCount >= 2) return true; + return false; + }; + + // Query in main document + let selectorError = null; + try { + const directMatches = document.querySelectorAll(selector); + for (let i = 0; i < directMatches.length; i++) { + if (recordMatch(directMatches[i])) { + // Early exit: either found first match (allowMultiple) or found multiple (not allowed) + return { element: firstMatch, matchCount: allowMultiple ? 1 : 2 }; + } + } + } catch (e) { + selectorError = e; + } + + if (selectorError) { + return { + element: null, + matchCount: 0, + error: `Invalid CSS selector "${selector}": ${selectorError.message || selectorError}`, + }; + } + + // If allowMultiple and we already have a match, return immediately + if (allowMultiple && firstMatch) { + return { element: firstMatch, matchCount: 1 }; + } + + // Query in shadow DOMs + const visited = new Set(); + const stack = [document.documentElement]; + while (stack.length) { + const node = stack.pop(); + if (!node || visited.has(node)) continue; + visited.add(node); + + try { + const shadowRoot = /** @type {any} */ (node).shadowRoot; + if (shadowRoot) { + try { + const shadowMatches = shadowRoot.querySelectorAll(selector); + for (let i = 0; i < shadowMatches.length; i++) { + if (recordMatch(shadowMatches[i])) { + // Early exit: either found first match (allowMultiple) or found multiple (not allowed) + return { element: firstMatch, matchCount: allowMultiple ? 1 : 2 }; + } + } + } catch (e) { + return { + element: null, + matchCount: 0, + error: `Invalid CSS selector "${selector}": ${e.message || e}`, + }; + } + + // Add shadow root children to stack + try { + const shadowChildren = shadowRoot.children || []; + for (let i = 0; i < shadowChildren.length; i++) { + stack.push(shadowChildren[i]); + } + } catch (_) {} + } + } catch (_) {} + + // Add regular children to stack + try { + const children = /** @type {Element} */ (node).children || []; + for (let i = 0; i < children.length; i++) { + stack.push(children[i]); + } + } catch (_) {} + } + + return { element: firstMatch, matchCount: Math.min(matchCount, 2) }; + } + + /** + * Query XPath selector and return match info including uniqueness check. + * @param {string} selector - XPath selector to query + * @param {boolean} allowMultiple - If true, skip uniqueness check and return first match + * @returns {{element: Element | null, matchCount: number, error?: string}} + * Note: matchCount is capped at 2 (where 2 means "2 or more") for performance + */ + function queryXPathWithUniquenessCheck(selector, allowMultiple = false) { + if (!selector) { + return { element: null, matchCount: 0 }; + } + + try { + if (allowMultiple) { + // When multiple matches are allowed, use ANY_UNORDERED_NODE_TYPE for performance + // This returns just the first match without evaluating the entire result set + const result = document.evaluate( + selector, + document, + null, + XPathResult.ANY_UNORDERED_NODE_TYPE, + null, + ); + const firstMatch = + result.singleNodeValue instanceof Element + ? /** @type {Element} */ (result.singleNodeValue) + : null; + return { element: firstMatch, matchCount: firstMatch ? 1 : 0 }; + } else { + // When uniqueness is required, use ORDERED_NODE_SNAPSHOT_TYPE to count matches + const snapshot = document.evaluate( + selector, + document, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + const totalMatches = snapshot.snapshotLength; + // Cap at 2 for performance (2 means "2 or more") + const matchCount = Math.min(totalMatches, 2); + const firstMatch = + totalMatches > 0 && snapshot.snapshotItem(0) instanceof Element + ? /** @type {Element} */ (snapshot.snapshotItem(0)) + : null; + return { element: firstMatch, matchCount }; + } + } catch (e) { + return { + element: null, + matchCount: 0, + error: `Invalid XPath "${selector}": ${e.message || e}`, + }; + } + } + + /** + * Whether to include element in tree under config + * @param {Element} el + * @param {{filter?: 'all'|'interactive'}} cfg + */ + function shouldInclude(el, cfg) { + const tag = el.tagName.toLowerCase(); + if (['script', 'style', 'meta', 'link', 'title', 'noscript'].includes(tag)) return false; + if (el.getAttribute('aria-hidden') === 'true') return false; + if (!isVisible(el)) return false; + if (cfg.filter !== 'all') { + const r = /** @type {HTMLElement} */ (el).getBoundingClientRect(); + if ( + !(r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0) + ) + return false; + } + if (cfg.filter === 'interactive') return isInteractive(el); + if (isInteractive(el)) return true; + if (isStructural(el)) return true; + if (inferLabel(el).length > 0) return true; + return isFormishContainer(el); + } + + /** + * Generate a fairly stable CSS selector + * @param {Element} el + * @returns {string} + */ + function generateSelector(el) { + if (!(el instanceof Element)) return ''; + if (/** @type {HTMLElement} */ (el).id) { + const idSel = `#${CSS.escape(/** @type {HTMLElement} */ (el).id)}`; + if (document.querySelectorAll(idSel).length === 1) return idSel; + } + for (const attr of ['data-testid', 'data-cy', 'name']) { + const attrValue = el.getAttribute(attr); + if (attrValue) { + const s = `[${attr}="${CSS.escape(attrValue)}"]`; + if (document.querySelectorAll(s).length === 1) return s; + } + } + let path = ''; + let current = el; + while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') { + let selector = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === current.tagName, + ); + if (siblings.length > 1) { + const index = siblings.indexOf(current) + 1; + selector += `:nth-of-type(${index})`; + } + } + path = path ? `${selector} > ${path}` : selector; + current = parent; + } + return path ? `body > ${path}` : 'body'; + } + + /** + * Traverse DOM and build pageContent lines; collect ref map for interactive nodes. + * @param {Element} el + * @param {number} depth + * @param {{filter?: 'all'|'interactive', maxDepth?: number}} cfg + * @param {string[]} out + * @param {Array<{ref:string, selector:string, rect:{x:number,y:number,width:number,height:number}}>} refMap + */ + function traverse(el, depth, cfg, out, refMap, state) { + const maxDepth = cfg && typeof cfg.maxDepth === 'number' ? cfg.maxDepth : MAX_DEPTH; + if (depth > maxDepth || !el || !el.tagName) return; + if (state.processed >= MAX_NODES) return; + if (state.visited.has(el)) return; + state.visited.add(el); + const include = shouldInclude(el, cfg) || depth === 0; + if (include) { + const role = inferRole(el); + let label = inferLabel(el); + let refId = null; + for (const k in window.__claudeElementMap) { + if (window.__claudeElementMap[k].deref && window.__claudeElementMap[k].deref() === el) { + refId = k; + break; + } + } + if (!refId) { + refId = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[refId] = new WeakRef(el); + } + const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect(); + const cx = Math.round(rect.left + rect.width / 2); + const cy = Math.round(rect.top + rect.height / 2); + let line = `${' '.repeat(depth)}- ${role}`; + if (label) { + label = label.replace(/\s+/g, ' ').substring(0, MAX_LINE_LABEL); + line += ` "${label.replace(/"/g, '\\"')}"`; + } + line += ` [ref=${refId}] (x=${cx},y=${cy})`; + if (/** @type {HTMLElement} */ (el).id) line += ` id="${/** @type {HTMLElement} */ (el).id}"`; + const href = el.getAttribute('href'); + if (href) line += ` href="${href}"`; + const type = el.getAttribute('type'); + if (type) line += ` type="${type}"`; + const placeholder = el.getAttribute('placeholder'); + if (placeholder) line += ` placeholder="${placeholder}"`; + // Surface disabled/pointer-events for better agent judgement + try { + const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true'; + if (disabled) line += ` disabled`; + const cs = window.getComputedStyle(/** @type {HTMLElement} */ (el)); + if (cs && cs.pointerEvents === 'none') line += ` pe=none`; + } catch (_) { + /* ignore style issues */ + } + out.push(line); + state.included++; + state.processed++; + + // Only collect ref mapping for interactive elements to limit cost + if (isInteractive(el) && refMap.length < REF_MAP_LIMIT) { + refMap.push({ + ref: /** @type {string} */ (refId), + selector: generateSelector(el), + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + }); + } + } + if (state.processed >= MAX_NODES) return; + // Traverse light DOM children + if (/** @type {HTMLElement} */ (el).children && depth < maxDepth) { + const children = /** @type {HTMLElement} */ (el).children; + for (let i = 0; i < children.length; i++) { + if (state.processed >= MAX_NODES) break; + traverse(children[i], include ? depth + 1 : depth, cfg, out, refMap, state); + } + } + // Traverse shadow DOM roots (limited by maxDepth and MAX_NODES) + try { + const anyEl = /** @type {any} */ (el); + if (anyEl && anyEl.shadowRoot && depth < maxDepth) { + const srChildren = anyEl.shadowRoot.children || []; + for (let i = 0; i < srChildren.length; i++) { + if (state.processed >= MAX_NODES) break; + traverse(srChildren[i], include ? depth + 1 : depth, cfg, out, refMap, state); + } + } + } catch (_) { + /* ignore shadow errors */ + } + } + + /** + * Generate tree and return + * @param {'all'|'interactive'|null} filter + * @param {{maxDepth?: number, refId?: string}|undefined} options + */ + function __generateAccessibilityTree(filter, options) { + try { + const start = performance && performance.now ? performance.now() : Date.now(); + const out = []; + const cfg = { filter: filter || undefined }; + + // Clamp maxDepth to MAX_DEPTH to keep costs bounded + if (options && Number.isFinite(options.maxDepth)) { + const d = Math.max(0, Math.floor(Number(options.maxDepth))); + cfg.maxDepth = Math.min(d, MAX_DEPTH); + } + + const refMap = []; + const state = { processed: 0, included: 0, visited: new WeakSet() }; + + // Determine root element (body or refId-specified element) + let focus = null; + let root = document.body; + if (options && options.refId) { + const refIdStr = String(options.refId || '').trim(); + if (refIdStr) { + const el = resolveRef(refIdStr); + if (!el || !(el instanceof Element)) { + return { error: `ref "${refIdStr}" not found or expired` }; + } + root = el; + focus = { refId: refIdStr }; + } + } + + if (root) traverse(root, 0, cfg, out, refMap, state); + for (const k in window.__claudeElementMap) { + if (!window.__claudeElementMap[k].deref || !window.__claudeElementMap[k].deref()) + delete window.__claudeElementMap[k]; + } + const pageContent = out + .filter((line) => !/^\s*- generic \[ref=ref_\d+\]$/.test(line)) + .join('\n'); + const end = performance && performance.now ? performance.now() : Date.now(); + return { + pageContent, + focus, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio || 1, + }, + stats: { + processed: state.processed, + included: state.included, + durationMs: Math.round(end - start), + }, + refMap, + }; + } catch (err) { + throw new Error( + 'Error generating accessibility tree: ' + + (err && err.message ? err.message : 'Unknown error'), + ); + } + } + + // Expose API on window + window.__generateAccessibilityTree = __generateAccessibilityTree; + + // ============================================================================ + // Hover for Ref (DOM Fallback Support) + // ============================================================================ + + async function handleHoverForRef(ref) { + if (!ref) return { success: false, error: 'ref is required' }; + const el = resolveRef(ref); + if (el) { + dispatchHoverEvents(el); + return { success: true, target: summarizeElement(el) }; + } + return await forwardHoverRefToChildren(ref); + } + + function resolveRef(ref) { + const map = window.__claudeElementMap || {}; + const weak = map[ref]; + return weak && typeof weak.deref === 'function' ? weak.deref() : null; + } + + function dispatchHoverEvents(el) { + const rect = el.getBoundingClientRect(); + const center = { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }; + ['mousemove', 'mouseover', 'mouseenter'].forEach((type) => { + el.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: center.x, + clientY: center.y, + view: window, + }), + ); + }); + } + + function summarizeElement(el) { + return { + tagName: el.tagName, + id: el.id || '', + className: el.className || '', + text: (el.textContent || '').trim().slice(0, 100), + }; + } + + function forwardHoverRefToChildren(ref) { + return new Promise((resolve) => { + const frames = Array.from(document.querySelectorAll('iframe, frame')); + if (!frames.length) { + resolve({ success: false, error: `ref "${ref}" not found` }); + return; + } + const reqId = `hover_ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const listener = (ev) => { + const data = ev?.data; + if (!data || data.type !== 'rr-bridge-hover-ref-result' || data.reqId !== reqId) return; + window.removeEventListener('message', listener, true); + resolve(data.result); + }; + window.addEventListener('message', listener, true); + setTimeout(() => { + window.removeEventListener('message', listener, true); + resolve({ success: false, error: `ref "${ref}" not found in child frames` }); + }, 1500); + for (const frame of frames) { + try { + frame.contentWindow?.postMessage({ type: 'rr-bridge-hover-ref', reqId, ref }, '*'); + } catch {} + } + }); + } + + // Chrome message bridge for ping and tree generation + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + try { + if (request && request.action === 'chrome_read_page_ping') { + sendResponse({ status: 'pong' }); + return false; + } + if (request && request.action === 'rr_overlay') { + try { + const cmd = request.cmd || 'init'; + let root = document.getElementById('__rr_overlay_root'); + if (!root) { + root = document.createElement('div'); + root.id = '__rr_overlay_root'; + Object.assign(root.style, { + position: 'fixed', + right: '8px', + bottom: '8px', + zIndex: 2_147_483_647, + maxWidth: '40vw', + maxHeight: '40vh', + overflow: 'auto', + background: 'rgba(0,0,0,0.6)', + color: '#fff', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: '12px', + padding: '8px', + borderRadius: '6px', + boxShadow: '0 2px 8px rgba(0,0,0,0.3)', + }); + const title = document.createElement('div'); + title.textContent = 'Record-Replay 运行日志'; + Object.assign(title.style, { fontWeight: 'bold', marginBottom: '6px' }); + const body = document.createElement('div'); + body.id = '__rr_overlay_body'; + root.appendChild(title); + root.appendChild(body); + document.documentElement.appendChild(root); + } + const body = document.getElementById('__rr_overlay_body'); + if (cmd === 'append' && body) { + const line = document.createElement('div'); + line.textContent = String(request.text || ''); + body.appendChild(line); + body.scrollTop = body.scrollHeight; + } + if (cmd === 'done' && root) { + root.style.opacity = '0.5'; + } + sendResponse({ success: true }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + // Element picker: start a temporary overlay to let user pick an element + if (request && request.action === 'rr_picker_start') { + try { + // state + const state = { active: true }; + const hostId = '__rr_picker_host__'; + let host = document.getElementById(hostId); + if (host) host.remove(); + host = document.createElement('div'); + host.id = hostId; + Object.assign(host.style, { + position: 'fixed', + inset: '0', + zIndex: 2147483646, + cursor: 'crosshair', + background: 'rgba(0,0,0,0.0)', + }); + const box = document.createElement('div'); + Object.assign(box.style, { + position: 'fixed', + border: '2px solid #3b82f6', + background: 'rgba(59,130,246,0.15)', + pointerEvents: 'none', + }); + const tip = document.createElement('div'); + tip.textContent = '点击选取元素(Esc 取消)'; + Object.assign(tip.style, { + position: 'fixed', + top: '10px', + left: '10px', + background: 'rgba(0,0,0,0.7)', + color: '#fff', + padding: '6px 10px', + borderRadius: '6px', + fontSize: '12px', + fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', + }); + host.appendChild(box); + host.appendChild(tip); + document.documentElement.appendChild(host); + + const cleanup = () => { + try { + host.remove(); + } catch {} + try { + document.removeEventListener('mousemove', onMove, true); + } catch {} + try { + document.removeEventListener('click', onClick, true); + } catch {} + try { + document.removeEventListener('keydown', onKey, true); + } catch {} + state.active = false; + }; + + const onMove = (e) => { + if (!state.active) return; + const el = e.target instanceof Element ? e.target : null; + if (!el) return; + try { + const r = el.getBoundingClientRect(); + Object.assign(box.style, { + left: `${Math.round(r.left)}px`, + top: `${Math.round(r.top)}px`, + width: `${Math.round(Math.max(0, r.width))}px`, + height: `${Math.round(Math.max(0, r.height))}px`, + display: r.width > 0 && r.height > 0 ? 'block' : 'none', + }); + } catch {} + }; + const uniqueClassSelector = (node) => { + try { + const classes = Array.from(node.classList || []).filter( + (c) => c && /^[a-zA-Z0-9_-]+$/.test(c), + ); + for (const cls of classes) { + const sel = `.${CSS.escape(cls)}`; + if (document.querySelectorAll(sel).length === 1) return sel; + } + const tag = node.tagName ? node.tagName.toLowerCase() : ''; + for (const cls of classes) { + const sel = `${tag}.${CSS.escape(cls)}`; + if (document.querySelectorAll(sel).length === 1) return sel; + } + for (let i = 0; i < Math.min(classes.length, 3); i++) { + for (let j = i + 1; j < Math.min(classes.length, 3); j++) { + const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`; + if (document.querySelectorAll(sel).length === 1) return sel; + } + } + } catch {} + return ''; + }; + const computeCandidates = (el) => { + const cands = []; + // css by id / class / short path + if (el.id) { + const idSel = `#${CSS.escape(el.id)}`; + if (document.querySelectorAll(idSel).length === 1) + cands.push({ type: 'css', value: idSel }); + } + const classSel = uniqueClassSelector(el); + if (classSel) cands.push({ type: 'css', value: classSel }); + // data-* and name + for (const attr of ['data-testid', 'data-cy', 'name']) { + const val = el.getAttribute(attr); + if (val) { + const s = `[${attr}="${CSS.escape(val)}"]`; + if (document.querySelectorAll(s).length === 1) + cands.push({ type: 'attr', value: s }); + } + } + // aria + const aria = el.getAttribute && el.getAttribute('aria-label'); + if (aria) cands.push({ type: 'aria', value: `textbox[name=${aria}]` }); + // text for clickable + const tag = (el.tagName || '').toLowerCase(); + if (['button', 'a', 'summary'].includes(tag)) { + const text = (el.textContent || '').trim(); + if (text) cands.push({ type: 'text', value: text.substring(0, 64) }); + } + // fallback path selector + const gen = (node) => { + if (!(node instanceof Element)) return ''; + let path = ''; + let current = node; + while ( + current && + current.nodeType === Node.ELEMENT_NODE && + current.tagName !== 'BODY' + ) { + let sel = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === current.tagName, + ); + if (siblings.length > 1) { + const index = siblings.indexOf(current) + 1; + sel += `:nth-of-type(${index})`; + } + } + path = path ? `${sel} > ${path}` : sel; + current = parent; + } + return path ? `body > ${path}` : 'body'; + }; + const pathSel = gen(el); + if (pathSel) cands.push({ type: 'css', value: pathSel }); + return cands; + }; + const onClick = (e) => { + if (!state.active) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.target instanceof Element ? e.target : null; + if (!el) { + cleanup(); + sendResponse({ success: false, error: 'no element' }); + return true; + } + // create ref + try { + if (!window.__claudeElementMap) window.__claudeElementMap = {}; + if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; + } catch {} + let refId = null; + try { + for (const k in window.__claudeElementMap) { + if ( + window.__claudeElementMap[k].deref && + window.__claudeElementMap[k].deref() === el + ) { + refId = k; + break; + } + } + if (!refId) { + refId = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[refId] = new WeakRef(el); + } + } catch {} + const cands = computeCandidates(el); + cleanup(); + sendResponse({ success: true, ref: refId, candidates: cands }); + return true; + }; + const onKey = (e) => { + if (e.key === 'Escape') { + cleanup(); + sendResponse({ success: false, cancelled: true }); + } + }; + document.addEventListener('mousemove', onMove, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKey, true); + return true; // async + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'rr_picker_stop') { + try { + const host = document.getElementById('__rr_picker_host__'); + if (host) host.remove(); + sendResponse({ success: true }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'generateAccessibilityTree') { + const result = __generateAccessibilityTree(request.filter || null, { + maxDepth: request.depth, + refId: request.refId, + }); + if (result && result.error) { + sendResponse({ success: false, error: result.error }); + return true; + } + sendResponse({ success: true, ...result }); + return true; + } + if (request && request.action === 'ensureRefForSelector') { + try { + // Composite selector support: "frameSelector |> innerSelector" + const maybeSel = String(request.selector || '').trim(); + const allowMultiple = !!request.allowMultiple; + if (maybeSel.includes('|>')) { + try { + const parts = maybeSel + .split('|>') + .map((s) => s.trim()) + .filter(Boolean); + if (parts.length >= 2) { + const frameSel = parts[0]; + const innerSel = parts.slice(1).join(' |> '); + // Find target frame element in current document + let frameEl = null; + try { + frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel); + } catch {} + if ( + !frameEl || + !(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement) + ) { + sendResponse({ + success: false, + error: `Composite frame selector not found: ${frameSel}`, + }); + return true; + } + const cw = frameEl.contentWindow; + if (!cw) { + sendResponse({ + success: false, + error: 'Unable to obtain contentWindow of target frame', + }); + return true; + } + // Bridge to child frame via postMessage with timeout + const reqId = `rrc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const BRIDGE_TIMEOUT_MS = 5000; // 5 second timeout for iframe bridge + let responded = false; + let timeoutHandle = null; + + const cleanup = () => { + window.removeEventListener('message', listener, true); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + }; + + const listener = (ev) => { + try { + const data = ev && ev.data; + if ( + !data || + data.type !== 'rr-bridge-ensure-ref-result' || + data.reqId !== reqId + ) + return; + // Validate source is the expected frame (security check) + if (ev.source !== cw) return; + + if (responded) return; // Already timed out + responded = true; + cleanup(); + + if (data.success) { + sendResponse({ + success: true, + ref: data.ref, + center: data.center, + href: data.href, + }); + } else { + sendResponse({ success: false, error: data.error || 'child failed' }); + } + } catch (e) { + if (!responded) { + responded = true; + cleanup(); + sendResponse({ + success: false, + error: String(e && e.message ? e.message : e), + }); + } + } + }; + + // Set up timeout to prevent infinite wait + timeoutHandle = setTimeout(() => { + if (!responded) { + responded = true; + cleanup(); + sendResponse({ + success: false, + error: `iframe bridge timeout after ${BRIDGE_TIMEOUT_MS}ms`, + }); + } + }, BRIDGE_TIMEOUT_MS); + + window.addEventListener('message', listener, true); + cw.postMessage( + { + type: 'rr-bridge-ensure-ref', + reqId, + selector: innerSel, + useText: !!request.useText, + isXPath: !!request.isXPath, + tagName: String(request.tagName || ''), + allowMultiple: !!request.allowMultiple, + }, + '*', + ); + return true; // async response via message bridge + } + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + // Support CSS selector, XPath, or visible text search + const useText = !!request.useText; + const textQuery = String(request.text || '').trim(); + const sel = String(request.selector || '').trim(); + const isXPath = !!request.isXPath; + const limitTag = String(request.tagName || '') + .trim() + .toUpperCase(); + let el = null; + if (useText && textQuery) { + const normalize = (s) => + String(s || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + const query = normalize(textQuery); + const bigrams = (s) => { + const arr = []; + for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2)); + return arr; + }; + const dice = (a, b) => { + if (!a || !b) return 0; + const A = bigrams(a); + const B = bigrams(b); + if (A.length === 0 || B.length === 0) return 0; + let inter = 0; + const map = new Map(); + for (const t of A) map.set(t, (map.get(t) || 0) + 1); + for (const t of B) { + const c = map.get(t) || 0; + if (c > 0) { + inter++; + map.set(t, c - 1); + } + } + return (2 * inter) / (A.length + B.length); + }; + let best = { el: null, score: 0 }; + // Deep traversal including shadow roots + const stack = [document.documentElement]; + let visited = 0; + while (stack.length) { + const node = /** @type {any} */ (stack.pop()); + if (!node || !(node instanceof Element)) continue; + try { + if (limitTag && String(node.tagName || '').toUpperCase() !== limitTag) { + // still traverse into children/shadow for performance? yes + } else { + const cs = window.getComputedStyle(node); + if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') { + /* skip hidden */ + } else { + const rect = /** @type {HTMLElement} */ (node).getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const txt = normalize(node.textContent || ''); + if (txt) { + if (txt.includes(query)) { + el = /** @type {Element} */ (node); + break; + } + const sc = dice(txt, query); + if (sc > best.score) + best = { el: /** @type {Element} */ (node), score: sc }; + } + } + } + } + } catch {} + // push children and shadow children + try { + const children = node.children || []; + for (let i = 0; i < children.length; i++) stack.push(children[i]); + } catch {} + try { + const sr = node.shadowRoot; + if (sr && sr.children) { + for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]); + } + } catch {} + if (++visited > 8000) break; + } + if (!el && best.el && best.score >= 0.6) el = best.el; + } else if (isXPath) { + if (!sel) { + sendResponse({ success: false, error: 'selector is required' }); + return true; + } + const result = queryXPathWithUniquenessCheck(sel, allowMultiple); + if (result.error) { + sendResponse({ success: false, error: result.error }); + return true; + } + if (result.matchCount === 0) { + sendResponse({ success: false, error: `selector not found: ${sel}` }); + return true; + } + if (!allowMultiple && result.matchCount > 1) { + sendResponse({ + success: false, + error: `Selector "${sel}" matched multiple elements. Please refine the selector to match only one element.`, + }); + return true; + } + el = result.element; + } else { + if (!sel) { + sendResponse({ success: false, error: 'selector is required' }); + return true; + } + const result = querySelectorWithUniquenessCheck(sel, allowMultiple); + if (result.error) { + sendResponse({ success: false, error: result.error }); + return true; + } + if (result.matchCount === 0) { + sendResponse({ success: false, error: `selector not found: ${sel}` }); + return true; + } + if (!allowMultiple && result.matchCount > 1) { + sendResponse({ + success: false, + error: `Selector "${sel}" matched multiple elements. Please refine the selector to match only one element.`, + }); + return true; + } + el = result.element; + } + if (!el) { + sendResponse({ success: false, error: `selector not found: ${sel}` }); + return true; + } + let refId = null; + for (const k in window.__claudeElementMap) { + if (window.__claudeElementMap[k].deref && window.__claudeElementMap[k].deref() === el) { + refId = k; + break; + } + } + if (!refId) { + refId = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[refId] = new WeakRef(el); + } + const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect(); + sendResponse({ + success: true, + ref: refId, + center: { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }, + }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'dispatchHoverForRef') { + handleHoverForRef(String(request.ref || '').trim()) + .then((result) => sendResponse(result)) + .catch((error) => + sendResponse({ success: false, error: error?.message || String(error) }), + ); + return true; + } + if (request && request.action === 'getAttributeForSelector') { + try { + const sel = String(request.selector || '').trim(); + const name = String(request.name || '').trim(); + if (!sel || !name) { + sendResponse({ success: false, error: 'selector and name are required' }); + return true; + } + const el = document.querySelector(sel) || querySelectorDeepFirst(sel); + if (!el) { + sendResponse({ success: false, error: `selector not found: ${sel}` }); + return true; + } + let value = null; + if (name === 'text' || name === 'textContent') { + value = (el.textContent || '').trim(); + } else if (name === 'value') { + try { + value = /** @type {HTMLInputElement} */ (el).value ?? null; + } catch (_) { + value = el.getAttribute('value'); + } + } else { + value = el.getAttribute(name); + } + sendResponse({ success: true, value }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'collectVariables') { + try { + let vars = Array.isArray(request.variables) ? request.variables : []; + if ((!vars || vars.length === 0) && request.payload) { + try { + const p = JSON.parse(String(request.payload || '{}')); + if (Array.isArray(p.variables)) vars = p.variables; + } catch {} + } + const useOverlay = request.useOverlay !== false; // default true + const values = {}; + if (!useOverlay) { + for (const v of vars) { + const key = String(v && v.key ? v.key : ''); + if (!key) continue; + const label = v.label || key; + const def = v.default || ''; + const promptText = `请输入参数 ${label} (${key})`; + let val = window.prompt(promptText, def); + if (typeof val !== 'string') val = def; + values[key] = val; + } + sendResponse({ success: true, values }); + return true; + } + // Build overlay form + const hostId = '__rr_var_overlay__'; + let host = document.getElementById(hostId); + if (host) host.remove(); + host = document.createElement('div'); + host.id = hostId; + Object.assign(host.style, { + position: 'fixed', + inset: '0', + background: 'rgba(0,0,0,0.35)', + zIndex: 2147483646, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }); + const panel = document.createElement('div'); + Object.assign(panel.style, { + background: '#fff', + borderRadius: '8px', + width: 'min(520px, 96vw)', + maxHeight: '80vh', + overflow: 'auto', + boxShadow: '0 8px 24px rgba(0,0,0,0.2)', + padding: '16px', + fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif', + }); + const title = document.createElement('div'); + title.textContent = '请输入回放参数'; + Object.assign(title.style, { fontSize: '16px', fontWeight: '600', marginBottom: '12px' }); + const form = document.createElement('form'); + for (const v of vars) { + const row = document.createElement('div'); + Object.assign(row.style, { marginBottom: '10px' }); + const label = document.createElement('label'); + label.textContent = `${v.label || v.key}${v.sensitive ? ' (敏感)' : ''}`; + Object.assign(label.style, { + display: 'block', + marginBottom: '6px', + fontWeight: '500', + }); + const input = document.createElement('input'); + input.type = v.sensitive ? 'password' : 'text'; + input.name = String(v.key); + input.value = String(v.default || ''); + Object.assign(input.style, { + width: '100%', + boxSizing: 'border-box', + padding: '8px 10px', + border: '1px solid #d0d7de', + borderRadius: '6px', + outline: 'none', + }); + row.appendChild(label); + row.appendChild(input); + form.appendChild(row); + } + const actions = document.createElement('div'); + Object.assign(actions.style, { display: 'flex', gap: '8px', marginTop: '12px' }); + const ok = document.createElement('button'); + ok.type = 'submit'; + ok.textContent = '确定'; + Object.assign(ok.style, { + background: '#0969da', + color: '#fff', + border: 'none', + padding: '8px 16px', + borderRadius: '6px', + cursor: 'pointer', + }); + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.textContent = '取消'; + Object.assign(cancel.style, { + background: '#f3f4f6', + color: '#111', + border: '1px solid #d0d7de', + padding: '8px 16px', + borderRadius: '6px', + cursor: 'pointer', + }); + actions.appendChild(ok); + actions.appendChild(cancel); + panel.appendChild(title); + panel.appendChild(form); + panel.appendChild(actions); + host.appendChild(panel); + document.documentElement.appendChild(host); + + const cleanup = () => { + try { + host.remove(); + } catch {} + }; + cancel.onclick = () => { + cleanup(); + sendResponse({ success: false, cancelled: true }); + }; + form.onsubmit = (e) => { + e.preventDefault(); + for (const v of vars) { + const el = form.querySelector(`input[name="${CSS.escape(String(v.key))}"]`); + if (el) values[v.key] = /** @type {HTMLInputElement} */ (el).value; + } + cleanup(); + sendResponse({ success: true, values }); + }; + return true; // async + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'resolveRef') { + const ref = request.ref; + try { + const map = window.__claudeElementMap; + const weak = map && map[ref]; + const el = weak && typeof weak.deref === 'function' ? weak.deref() : null; + if (!el || !(el instanceof Element)) { + sendResponse({ success: false, error: `ref "${ref}" not found or expired` }); + return true; + } + const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect(); + sendResponse({ + success: true, + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + center: { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }, + selector: (function () { + // Simple selector generation inline to avoid duplication + const generateSelector = function (node) { + if (!(node instanceof Element)) return ''; + if (node.id) { + const idSel = `#${CSS.escape(node.id)}`; + if (document.querySelectorAll(idSel).length === 1) return idSel; + } + // prefer unique class selectors if available + try { + const classes = Array.from(node.classList || []).filter( + (c) => c && /^[a-zA-Z0-9_-]+$/.test(c), + ); + for (const cls of classes) { + const sel = `.${CSS.escape(cls)}`; + if (document.querySelectorAll(sel).length === 1) return sel; + } + const tag = node.tagName ? node.tagName.toLowerCase() : ''; + for (const cls of classes) { + const sel = `${tag}.${CSS.escape(cls)}`; + if (document.querySelectorAll(sel).length === 1) return sel; + } + for (let i = 0; i < Math.min(classes.length, 3); i++) { + for (let j = i + 1; j < Math.min(classes.length, 3); j++) { + const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`; + if (document.querySelectorAll(sel).length === 1) return sel; + } + } + } catch {} + for (const attr of ['data-testid', 'data-cy', 'name']) { + const val = node.getAttribute(attr); + if (val) { + const s = `[${attr}="${CSS.escape(val)}"]`; + if (document.querySelectorAll(s).length === 1) return s; + } + } + let path = ''; + let current = node; + while ( + current && + current.nodeType === Node.ELEMENT_NODE && + current.tagName !== 'BODY' + ) { + let sel = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + (c) => c.tagName === current.tagName, + ); + if (siblings.length > 1) { + const idx = siblings.indexOf(current) + 1; + sel += `:nth-of-type(${idx})`; + } + } + path = path ? `${sel} > ${path}` : sel; + current = parent; + } + return path ? `body > ${path}` : 'body'; + }; + return generateSelector(el); + })(), + }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'verifyFingerprint') { + try { + const ref = String(request.ref || '').trim(); + const fingerprint = String(request.fingerprint || '').trim(); + if (!ref || !fingerprint) { + sendResponse({ success: false, error: 'ref and fingerprint are required' }); + return true; + } + const map = window.__claudeElementMap; + const weak = map && map[ref]; + const el = weak && typeof weak.deref === 'function' ? weak.deref() : null; + if (!el || !(el instanceof Element)) { + sendResponse({ success: false, error: `ref "${ref}" not found or expired` }); + return true; + } + // 验证指纹:解析存储的指纹并与当前元素对比 + const parts = fingerprint.split('|'); + const storedTag = parts[0] || 'unknown'; + const currentTag = el.tagName ? String(el.tagName).toLowerCase() : 'unknown'; + // Tag 必须匹配 + if (storedTag !== currentTag) { + sendResponse({ success: true, match: false }); + return true; + } + // 如果存储的指纹有 id,当前元素必须有相同的 id + const storedIdPart = parts.find((p) => p.startsWith('id=')); + if (storedIdPart) { + const storedId = storedIdPart.slice(3); + const currentId = el.id ? String(el.id).trim() : ''; + if (storedId !== currentId) { + sendResponse({ success: true, match: false }); + return true; + } + } + sendResponse({ success: true, match: true }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + if (request && request.action === 'focusByRef') { + try { + const ref = String(request.ref || ''); + const map = window.__claudeElementMap || {}; + const weak = map[ref]; + const el = weak && typeof weak.deref === 'function' ? weak.deref() : null; + if (!el || !(el instanceof Element)) { + sendResponse({ success: false, error: `ref "${ref}" not found or expired` }); + return true; + } + try { + /** @type {HTMLElement} */ (el).scrollIntoView({ + behavior: 'instant', + block: 'center', + inline: 'nearest', + }); + } catch {} + try { + /** @type {HTMLElement} */ (el).focus && /** @type {HTMLElement} */ (el).focus(); + } catch {} + sendResponse({ success: true }); + return true; + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + } + } catch (e) { + sendResponse({ success: false, error: e && e.message ? e.message : String(e) }); + return true; + } + return false; + }); + + console.log('Accessibility tree helper script loaded'); + // Cross-frame bridge: child listens for ensure-ref requests from parent (composite selector) + try { + window.addEventListener( + 'message', + (ev) => { + try { + const data = ev && ev.data; + // Handle hover-ref bridge requests from parent frame + if (data && data.type === 'rr-bridge-hover-ref') { + handleHoverForRef(data.ref) + .then((result) => { + ev.source?.postMessage( + { type: 'rr-bridge-hover-ref-result', reqId: data.reqId, result }, + '*', + ); + }) + .catch((error) => { + ev.source?.postMessage( + { + type: 'rr-bridge-hover-ref-result', + reqId: data.reqId, + result: { success: false, error: error?.message || String(error) }, + }, + '*', + ); + }); + return; + } + if (!data || data.type !== 'rr-bridge-ensure-ref') return; + const { reqId, selector, useText, isXPath, tagName } = data || {}; + const respond = (payload) => { + try { + ev.source && + ev.source.postMessage( + { type: 'rr-bridge-ensure-ref-result', reqId, ...payload }, + '*', + ); + } catch {} + }; + try { + const sel = String(selector || '').trim(); + const limitTag = String(tagName || '') + .trim() + .toUpperCase(); + let el = null; + if (useText && sel) { + const normalize = (s) => + String(s || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + const query = normalize(sel); + const bigrams = (s) => { + const arr = []; + for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2)); + return arr; + }; + const dice = (a, b) => { + if (!a || !b) return 0; + const A = bigrams(a), + B = bigrams(b); + if (!A.length || !B.length) return 0; + let inter = 0; + const m = new Map(); + for (const t of A) m.set(t, (m.get(t) || 0) + 1); + for (const t of B) { + const c = m.get(t) || 0; + if (c > 0) { + inter++; + m.set(t, c - 1); + } + } + return (2 * inter) / (A.length + B.length); + }; + let best = { el: null, score: 0 }; + const stack = [document.documentElement]; + while (stack.length) { + const node = stack.pop(); + if (!node || !(node instanceof Element)) continue; + try { + if (limitTag && String(node.tagName || '').toUpperCase() !== limitTag) { + } else { + const cs = window.getComputedStyle(node); + if (cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') { + const rect = node.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const txt = normalize(node.textContent || ''); + if (txt) { + if (txt.includes(query)) { + el = node; + break; + } + const sc = dice(txt, query); + if (sc > best.score) best = { el: node, score: sc }; + } + } + } + } + } catch {} + try { + const children = node.children || []; + for (let i = 0; i < children.length; i++) stack.push(children[i]); + const sr = node.shadowRoot; + if (sr && sr.children) + for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]); + } catch {} + } + if (!el && best.el) el = best.el; + } else if (isXPath) { + if (!sel) { + respond({ success: false, error: 'selector is required' }); + return; + } + const allowMultiple = !!data.allowMultiple; + const result = queryXPathWithUniquenessCheck(sel, allowMultiple); + if (result.error) { + respond({ success: false, error: result.error }); + return; + } + if (result.matchCount === 0) { + respond({ success: false, error: `Selector "${sel}" not found in child frame` }); + return; + } + if (!allowMultiple && result.matchCount > 1) { + respond({ + success: false, + error: `Selector "${sel}" matched multiple elements inside frame. Please refine the selector to match only one element.`, + }); + return; + } + el = result.element; + } else { + if (!sel) { + respond({ success: false, error: 'selector is required' }); + return; + } + const allowMultiple = !!data.allowMultiple; + const result = querySelectorWithUniquenessCheck(sel, allowMultiple); + if (result.error) { + respond({ success: false, error: result.error }); + return; + } + if (result.matchCount === 0) { + respond({ success: false, error: `Selector "${sel}" not found in child frame` }); + return; + } + if (!allowMultiple && result.matchCount > 1) { + respond({ + success: false, + error: `Selector "${sel}" matched multiple elements inside frame. Please refine the selector to match only one element.`, + }); + return; + } + el = result.element; + } + if (!el || !(el instanceof Element)) { + respond({ success: false, error: 'Element not found in child frame' }); + return; + } + if (!window.__claudeElementMap) window.__claudeElementMap = {}; + if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; + let refId = null; + for (const k in window.__claudeElementMap) { + const w = window.__claudeElementMap[k]; + if (w && typeof w.deref === 'function' && w.deref && w.deref() === el) { + refId = k; + break; + } + } + if (!refId) { + refId = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[refId] = new WeakRef(el); + } + const rect = el.getBoundingClientRect(); + respond({ + success: true, + ref: refId, + center: { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }, + href: String(location && location.href ? location.href : ''), + }); + } catch (e) { + respond({ success: false, error: String(e && e.message ? e.message : e) }); + } + } catch {} + }, + true, + ); + } catch {} +})(); diff --git a/app/chrome-extension/inject-scripts/click-helper.js b/app/chrome-extension/inject-scripts/click-helper.js new file mode 100644 index 0000000..4b95de3 --- /dev/null +++ b/app/chrome-extension/inject-scripts/click-helper.js @@ -0,0 +1,370 @@ +/* eslint-disable */ +// click-helper.js +// This script is injected into the page to handle click operations + +if (window.__CLICK_HELPER_INITIALIZED__) { + // Already initialized, skip +} else { + window.__CLICK_HELPER_INITIALIZED__ = true; + /** + * Click on an element matching the selector or at specific coordinates + * @param {string} selector - CSS selector for the element to click + * @param {boolean} waitForNavigation - Whether to wait for navigation to complete after click + * @param {number} timeout - Timeout in milliseconds for waiting for the element or navigation + * @param {Object} coordinates - Optional coordinates for clicking at a specific position + * @param {number} coordinates.x - X coordinate relative to the viewport + * @param {number} coordinates.y - Y coordinate relative to the viewport + * @returns {Promise} - Result of the click operation + */ + async function clickElement( + selector, + waitForNavigation = false, + timeout = 5000, + coordinates = null, + ref = null, + double = false, + options = {}, + ) { + try { + let element = null; + let elementInfo = null; + let clickX, clickY; + + if (ref && typeof ref === 'string') { + // Resolve element from weak map + let target = null; + try { + const map = window.__claudeElementMap; + const weak = map && map[ref]; + target = weak && typeof weak.deref === 'function' ? weak.deref() : null; + } catch (e) { + // ignore + } + + if (!target || !(target instanceof Element)) { + return { + error: `Element ref "${ref}" not found. Please call chrome_read_page first and ensure the ref is still valid.`, + }; + } + + element = target; + element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' }); + await new Promise((resolve) => setTimeout(resolve, 80)); + + const rect = element.getBoundingClientRect(); + clickX = rect.left + rect.width / 2; + clickY = rect.top + rect.height / 2; + elementInfo = { + tagName: element.tagName, + id: element.id, + className: element.className, + text: element.textContent?.trim().substring(0, 100) || '', + href: element.href || null, + type: element.type || null, + isVisible: true, + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + clickMethod: 'ref', + ref, + }; + } else if ( + coordinates && + typeof coordinates.x === 'number' && + typeof coordinates.y === 'number' + ) { + clickX = coordinates.x; + clickY = coordinates.y; + + element = document.elementFromPoint(clickX, clickY); + + if (element) { + const rect = element.getBoundingClientRect(); + elementInfo = { + tagName: element.tagName, + id: element.id, + className: element.className, + text: element.textContent?.trim().substring(0, 100) || '', + href: element.href || null, + type: element.type || null, + isVisible: true, + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + clickMethod: 'coordinates', + clickPosition: { x: clickX, y: clickY }, + }; + } else { + elementInfo = { + clickMethod: 'coordinates', + clickPosition: { x: clickX, y: clickY }, + warning: 'No element found at the specified coordinates', + }; + } + } else { + element = document.querySelector(selector); + if (!element) { + return { + error: `Element with selector "${selector}" not found`, + }; + } + + const rect = element.getBoundingClientRect(); + elementInfo = { + tagName: element.tagName, + id: element.id, + className: element.className, + text: element.textContent?.trim().substring(0, 100) || '', + href: element.href || null, + type: element.type || null, + isVisible: true, + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + clickMethod: 'selector', + }; + + // First sroll so that the element is in view, then check visibility. + element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' }); + await new Promise((resolve) => setTimeout(resolve, 100)); + elementInfo.isVisible = isElementVisible(element); + if (!elementInfo.isVisible) { + return { + error: `Element with selector "${selector}" is not visible`, + elementInfo, + }; + } + + const updatedRect = element.getBoundingClientRect(); + clickX = updatedRect.left + updatedRect.width / 2; + clickY = updatedRect.top + updatedRect.height / 2; + } + + let navigationPromise; + if (waitForNavigation) { + navigationPromise = new Promise((resolve) => { + const beforeUnloadListener = () => { + window.removeEventListener('beforeunload', beforeUnloadListener); + resolve(true); + }; + window.addEventListener('beforeunload', beforeUnloadListener); + + setTimeout(() => { + window.removeEventListener('beforeunload', beforeUnloadListener); + resolve(false); + }, timeout); + }); + } + + if ( + element && + (elementInfo.clickMethod === 'selector' || elementInfo.clickMethod === 'ref') + ) { + if (double) { + dispatchClickSequence(element, clickX, clickY, options, true); + } else { + dispatchClickSequence(element, clickX, clickY, options, false); + } + } else { + if (double) simulateDoubleClick(clickX, clickY, options); + else simulateClick(clickX, clickY, options); + } + + // Wait for navigation if needed + let navigationOccurred = false; + if (waitForNavigation) { + navigationOccurred = await navigationPromise; + } + + return { + success: true, + message: 'Element clicked successfully', + elementInfo, + navigationOccurred, + }; + } catch (error) { + return { + error: `Error clicking element: ${error.message}`, + }; + } + } + + /** + * Simulate a mouse click at specific coordinates + * @param {number} x - X coordinate relative to the viewport + * @param {number} y - Y coordinate relative to the viewport + */ + function simulateClick(x, y, options = {}) { + const element = document.elementFromPoint(x, y); + if (!element) return; + dispatchClickSequence(element, x, y, options, false); + } + + /** + * Simulate a double click sequence at specific coordinates + */ + function simulateDoubleClick(x, y, options = {}) { + const element = document.elementFromPoint(x, y); + if (!element) return; + dispatchClickSequence(element, x, y, options, true); + } + + /** + * Simulate double click using element when available + */ + function simulateDomDoubleClick(element, x, y, options) { + dispatchClickSequence(element, x, y, options, true); + } + + function normalizeMouseOpts(x, y, options = {}) { + const bubbles = options.bubbles !== false; // default true + const cancelable = options.cancelable !== false; // default true + const altKey = !!(options.modifiers && options.modifiers.altKey); + const ctrlKey = !!(options.modifiers && options.modifiers.ctrlKey); + const metaKey = !!(options.modifiers && options.modifiers.metaKey); + const shiftKey = !!(options.modifiers && options.modifiers.shiftKey); + const btn = String(options.button || 'left'); + const button = btn === 'right' ? 2 : btn === 'middle' ? 1 : 0; + const buttons = btn === 'right' ? 2 : btn === 'middle' ? 4 : 1; + return { + bubbles, + cancelable, + altKey, + ctrlKey, + metaKey, + shiftKey, + button, + buttons, + clientX: x, + clientY: y, + view: window, + }; + } + + function dispatchClickSequence(element, x, y, options = {}, isDouble = false) { + const base = normalizeMouseOpts(x, y, options); + const down = new MouseEvent('mousedown', base); + const up = new MouseEvent('mouseup', base); + const click = new MouseEvent('click', base); + try { + element.dispatchEvent(down); + } catch {} + try { + element.dispatchEvent(up); + } catch {} + try { + element.dispatchEvent(click); + } catch {} + if (base.button === 2) { + // right button contextmenu + const ctx = new MouseEvent('contextmenu', base); + try { + element.dispatchEvent(ctx); + } catch {} + } + if (isDouble) { + // second sequence + dblclick + setTimeout(() => { + try { + element.dispatchEvent(new MouseEvent('mousedown', base)); + } catch {} + try { + element.dispatchEvent(new MouseEvent('mouseup', base)); + } catch {} + try { + element.dispatchEvent(new MouseEvent('click', base)); + } catch {} + try { + element.dispatchEvent(new MouseEvent('dblclick', base)); + } catch {} + }, 30); + } + } + + /** + * Check if an element is visible + * @param {Element} element - The element to check + * @returns {boolean} - Whether the element is visible + */ + function isElementVisible(element) { + if (!element) return false; + + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return false; + } + + if ( + rect.bottom < 0 || + rect.top > window.innerHeight || + rect.right < 0 || + rect.left > window.innerWidth + ) { + return false; + } + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const elementAtPoint = document.elementFromPoint(centerX, centerY); + if (!elementAtPoint) return false; + + return element === elementAtPoint || element.contains(elementAtPoint); + } + + // Listen for messages from the extension + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.action === 'clickElement') { + clickElement( + request.selector, + request.waitForNavigation, + request.timeout, + request.coordinates, + request.ref, + !!request.double, + { + button: request.button, + bubbles: request.bubbles, + cancelable: request.cancelable, + modifiers: request.modifiers, + }, + ) + .then(sendResponse) + .catch((error) => { + sendResponse({ + error: `Unexpected error: ${error.message}`, + }); + }); + return true; // Indicates async response + } else if (request.action === 'chrome_click_element_ping') { + sendResponse({ status: 'pong' }); + return false; + } + }); +} diff --git a/app/chrome-extension/inject-scripts/dom-observer.js b/app/chrome-extension/inject-scripts/dom-observer.js new file mode 100644 index 0000000..c323f0d --- /dev/null +++ b/app/chrome-extension/inject-scripts/dom-observer.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +// dom-observer.js - observe DOM for triggers and notify background +(function () { + if (window.__RR_DOM_OBSERVER__) return; + window.__RR_DOM_OBSERVER__ = true; + + const active = { triggers: [], hits: new Map() }; + + function now() { + return Date.now(); + } + + function applyTriggers(list) { + try { + active.triggers = Array.isArray(list) ? list.slice() : []; + active.hits.clear(); + checkAll(); + } catch (e) {} + } + + function checkAll() { + try { + for (const t of active.triggers) { + maybeFire(t); + } + } catch (e) {} + } + + function maybeFire(t) { + try { + const appear = t.appear !== false; // default true + const sel = String(t.selector || '').trim(); + if (!sel) return; + const exists = !!document.querySelector(sel); + const key = t.id; + const last = active.hits.get(key) || 0; + const debounce = Math.max(0, Number(t.debounceMs ?? 800)); + if (now() - last < debounce) return; + const should = appear ? exists : !exists; + if (should) { + active.hits.set(key, now()); + chrome.runtime.sendMessage({ + action: 'dom_trigger_fired', + triggerId: t.id, + url: location.href, + }); + if (t.once !== false) removeTrigger(t.id); + } + } catch (e) {} + } + + function removeTrigger(id) { + try { + active.triggers = active.triggers.filter((x) => x.id !== id); + } catch (e) {} + } + + const mo = new MutationObserver(() => { + checkAll(); + }); + try { + mo.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); + } catch (e) {} + + chrome.runtime.onMessage.addListener((req, _sender, sendResponse) => { + try { + if (req && req.action === 'dom_observer_ping') { + sendResponse({ status: 'pong' }); + return false; + } + if (req && req.action === 'set_dom_triggers') { + applyTriggers(req.triggers || []); + sendResponse({ success: true, count: active.triggers.length }); + return true; + } + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + return false; + }); +})(); diff --git a/app/chrome-extension/inject-scripts/element-marker.js b/app/chrome-extension/inject-scripts/element-marker.js new file mode 100644 index 0000000..ccc837c --- /dev/null +++ b/app/chrome-extension/inject-scripts/element-marker.js @@ -0,0 +1,2802 @@ +/* eslint-disable */ +(function () { + if (window.__ELEMENT_MARKER_INSTALLED__) return; + window.__ELEMENT_MARKER_INSTALLED__ = true; + + const IS_MAIN = window === window.top; + + // ============================================================================ + // Utility Functions + // ============================================================================ + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // ============================================================================ + // Constants & Configuration + // ============================================================================ + + const CONFIG = { + DEFAULTS: { + PREFS: { + preferId: true, + preferStableAttr: true, + preferClass: true, + }, + SELECTOR_TYPE: 'css', + LIST_MODE: false, + }, + Z_INDEX: { + OVERLAY: 2147483646, + HIGHLIGHTER: 2147483645, + RECTS: 2147483644, + }, + COLORS: { + PRIMARY: '#2563eb', + SUCCESS: '#10b981', + WARNING: '#f59e0b', + DANGER: '#ef4444', + HOVER: '#10b981', + VERIFY: '#3b82f6', + }, + }; + + // ============================================================================ + // Panel Host Module - Shadow DOM Management + // ============================================================================ + + const PanelHost = (() => { + let hostElement = null; + let shadowRoot = null; + + const PANEL_STYLES = ` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + .em-panel { + width: 400px; + background: #ffffff; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + padding: 20px; + transition: opacity 150ms ease; + } + + + /* Header */ + .em-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + user-select: none; + } + + .em-title { + font-size: 20px; + font-weight: 500; + color: #262626; + } + + .em-header-actions { + display: flex; + gap: 4px; + align-items: center; + } + + .em-icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: #a3a3a3; + cursor: pointer; + transition: color 150ms ease; + padding: 0; + } + + .em-icon-btn:hover { + color: #525252; + } + + .em-icon-btn svg { + width: 20px; + height: 20px; + stroke-width: 2; + } + + /* Controls Row */ + .em-controls { + display: flex; + gap: 8px; + margin-bottom: 12px; + } + + .em-select-wrapper { + flex: 1; + position: relative; + } + + .em-select { + width: 100%; + height: 44px; + padding: 0 40px 0 16px; + background: #f5f5f5; + color: #262626; + font-size: 15px; + border: none; + border-radius: 10px; + appearance: none; + cursor: pointer; + outline: none; + font-family: inherit; + font-weight: 400; + } + + .em-select-wrapper::after { + content: ''; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #737373; + pointer-events: none; + } + + .em-square-btn { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border: none; + border-radius: 10px; + cursor: pointer; + transition: background 150ms ease; + padding: 0; + } + + .em-square-btn:hover { + background: #e5e5e5; + } + + .em-square-btn.active { + background: #2563eb; + } + + .em-square-btn.active svg { + color: #ffffff; + } + + .em-square-btn svg { + width: 18px; + height: 18px; + color: #525252; + stroke-width: 2; + } + + /* Selector Display */ + .em-selector-display { + display: flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 12px 0 16px; + background: #f5f5f5; + border-radius: 10px; + margin-bottom: 16px; + } + + .em-selector-display svg { + width: 18px; + height: 18px; + color: #a3a3a3; + flex-shrink: 0; + stroke-width: 2; + } + + .em-selector-text { + flex: 1; + font-size: 14px; + color: #525252; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; + } + + .em-selector-nav { + display: flex; + gap: 2px; + } + + .em-nav-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + transition: background 150ms ease; + border-radius: 6px; + padding: 0; + } + + .em-nav-btn:hover { + background: #e5e5e5; + } + + .em-nav-btn svg { + width: 16px; + height: 16px; + color: #525252; + stroke-width: 2; + } + + /* Tabs */ + .em-tabs { + display: inline-flex; + gap: 2px; + padding: 2px; + background: #f5f5f5; + border-radius: 8px; + margin-bottom: 16px; + } + + .em-tab { + padding: 6px 16px; + font-size: 12px; + font-weight: 500; + color: #737373; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 150ms ease; + } + + .em-tab:hover { + color: #404040; + } + + .em-tab.active { + color: #262626; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + /* Content */ + .em-content { + margin-bottom: 0; + } + + #__em_tab_settings { + max-height: min(60vh, 480px); + overflow-y: auto; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + } + + #__em_tab_settings::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + } + + .em-section-title { + font-size: 13px; + color: #737373; + margin-bottom: 16px; + font-weight: 400; + } + + .em-attributes { + display: flex; + flex-direction: column; + gap: 12px; + } + + .em-attribute { + display: flex; + flex-direction: column; + gap: 6px; + } + + .em-attribute-label { + font-size: 12px; + color: #a3a3a3; + font-weight: 400; + } + + .em-attribute-value { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 0 12px 0 16px; + background: #f5f5f5; + border-radius: 10px; + } + + .em-attribute-value.editable { + padding: 0 16px; + } + + .em-attribute-value svg { + width: 18px; + height: 18px; + stroke-width: 2; + cursor: pointer; + transition: color 150ms ease; + flex-shrink: 0; + } + + .em-attribute-value svg.copy-icon { + color: #a3a3a3; + } + + .em-attribute-value svg.copy-icon:hover { + color: #525252; + } + + .em-attribute-value svg.copy-icon.disabled { + color: #d4d4d4; + cursor: default; + } + + .em-attribute-text { + flex: 1; + font-size: 14px; + color: #404040; + user-select: text; + } + + .em-attribute-text.empty { + color: #a3a3a3; + } + + .em-input { + flex: 1; + border: none; + background: transparent; + font-size: 14px; + color: #404040; + font-family: inherit; + outline: none; + padding: 0; + height: 44px; + } + + .em-input::placeholder { + color: #a3a3a3; + } + + /* Settings Panel */ + .em-settings { + display: flex; + flex-direction: column; + gap: 16px; + } + + .em-settings-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .em-settings-label { + font-size: 12px; + font-weight: 500; + color: #737373; + } + + .em-checkbox-group { + display: flex; + flex-direction: column; + gap: 10px; + } + + .em-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #404040; + cursor: pointer; + } + + .em-checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + margin: 0; + } + + /* Action Buttons */ + .em-actions { + display: flex; + gap: 8px; + margin-top: 20px; + } + + .em-btn { + flex: 1; + height: 40px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; + } + + .em-btn-primary { + background: #2563eb; + color: #ffffff; + } + + .em-btn-primary:hover { + background: #1d4ed8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); + } + + .em-btn-success { + background: #10b981; + color: #ffffff; + } + + .em-btn-success:hover { + background: #059669; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); + } + + .em-btn-ghost { + background: #f5f5f5; + color: #404040; + } + + .em-btn-ghost:hover { + background: #e5e5e5; + } + + /* Footer */ + .em-footer { + font-size: 12px; + color: #a3a3a3; + text-align: center; + margin-top: 16px; + } + + .em-footer kbd { + display: inline-block; + padding: 2px 6px; + background: #f5f5f5; + border-radius: 4px; + font-family: monospace; + font-size: 11px; + color: #737373; + } + + /* Status */ + .em-status { + font-size: 13px; + padding: 10px 12px; + border-radius: 8px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; + } + + .em-status.idle { + display: none; + } + + .em-status.running { + background: rgba(37, 99, 235, 0.1); + color: #2563eb; + } + + .em-status.success { + background: rgba(16, 185, 129, 0.1); + color: #10b981; + } + + .em-status.failure { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + } + + /* Grid Layout */ + .em-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .em-field { + display: flex; + flex-direction: column; + gap: 6px; + } + + .em-field-label { + font-size: 12px; + color: #a3a3a3; + } + + .em-field-input { + height: 40px; + padding: 0 12px; + background: #f5f5f5; + border: none; + border-radius: 8px; + font-size: 14px; + color: #404040; + font-family: inherit; + outline: none; + } + + .em-field-input:focus { + background: #e5e5e5; + } + + /* Details/Accordion */ + .em-details { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #f5f5f5; + } + + .em-details summary { + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: #737373; + padding: 8px 0; + user-select: none; + list-style: none; + } + + .em-details summary::-webkit-details-marker { + display: none; + } + + .em-details summary:hover { + color: #404040; + } + + .em-details[open] summary { + margin-bottom: 12px; + } + + /* Dragging state */ + body[data-em-dragging] { + user-select: none !important; + cursor: grabbing !important; + } + + body[data-em-dragging] * { + cursor: grabbing !important; + } + + /* SVG Icons */ + svg { + fill: none; + stroke: currentColor; + } + + .em-drag-handle { + cursor: grab; + } + + .em-drag-handle:active { + cursor: grabbing; + } + `; + + const PANEL_TEMPLATE = ` +
+ +
+

元素标注

+
+ +
+
+ + +
+
+ +
+ + +
+ + +
+ + + + Click an element to select +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+

#1 Element

+ +
+
+
name
+
+ +
+
+ +
+
selector
+
+ + + + - +
+
+
+ +

Selector Preferences

+
+
+ + + +
+
+ +
+ +
+ +
+ + +
+
+ + + + + + +
+ `; + + function mount() { + if (hostElement) return { host: hostElement, shadow: shadowRoot }; + + hostElement = document.createElement('div'); + hostElement.id = '__element_marker_overlay'; + Object.assign(hostElement.style, { + position: 'fixed', + top: '24px', + right: '24px', + zIndex: String(CONFIG.Z_INDEX.OVERLAY), + pointerEvents: 'none', + }); + + shadowRoot = hostElement.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = `${PANEL_TEMPLATE}`; + + hostElement.querySelector = (...args) => shadowRoot.querySelector(...args); + hostElement.querySelectorAll = (...args) => shadowRoot.querySelectorAll(...args); + + const panel = shadowRoot.querySelector('.em-panel'); + if (panel) { + panel.style.pointerEvents = 'auto'; + } + + document.documentElement.appendChild(hostElement); + return { host: hostElement, shadow: shadowRoot }; + } + + function unmount() { + if (hostElement?.parentNode) { + hostElement.parentNode.removeChild(hostElement); + } + hostElement = null; + shadowRoot = null; + } + + function getHost() { + return hostElement; + } + + function getShadow() { + return shadowRoot; + } + + return { + mount, + unmount, + getHost, + getShadow, + }; + })(); + + // ============================================================================ + // State Store Module - Centralized State Management + // ============================================================================ + + const StateStore = (() => { + const state = { + selectorType: CONFIG.DEFAULTS.SELECTOR_TYPE, + listMode: CONFIG.DEFAULTS.LIST_MODE, + prefs: { ...CONFIG.DEFAULTS.PREFS }, + activeTab: 'attributes', + validation: { + status: 'idle', + message: '', + }, + validationHistory: [], // Last 5 validation results + }; + + const listeners = new Set(); + + function init() { + return state; + } + + function get(key) { + return key ? state[key] : state; + } + + function set(partial) { + const changed = {}; + + Object.keys(partial).forEach((key) => { + if (JSON.stringify(state[key]) !== JSON.stringify(partial[key])) { + changed[key] = true; + state[key] = partial[key]; + } + }); + + if (Object.keys(changed).length === 0) return; + + if (changed.validation) { + updateValidationUI(); + } + if (changed.activeTab) { + updateTabUI(); + } + if (changed.listMode) { + updateListModeUI(); + } + if (changed.validationHistory) { + updateValidationHistoryUI(); + } + + notifyListeners(); + } + + function subscribe(callback) { + listeners.add(callback); + return () => listeners.delete(callback); + } + + function notifyListeners() { + listeners.forEach((cb) => { + try { + cb(state); + } catch (err) { + console.error('[StateStore] Listener error:', err); + } + }); + } + + function updateValidationUI() { + const statusEl = PanelHost.getShadow()?.getElementById('__em_status'); + if (!statusEl) return; + + const { status, message } = state.validation; + statusEl.className = `em-status ${status}`; + statusEl.textContent = message; + } + + function updateListModeUI() { + const shadow = PanelHost.getShadow(); + if (!shadow) return; + + const btn = shadow.getElementById('__em_toggle_list'); + if (!btn) return; + + if (state.listMode) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + } + + function updateTabUI() { + const shadow = PanelHost.getShadow(); + if (!shadow) return; + + const tabs = shadow.querySelectorAll('.em-tab'); + tabs.forEach((tab) => { + if (tab.dataset.tab === state.activeTab) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + const attrContent = shadow.getElementById('__em_tab_attributes'); + const executeContent = shadow.getElementById('__em_tab_execute'); + + if (attrContent) + attrContent.style.display = state.activeTab === 'attributes' ? 'block' : 'none'; + if (executeContent) + executeContent.style.display = state.activeTab === 'execute' ? 'block' : 'none'; + + // Sync interaction mode when tab changes + syncInteractionMode(); + } + + function updateValidationHistoryUI() { + const shadow = PanelHost.getShadow(); + if (!shadow) return; + + const historyContainer = shadow.getElementById('__em_execution_history'); + const historyList = shadow.getElementById('__em_history_list'); + if (!historyContainer || !historyList) return; + + if (state.validationHistory.length === 0) { + historyContainer.style.display = 'none'; + return; + } + + historyContainer.style.display = 'block'; + historyList.innerHTML = state.validationHistory + .slice(-5) + .reverse() + .map((entry) => { + const icon = entry.success ? '✓' : '✗'; + const color = entry.success ? '#10b981' : '#ef4444'; + const timestamp = new Date(entry.timestamp).toLocaleTimeString(); + return `
+ ${icon} + ${entry.action} + ${timestamp} +
`; + }) + .join(''); + } + + return { + init, + get, + set, + subscribe, + }; + })(); + + // ============================================================================ + // Drag Controller Module + // ============================================================================ + + const DragController = (() => { + let dragging = false; + let startPos = { x: 0, y: 0 }; + let startOffset = { top: 0, right: 0 }; + + function init(handleElement) { + if (!handleElement) return; + handleElement.addEventListener('mousedown', onDragStart); + } + + function onDragStart(event) { + event.preventDefault(); + dragging = true; + + const host = PanelHost.getHost(); + if (!host) return; + + startPos = { x: event.clientX, y: event.clientY }; + startOffset = { + top: parseInt(host.style.top) || 0, + right: parseInt(host.style.right) || 0, + }; + + document.addEventListener('mousemove', onDragMove, { capture: true, passive: false }); + document.addEventListener('mouseup', onDragEnd, { capture: true, passive: false }); + document.body.setAttribute('data-em-dragging', 'true'); + } + + function onDragMove(event) { + if (!dragging) return; + event.preventDefault(); + event.stopPropagation(); + + const host = PanelHost.getHost(); + if (!host) return; + + const deltaX = event.clientX - startPos.x; + const deltaY = event.clientY - startPos.y; + + const newTop = Math.max(8, startOffset.top + deltaY); + const newRight = Math.max(8, startOffset.right - deltaX); + + host.style.top = `${newTop}px`; + host.style.right = `${newRight}px`; + } + + function onDragEnd(event) { + if (!dragging) return; + event.preventDefault(); + event.stopPropagation(); + + dragging = false; + document.removeEventListener('mousemove', onDragMove, { capture: true }); + document.removeEventListener('mouseup', onDragEnd, { capture: true }); + document.body.removeAttribute('data-em-dragging'); + } + + function destroy() { + if (dragging) { + onDragEnd(new MouseEvent('mouseup')); + } + } + + return { init, destroy }; + })(); + + // [继续下一部分...] + // ============================================================================ + // Selector Engine - Heuristic Selector Generation + // ============================================================================ + + function generateSelector(el) { + if (!(el instanceof Element)) return ''; + + const prefs = StateStore.get('prefs'); + + if (prefs.preferId && el.id) { + const idSel = `#${CSS.escape(el.id)}`; + if (isDeepSelectorUnique(idSel, el)) return idSel; + } + + if (prefs.preferStableAttr) { + const attrNames = [ + 'data-testid', + 'data-testId', + 'data-test', + 'data-qa', + 'data-cy', + 'name', + 'title', + 'alt', + 'aria-label', + ]; + const tag = el.tagName.toLowerCase(); + + for (const attr of attrNames) { + const v = el.getAttribute(attr); + if (!v) continue; + const attrSel = `[${attr}="${CSS.escape(v)}"]`; + const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel; + if (isDeepSelectorUnique(testSel, el)) return testSel; + } + } + + if (prefs.preferClass) { + try { + const classes = Array.from(el.classList || []).filter( + (c) => c && /^[a-zA-Z0-9_-]+$/.test(c), + ); + const tag = el.tagName.toLowerCase(); + + for (const cls of classes) { + const sel = `.${CSS.escape(cls)}`; + if (isDeepSelectorUnique(sel, el)) return sel; + } + + for (const cls of classes) { + const sel = `${tag}.${CSS.escape(cls)}`; + if (isDeepSelectorUnique(sel, el)) return sel; + } + + for (let i = 0; i < Math.min(classes.length, 3); i++) { + for (let j = i + 1; j < Math.min(classes.length, 3); j++) { + const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`; + if (isDeepSelectorUnique(sel, el)) return sel; + } + } + } catch {} + } + + if (prefs.preferStableAttr) { + try { + let cur = el; + const anchorAttrs = [ + 'id', + 'data-testid', + 'data-testId', + 'data-test', + 'data-qa', + 'data-cy', + 'name', + ]; + + // Detect shadow DOM boundary + const root = el.getRootNode(); + const isShadowElement = root instanceof ShadowRoot; + const boundary = isShadowElement ? root.host : document.body; + + while (cur && cur !== boundary) { + if (cur.id) { + const anchor = `#${CSS.escape(cur.id)}`; + if (isDeepSelectorUnique(anchor, cur)) { + const rel = buildPathFromAncestor(cur, el); + const composed = rel ? `${anchor} ${rel}` : anchor; + if (isDeepSelectorUnique(composed, el)) return composed; + } + } + + for (const attr of anchorAttrs) { + const val = cur.getAttribute(attr); + if (!val) continue; + const aSel = `[${attr}="${CSS.escape(val)}"]`; + if (isDeepSelectorUnique(aSel, cur)) { + const rel = buildPathFromAncestor(cur, el); + const composed = rel ? `${aSel} ${rel}` : aSel; + if (isDeepSelectorUnique(composed, el)) return composed; + } + } + cur = cur.parentElement; + } + } catch {} + } + + return buildFullPath(el); + } + + function buildPathFromAncestor(ancestor, target) { + const segs = []; + let cur = target; + + // Detect if we're inside shadow DOM + const root = target.getRootNode(); + const isShadowElement = root instanceof ShadowRoot; + const boundary = isShadowElement ? root.host : document.body; + + while (cur && cur !== ancestor && cur !== boundary) { + let seg = cur.tagName.toLowerCase(); + const parent = cur.parentElement; + + if (parent) { + const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName); + if (siblings.length > 1) { + seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`; + } + } + + segs.unshift(seg); + cur = parent; + + // Stop if we've reached the shadow root host + if (isShadowElement && cur === boundary) { + break; + } + } + + return segs.join(' > '); + } + + function buildFullPath(el) { + let path = ''; + let current = el; + + // Detect if the element is inside a shadow DOM + const root = el.getRootNode(); + const isShadowElement = root instanceof ShadowRoot; + + // Determine the boundary where we should stop traversing + const boundary = isShadowElement ? root.host : document.body; + + while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) { + let sel = current.tagName.toLowerCase(); + const parent = current.parentElement; + + if (parent) { + const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName); + if (siblings.length > 1) { + sel += `:nth-of-type(${siblings.indexOf(current) + 1})`; + } + } + + path = path ? `${sel} > ${path}` : sel; + current = parent; + + // Stop if we've reached the shadow root host + if (isShadowElement && current === boundary) { + break; + } + } + + // For shadow DOM elements, don't prepend "body >" + // The selector should be relative within the shadow tree + if (isShadowElement) { + return path || el.tagName.toLowerCase(); + } + + // For light DOM elements, keep the original behavior + return path ? `body > ${path}` : 'body'; + } + + function generateXPath(el) { + if (!(el instanceof Element)) return ''; + if (el.id) return `//*[@id="${el.id}"]`; + + const segs = []; + let cur = el; + + while (cur && cur.nodeType === 1 && cur !== document.documentElement) { + const tag = cur.tagName.toLowerCase(); + + if (cur.id) { + segs.unshift(`//*[@id="${cur.id}"]`); + break; + } + + let i = 1; + let sib = cur; + while ((sib = sib.previousElementSibling)) { + if (sib.tagName.toLowerCase() === tag) i++; + } + + segs.unshift(`${tag}[${i}]`); + cur = cur.parentElement; + } + + return segs[0]?.startsWith('//*') ? segs.join('/') : '//' + segs.join('/'); + } + + function generateListSelector(target) { + const list = computeElementList(target); + const selected = list?.[0] || target; + const parent = selected.parentElement; + + if (!parent) return generateSelector(target); + + const parentSel = generateSelector(parent); + const childRel = generateSelectorWithinRoot(selected, parent); + + return parentSel && childRel ? `${parentSel} ${childRel}` : generateSelector(target); + } + + function generateSelectorWithinRoot(el, root) { + if (!(el instanceof Element)) return ''; + + const tag = el.tagName.toLowerCase(); + + // Use isDeepSelectorUnique for ID to support shadow DOM elements + if (el.id) { + const idSel = `#${CSS.escape(el.id)}`; + if (isDeepSelectorUnique(idSel, el)) return idSel; + } + + const attrNames = [ + 'data-testid', + 'data-testId', + 'data-test', + 'data-qa', + 'data-cy', + 'name', + 'title', + 'alt', + 'aria-label', + ]; + + // Use isDeepSelectorUnique for attributes to support shadow DOM elements + for (const attr of attrNames) { + const v = el.getAttribute(attr); + if (!v) continue; + const aSel = `[${attr}="${CSS.escape(v)}"]`; + const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${aSel}` : aSel; + if (isDeepSelectorUnique(testSel, el)) return testSel; + } + + try { + const classes = Array.from(el.classList || []).filter((c) => c && /^[a-zA-Z0-9_-]+$/.test(c)); + + // Use isDeepSelectorUnique for classes to support shadow DOM elements + for (const cls of classes) { + const sel = `.${CSS.escape(cls)}`; + if (isDeepSelectorUnique(sel, el)) return sel; + } + + for (const cls of classes) { + const sel = `${tag}.${CSS.escape(cls)}`; + if (isDeepSelectorUnique(sel, el)) return sel; + } + } catch {} + + return buildPathFromAncestor(root, el); + } + + function getAccessibleName(el) { + try { + const labelledby = el.getAttribute('aria-labelledby'); + if (labelledby) { + const labelEl = document.getElementById(labelledby); + if (labelEl) return (labelEl.textContent || '').trim(); + } + + const ariaLabel = el.getAttribute('aria-label'); + if (ariaLabel) return ariaLabel.trim(); + + if (el.id) { + const label = document.querySelector(`label[for="${el.id}"]`); + if (label) return (label.textContent || '').trim(); + } + + const parentLabel = el.closest('label'); + if (parentLabel) return (parentLabel.textContent || '').trim(); + + return ( + el.getAttribute('placeholder') || + el.getAttribute('value') || + el.textContent || + '' + ).trim(); + } catch { + return ''; + } + } + + // ============================================================================ + // List Mode Utilities + // ============================================================================ + + function getAllSiblings(el, selector) { + const siblings = [el]; + const validate = (element) => { + const isSameTag = el.tagName === element.tagName; + let ok = isSameTag; + if (selector) { + try { + ok = ok && !!element.querySelector(selector); + } catch {} + } + return ok; + }; + + let next = el; + let prev = el; + let elementIndex = 1; + + while ((prev = prev?.previousElementSibling)) { + if (validate(prev)) { + elementIndex += 1; + siblings.unshift(prev); + } + } + + while ((next = next?.nextElementSibling)) { + if (validate(next)) siblings.push(next); + } + + return { elements: siblings, index: elementIndex }; + } + + function getElementList(el, maxDepth = 50, paths = []) { + if (maxDepth === 0 || !el || el.tagName === 'BODY') return null; + + let selector = el.tagName.toLowerCase(); + const { elements, index } = getAllSiblings(el, paths.join(' > ')); + let siblings = elements; + + if (index !== 1) selector += `:nth-of-type(${index})`; + paths.unshift(selector); + + if (siblings.length === 1) { + siblings = getElementList(el.parentElement, maxDepth - 1, paths); + } + + return siblings; + } + + function computeElementList(target) { + try { + return getElementList(target) || [target]; + } catch { + return [target]; + } + } + + // ============================================================================ + // Deep Query (Shadow DOM Support) + // ============================================================================ + + function* walkAllNodesDeep(root) { + const stack = [root]; + let count = 0; + const MAX = 10000; + + while (stack.length) { + const node = stack.pop(); + if (!node || ++count > MAX) continue; + + // Skip overlay elements to prevent panel self-highlighting + if (isOverlayElement(node)) { + continue; + } + + yield node; + + try { + if (node.children) { + const children = Array.from(node.children); + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } + } + + if (node.shadowRoot?.children) { + const srChildren = Array.from(node.shadowRoot.children); + for (let i = srChildren.length - 1; i >= 0; i--) { + stack.push(srChildren[i]); + } + } + } catch {} + } + } + + function queryAllDeep(selector) { + const results = []; + for (const node of walkAllNodesDeep(document)) { + if (!(node instanceof Element)) continue; + try { + if (node.matches(selector)) results.push(node); + } catch {} + } + return results; + } + + /** + * Check if a selector uniquely identifies the target element across the entire DOM tree, + * including shadow DOM boundaries. + * + * This function uses queryAllDeep to traverse both light DOM and shadow DOM, + * ensuring that selectors work correctly for elements inside shadow roots. + * + * @param {string} selector - The CSS selector to test + * @param {Element} target - The target element that should be uniquely identified + * @returns {boolean} True if the selector matches exactly one element and it's the target + */ + function isDeepSelectorUnique(selector, target) { + if (!selector || !(target instanceof Element)) return false; + try { + const matches = queryAllDeep(selector); + return matches.length === 1 && matches[0] === target; + } catch (error) { + return false; + } + } + + function evaluateXPathAll(xpath) { + try { + const arr = []; + const res = document.evaluate( + xpath, + document, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + + for (let i = 0; i < res.snapshotLength; i++) { + const n = res.snapshotItem(i); + // Filter out overlay elements to prevent panel self-highlighting + if (n?.nodeType === 1 && !isOverlayElement(n)) { + arr.push(n); + } + } + return arr; + } catch { + return []; + } + } + + // ============================================================================ + // Highlighter & Rects Management + // ============================================================================ + + const STATE = { + active: false, + hoverEl: null, + selectedEl: null, + box: null, + highlighter: null, + listenersAttached: false, + rectsHost: null, + hoveredList: [], + verifyRectsActive: false, // Track if verify rects are showing + // Performance optimization: rAF throttling for hover + hoverRafId: null, + lastHoverTarget: null, + // DOM pooling for rect elements + rectPool: [], + rectPoolUsed: 0, + }; + + function ensureHighlighter() { + if (STATE.highlighter) return STATE.highlighter; + + const hl = document.createElement('div'); + hl.id = '__element_marker_highlight'; + Object.assign(hl.style, { + position: 'fixed', + zIndex: String(CONFIG.Z_INDEX.HIGHLIGHTER), + pointerEvents: 'none', + border: `2px solid ${CONFIG.COLORS.HOVER}`, + borderRadius: '4px', + boxShadow: `0 0 0 2px ${CONFIG.COLORS.HOVER}33`, + transition: 'all 100ms ease-out', + }); + + document.documentElement.appendChild(hl); + STATE.highlighter = hl; + return hl; + } + + function ensureRectsHost() { + if (STATE.rectsHost) return STATE.rectsHost; + + const host = document.createElement('div'); + host.id = '__element_marker_rects'; + Object.assign(host.style, { + position: 'fixed', + zIndex: String(CONFIG.Z_INDEX.RECTS), + pointerEvents: 'none', + inset: '0', + }); + + document.documentElement.appendChild(host); + STATE.rectsHost = host; + return host; + } + + function moveHighlighterTo(el) { + const hl = ensureHighlighter(); + const r = el.getBoundingClientRect(); + hl.style.left = `${r.left}px`; + hl.style.top = `${r.top}px`; + hl.style.width = `${r.width}px`; + hl.style.height = `${r.height}px`; + hl.style.display = 'block'; + } + + function clearHighlighter() { + if (STATE.highlighter) STATE.highlighter.style.display = 'none'; + // Only clear hover rects, not verify rects + if (!STATE.verifyRectsActive) { + clearRects(); + } + } + + function clearRects() { + // Hide all pooled rect boxes instead of destroying them + const used = STATE.rectPoolUsed || 0; + for (let i = 0; i < used; i++) { + const box = STATE.rectPool[i]; + if (box) box.style.display = 'none'; + } + STATE.rectPoolUsed = 0; + STATE.verifyRectsActive = false; + // Invalidate lastHoverTarget so next hover will redraw even on same element + STATE.lastHoverTarget = null; + } + + /** + * Get or create a rect box from the pool + * @param {HTMLElement} host - The container element + * @param {number} index - The pool index + * @returns {HTMLDivElement} The rect box element + */ + function getOrCreateRectBox(host, index) { + let box = STATE.rectPool[index]; + if (!box) { + box = document.createElement('div'); + Object.assign(box.style, { + position: 'fixed', + pointerEvents: 'none', + borderRadius: '4px', + transition: 'all 100ms ease-out', + display: 'none', + }); + STATE.rectPool[index] = box; + } + // Ensure the box is attached to the host + if (!box.isConnected) { + host.appendChild(box); + } + return box; + } + + // Maximum rect pool size to prevent memory bloat + const MAX_RECT_POOL_SIZE = 100; + + /** + * Draw rect boxes with pooling optimization + * @param {Array<{x: number, y: number, width: number, height: number}>} rects - Rect data + * @param {Object} options - Drawing options + * @param {boolean} options.isVerify - Whether this is a verify highlight (affects verifyRectsActive) + */ + function drawRectBoxes( + rects, + { color = CONFIG.COLORS.HOVER, dashed = true, offsetX = 0, offsetY = 0, isVerify = false } = {}, + ) { + const host = ensureRectsHost(); + const prevUsed = STATE.rectPoolUsed || 0; + // Limit rect count to prevent memory bloat + const count = Math.min(Array.isArray(rects) ? rects.length : 0, MAX_RECT_POOL_SIZE); + + // Update or show rect boxes + for (let i = 0; i < count; i++) { + const r = rects[i]; + if (!r) continue; + + const x = Number.isFinite(r.left) ? r.left : Number.isFinite(r.x) ? r.x : 0; + const y = Number.isFinite(r.top) ? r.top : Number.isFinite(r.y) ? r.y : 0; + const w = Number.isFinite(r.width) ? r.width : 0; + const h = Number.isFinite(r.height) ? r.height : 0; + + const box = getOrCreateRectBox(host, i); + Object.assign(box.style, { + left: `${offsetX + x}px`, + top: `${offsetY + y}px`, + width: `${w}px`, + height: `${h}px`, + border: `2px ${dashed ? 'dashed' : 'solid'} ${color}`, + boxShadow: `0 0 0 2px ${color}22`, + display: 'block', + }); + } + + // Hide excess boxes from previous render + for (let i = count; i < prevUsed; i++) { + const box = STATE.rectPool[i]; + if (box) box.style.display = 'none'; + } + + STATE.rectPoolUsed = count; + // Reset verifyRectsActive for hover operations (so clearHighlighter works correctly) + // Only set to true when isVerify is explicitly true + STATE.verifyRectsActive = isVerify; + } + + function drawRects(elements, color = CONFIG.COLORS.HOVER, dashed = true, isVerify = false) { + const rects = elements.map((el) => { + const r = el.getBoundingClientRect(); + return { x: r.left, y: r.top, width: r.width, height: r.height }; + }); + drawRectBoxes(rects, { color, dashed, isVerify }); + } + + // ============================================================================ + // Interaction Logic + // ============================================================================ + + function isInsidePanel(target) { + const shadow = PanelHost.getShadow(); + return !!shadow && target instanceof Node && shadow.contains(target); + } + + /** + * Check if a node belongs to the element marker overlay (panel host or its shadow DOM) + * This is used to filter out overlay elements from query results to prevent self-highlighting + * + * @param {Node} node - The node to check + * @returns {boolean} True if the node is part of the overlay + */ + function isOverlayElement(node) { + if (!(node instanceof Node)) return false; + + const host = PanelHost.getHost(); + if (!host) return false; + + // Check if node is the panel host itself + if (node === host) return true; + + // Check if node is within the shadow DOM of the panel host + const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null; + return root instanceof ShadowRoot && root.host === host; + } + + /** + * Filter out overlay elements from an array of elements + * This ensures that panel components are never included in highlight/verification results + * + * @param {Array} elements - Array of elements to filter + * @returns {Array} Filtered array without overlay elements + */ + function filterOverlayElements(elements) { + if (!Array.isArray(elements)) return []; + return elements.filter((node) => !isOverlayElement(node)); + } + + /** + * Get the effective event target for page element selection, considering shadow DOM boundaries. + * + * This function resolves the real target element from a pointer event by walking the + * composed path (if available) to find the innermost page element, skipping overlay elements. + * + * Background: + * - When events bubble up from inside shadow DOM, they get "retargeted" at shadow boundaries + * - By the time a window-level listener receives the event, ev.target points to the shadow host + * - composedPath() exposes the original event path before retargeting + * - This allows us to select elements inside shadow DOM (e.g., internals) + * + * IMPORTANT: This function should only be called AFTER verifying the event is not from + * overlay UI (panel buttons, etc). Otherwise it will filter out overlay elements and break + * panel interactions. + * + * @param {Event} ev - The pointer event (mousemove, click, etc.) + * @returns {Element|null} The innermost non-overlay page element, or null if none found + */ + function getDeepPageTarget(ev) { + if (!ev) return null; + + // Try to walk the composed path to find the innermost non-overlay element + try { + const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null; + if (Array.isArray(path) && path.length > 0) { + // Walk from innermost to outermost, find the first real page element + for (const node of path) { + if (node instanceof Element && !isOverlayElement(node)) { + return node; + } + } + } + } catch (error) { + // composedPath() may throw in some edge cases (e.g., detached nodes) + // Fall through to use ev.target + } + + // Fallback: use ev.target if composedPath is unavailable or all nodes were filtered + const fallback = ev.target instanceof Element ? ev.target : null; + // If fallback is overlay, return null (caller should handle this case) + if (fallback && !isOverlayElement(fallback)) { + return fallback; + } + return null; + } + + // Store pending hover event for rAF processing + let pendingHoverEvent = null; + + /** + * Process mouse move event - the actual hover update logic + * Separated from onMouseMove for rAF throttling + */ + function processMouseMove(ev) { + if (!STATE.active) return; + + const rawTarget = ev?.target; + if (!(rawTarget instanceof Element)) { + STATE.hoverEl = null; + STATE.lastHoverTarget = null; + clearHighlighter(); + return; + } + + const host = PanelHost.getHost(); + if ((host && rawTarget === host) || isInsidePanel(rawTarget)) { + STATE.hoverEl = null; + STATE.lastHoverTarget = null; + clearHighlighter(); + return; + } + + const target = getDeepPageTarget(ev) || rawTarget; + STATE.hoverEl = target; + + // Get current listMode + let listMode = false; + try { + listMode = !!StateStore.get('listMode'); + } catch {} + + // Skip update if target and mode haven't changed + const last = STATE.lastHoverTarget; + if (last && last.element === target && last.listMode === listMode) { + return; + } + STATE.lastHoverTarget = { element: target, listMode }; + + if (!IS_MAIN) { + try { + const list = listMode ? computeElementList(target) || [target] : [target]; + const rects = list.map((el) => { + const r = el.getBoundingClientRect(); + return { x: r.left, y: r.top, width: r.width, height: r.height }; + }); + + // Performance: Don't generate selector on hover (defer to click) + window.top.postMessage({ type: 'em_hover', rects }, '*'); + } catch {} + return; + } + + if (listMode) { + STATE.hoveredList = computeElementList(target) || [target]; + drawRects(STATE.hoveredList); + } else { + moveHighlighterTo(target); + } + } + + /** + * Mouse move handler with rAF throttling + * Ensures hover updates are batched to animation frame rate + */ + function onMouseMove(ev) { + if (!STATE.active) return; + + // Store the latest event + pendingHoverEvent = ev; + + // Skip if already scheduled + if (STATE.hoverRafId != null) return; + + // Schedule processing on next animation frame + STATE.hoverRafId = requestAnimationFrame(() => { + STATE.hoverRafId = null; + const latest = pendingHoverEvent; + pendingHoverEvent = null; + if (!latest) return; + processMouseMove(latest); + }); + } + + // ============================================================================ + // Event Listeners Management + // ============================================================================ + + function attachPointerListeners() { + if (STATE.listenersAttached) return; + window.addEventListener('mousemove', onMouseMove, true); + window.addEventListener('click', onClick, true); + STATE.listenersAttached = true; + } + + function detachPointerListeners() { + if (!STATE.listenersAttached) return; + window.removeEventListener('mousemove', onMouseMove, true); + window.removeEventListener('click', onClick, true); + STATE.listenersAttached = false; + } + + function attachKeyboardListener() { + window.addEventListener('keydown', onKeyDown, true); + } + + function detachKeyboardListener() { + window.removeEventListener('keydown', onKeyDown, true); + } + + function syncInteractionMode() { + if (!STATE.active) return; + const activeTab = StateStore.get('activeTab'); + if (activeTab === 'execute') { + // In execute mode, detach pointer listeners to allow real interactions + // but keep keyboard listener for Esc key + detachPointerListeners(); + // Only clear the hover highlighter, not the verification rects + if (STATE.highlighter) STATE.highlighter.style.display = 'none'; + } else { + // In attributes mode, attach all listeners for element selection + attachPointerListeners(); + } + } + + // ============================================================================ + // Event Handlers + // ============================================================================ + + function onClick(ev) { + if (!STATE.active) return; + + // First, use the raw ev.target to check for overlay UI + // This ensures panel buttons and other UI elements remain interactive + const rawTarget = ev.target; + const host = PanelHost.getHost(); + + // Check if raw target is the panel host itself or inside the shadow DOM + // IMPORTANT: Return early WITHOUT preventDefault to allow overlay button clicks + if ((host && rawTarget === host) || isInsidePanel(rawTarget)) { + return; + } + + // Now we know it's a page element, prevent default and get deep target + ev.preventDefault(); + ev.stopPropagation(); + + if (!(rawTarget instanceof Element)) return; + + // Get the deep target (considering shadow DOM) after confirming it's not overlay + const target = getDeepPageTarget(ev) || rawTarget; + + if (!IS_MAIN) { + try { + const selectorType = StateStore.get('selectorType'); + const listMode = StateStore.get('listMode'); + + const sel = + selectorType === 'xpath' + ? generateXPath(target) + : listMode + ? generateListSelector(target) + : generateSelector(target); + + window.top.postMessage({ type: 'em_click', innerSel: sel }, '*'); + } catch {} + return; + } + + setSelection(target); + } + + function onKeyDown(e) { + if (!STATE.active) return; + + // Check if the focused element is inside the panel - if so, don't handle selection keys + if (isInsidePanel(e.target)) { + // Key event is from panel, don't interfere + if (e.key !== 'Escape') return; // Still allow Escape to close + } + + // In execute mode, only handle Escape to close - don't intercept other keys + // This allows real page interactions (typing, scrolling, etc.) + const activeTab = StateStore.get('activeTab'); + if (activeTab === 'execute') { + if (e.key === 'Escape') { + e.preventDefault(); + stop(); + } + return; // Don't intercept Space/Arrow keys in execute mode + } + + if (e.key === 'Escape') { + e.preventDefault(); + stop(); + } else if (e.key === ' ' || e.code === 'Space') { + e.preventDefault(); + const t = STATE.hoverEl || STATE.selectedEl; + if (t) setSelection(t); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const base = STATE.selectedEl || STATE.hoverEl; + if (base?.parentElement) setSelection(base.parentElement); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + const base = STATE.selectedEl || STATE.hoverEl; + if (base?.firstElementChild) setSelection(base.firstElementChild); + } + } + + function setSelection(el) { + if (!(el instanceof Element)) return; + + STATE.selectedEl = el; + + const selectorType = StateStore.get('selectorType'); + const listMode = StateStore.get('listMode'); + + const sel = + selectorType === 'xpath' + ? generateXPath(el) + : listMode + ? generateListSelector(el) + : generateSelector(el); + + const name = getAccessibleName(el) || el.tagName.toLowerCase(); + + const selectorText = STATE.box?.querySelector('#__em_selector'); + const inputName = STATE.box?.querySelector('#__em_name'); + const selectorDisplay = STATE.box?.querySelector('#__em_selector_text'); + + if (selectorText) selectorText.textContent = sel; + if (selectorDisplay) selectorDisplay.textContent = sel; + if (inputName && !inputName.value) inputName.value = name; + + moveHighlighterTo(el); + } + + // ============================================================================ + // Validation Logic + // ============================================================================ + + /** + * Verify selector by highlighting only (non-destructive) + */ + async function verifyHighlightOnly() { + try { + const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); + if (!selector) return; + + StateStore.set({ + validation: { status: 'running', message: 'Verifying selector...' }, + }); + + const selectorType = StateStore.get('selectorType'); + const listMode = StateStore.get('listMode'); + const effectiveType = listMode ? 'css' : selectorType; + + // Query for matches + const matches = + effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector); + + // Additional defense: filter out any overlay elements that might have slipped through + const filteredMatches = filterOverlayElements(matches); + + if (!filteredMatches || filteredMatches.length === 0) { + StateStore.set({ + validation: { status: 'failure', message: 'No elements found' }, + }); + return; + } + + // Scroll first match into view + const primaryMatch = filteredMatches[0]; + if (primaryMatch) { + primaryMatch.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'smooth', + }); + } + + await sleep(200); + + // Highlight matches with isVerify=true to prevent clearing on hover + drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false, true); + + StateStore.set({ + validation: { + status: 'success', + message: `Found ${filteredMatches.length} element${filteredMatches.length > 1 ? 's' : ''}`, + }, + }); + + // Auto-clear highlight after 2 seconds + setTimeout(() => { + clearRects(); + StateStore.set({ + validation: { status: 'idle', message: '' }, + }); + }, 2000); + } catch (error) { + console.error('[verifyHighlightOnly] error:', error); + StateStore.set({ + validation: { status: 'failure', message: error.message || 'Verification failed' }, + }); + } + } + + /** + * Execute action on selector (destructive) + */ + async function verifySelectorNow() { + try { + const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); + if (!selector) return; + + StateStore.set({ + validation: { status: 'running', message: 'Executing action...' }, + }); + + const selectorType = StateStore.get('selectorType'); + const listMode = StateStore.get('listMode'); + + const effectiveType = listMode ? 'css' : selectorType; + + const matches = + effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector); + + // Additional defense: filter out any overlay elements that might have slipped through + const filteredMatches = filterOverlayElements(matches); + + if (!filteredMatches || filteredMatches.length === 0) { + StateStore.set({ + validation: { status: 'failure', message: 'No elements found' }, + }); + return; + } + + drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false); + + const action = STATE.box?.querySelector('#__em_action')?.value || 'hover'; + + const payload = { + type: 'element_marker_validate', + selector, + selectorType: effectiveType, + action, + listMode, + }; + + // Action-specific parameters with validation + if (action === 'type_text') { + const actionText = String( + STATE.box?.querySelector('#__em_action_text')?.value || '', + ).trim(); + if (!actionText) { + StateStore.set({ + validation: { status: 'failure', message: 'Text is required for type_text' }, + }); + return; + } + payload.text = actionText; + } + + if (action === 'press_keys') { + const actionKeys = String( + STATE.box?.querySelector('#__em_action_keys')?.value || '', + ).trim(); + if (!actionKeys) { + StateStore.set({ + validation: { status: 'failure', message: 'Keys are required for press_keys' }, + }); + return; + } + payload.keys = actionKeys; + } + + if (action === 'scroll') { + const direction = STATE.box?.querySelector('#__em_scroll_direction')?.value || 'down'; + const rawAmount = Number(STATE.box?.querySelector('#__em_scroll_distance')?.value); + // Clamp to 1-10 range (backend expects ticks, not pixels) + const amount = Math.max( + 1, + Math.min(Math.round(Number.isFinite(rawAmount) ? rawAmount : 3), 10), + ); + payload.scrollDirection = direction; + payload.scrollAmount = amount; + } + + if (['left_click', 'double_click', 'right_click'].includes(action)) { + payload.modifiers = { + altKey: !!STATE.box?.querySelector('#__em_mod_alt')?.checked, + ctrlKey: !!STATE.box?.querySelector('#__em_mod_ctrl')?.checked, + metaKey: !!STATE.box?.querySelector('#__em_mod_meta')?.checked, + shiftKey: !!STATE.box?.querySelector('#__em_mod_shift')?.checked, + }; + payload.button = STATE.box?.querySelector('#__em_btn')?.value || 'left'; + payload.waitForNavigation = !!STATE.box?.querySelector('#__em_wait_nav')?.checked; + payload.timeoutMs = Number(STATE.box?.querySelector('#__em_nav_timeout')?.value) || 3000; + } + + const res = await chrome.runtime.sendMessage(payload); + + const success = !!res?.tool?.ok; + const newEntry = { + action, + success, + timestamp: Date.now(), + matchCount: filteredMatches.length, + }; + const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5); + + if (res?.tool?.ok) { + StateStore.set({ + validation: { + status: 'success', + message: `✓ 验证成功 (匹配 ${filteredMatches.length} 个元素)`, + }, + validationHistory: history, + }); + } else { + StateStore.set({ + validation: { + status: 'failure', + message: res?.tool?.error || '验证失败', + }, + validationHistory: history, + }); + } + } catch (err) { + const newEntry = { + action: STATE.box?.querySelector('#__em_action')?.value || 'hover', + success: false, + timestamp: Date.now(), + matchCount: 0, + }; + const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5); + + StateStore.set({ + validation: { + status: 'failure', + message: `错误: ${err.message}`, + }, + validationHistory: history, + }); + } + } + + /** + * Highlight selector from external request (popup/background) + * Supports composite iframe selectors: "frameSelector |> innerSelector" + */ + async function highlightSelectorExternal({ selector, selectorType = 'css', listMode = false }) { + const normalized = String(selector || '').trim(); + if (!normalized) { + return { success: false, error: 'selector is required' }; + } + + try { + // Handle composite iframe selector + if (normalized.includes('|>')) { + const parts = normalized + .split('|>') + .map((s) => s.trim()) + .filter(Boolean); + + if (parts.length >= 2) { + const frameSel = parts[0]; + const innerSel = parts.slice(1).join(' |> '); + + // Find frame element + let frameEl = null; + try { + frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel); + } catch {} + + if ( + !frameEl || + !(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement) + ) { + return { success: false, error: `Frame element not found: ${frameSel}` }; + } + + const cw = frameEl.contentWindow; + if (!cw) { + return { success: false, error: 'Unable to access frame contentWindow' }; + } + + // Forward highlight request to iframe + return new Promise((resolve) => { + const reqId = `em_highlight_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const listener = (ev) => { + try { + const data = ev?.data; + if (!data || data.type !== 'em-highlight-result' || data.reqId !== reqId) return; + window.removeEventListener('message', listener, true); + resolve(data.result); + } catch {} + }; + + window.addEventListener('message', listener, true); + setTimeout(() => { + window.removeEventListener('message', listener, true); + resolve({ success: false, error: 'Frame highlight timeout' }); + }, 3000); + + cw.postMessage( + { + type: 'em-highlight-request', + reqId, + selector: innerSel, + selectorType, + listMode, + }, + '*', + ); + }); + } + } + + // Handle normal selector (non-iframe) + const effectiveType = listMode ? 'css' : selectorType; + const matches = + effectiveType === 'xpath' ? evaluateXPathAll(normalized) : queryAllDeep(normalized); + + // Additional defense: filter out any overlay elements that might have slipped through + const filteredMatches = filterOverlayElements(matches); + + if (!filteredMatches || filteredMatches.length === 0) { + return { success: false, error: 'No elements found for selector' }; + } + + // Scroll first match into view + const primaryMatch = filteredMatches[0]; + if (primaryMatch) { + primaryMatch.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'smooth', + }); + } + + await sleep(150); + + // Draw highlight rectangles + drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false); + + // Auto-clear after 2 seconds + setTimeout(() => { + clearRects(); + }, 2000); + + return { success: true, count: filteredMatches.length }; + } catch (error) { + return { success: false, error: error.message || String(error) }; + } + } + + function copySelectorNow() { + try { + const sel = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); + if (!sel) return; + navigator.clipboard?.writeText(sel).catch(() => {}); + + StateStore.set({ + validation: { status: 'success', message: '✓ 已复制到剪贴板' }, + }); + + setTimeout(() => { + StateStore.set({ validation: { status: 'idle', message: '' } }); + }, 2000); + } catch {} + } + + async function save() { + try { + const name = STATE.box?.querySelector('#__em_name')?.value?.trim(); + const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); + + if (!selector) return; + + const url = location.href; + let selectorType = StateStore.get('selectorType'); + const listMode = StateStore.get('listMode'); + + if (listMode && selectorType === 'xpath') { + selectorType = 'css'; + } + + await chrome.runtime.sendMessage({ + type: 'element_marker_save', + marker: { + url, + name: name || selector, + selector, + selectorType, + listMode, + }, + }); + } catch {} + + stop(); + } + + // ============================================================================ + // Lifecycle Management + // ============================================================================ + + function start() { + if (STATE.active) return; + STATE.active = true; + + if (IS_MAIN) { + const { host } = PanelHost.mount(); + STATE.box = host; + StateStore.init(); + bindControls(); + } + + ensureHighlighter(); + ensureRectsHost(); + + attachPointerListeners(); + attachKeyboardListener(); + syncInteractionMode(); + } + + function stop() { + STATE.active = false; + + detachPointerListeners(); + detachKeyboardListener(); + + // Cancel pending rAF + if (STATE.hoverRafId != null) { + cancelAnimationFrame(STATE.hoverRafId); + STATE.hoverRafId = null; + } + pendingHoverEvent = null; + + try { + STATE.highlighter?.remove(); + STATE.rectsHost?.remove(); + PanelHost.unmount(); + DragController.destroy(); + } catch {} + + STATE.highlighter = null; + STATE.rectsHost = null; + STATE.box = null; + STATE.hoveredList = []; + STATE.hoverEl = null; + STATE.selectedEl = null; + STATE.lastHoverTarget = null; + STATE.verifyRectsActive = false; + + // Clear rect pool to release DOM references + STATE.rectPool.length = 0; + STATE.rectPoolUsed = 0; + } + + // ============================================================================ + // Controls Binding + // ============================================================================ + + function bindControls() { + const host = STATE.box; + if (!host) return; + + // Close/Cancel + host.querySelector('#__em_close')?.addEventListener('click', stop); + host.querySelector('#__em_cancel')?.addEventListener('click', stop); + + // Save + host.querySelector('#__em_save')?.addEventListener('click', save); + + // Verify (highlight only) & Execute (real action) + host.querySelector('#__em_verify')?.addEventListener('click', verifyHighlightOnly); + host.querySelector('#__em_execute')?.addEventListener('click', verifySelectorNow); + + // Copy + host.querySelector('#__em_copy')?.addEventListener('click', copySelectorNow); + host.querySelector('#__em_copy_selector')?.addEventListener('click', copySelectorNow); + + // Action change handler - show/hide action-specific options + host.querySelector('#__em_action')?.addEventListener('change', (e) => { + updateActionSpecificUI(e.target.value); + }); + + // Selector type + host.querySelector('#__em_selector_type')?.addEventListener('change', (e) => { + const newType = e.target.value; + const listMode = StateStore.get('listMode'); + + // If switching to XPath while in list mode, disable list mode + if (newType === 'xpath' && listMode) { + StateStore.set({ selectorType: newType, listMode: false }); + } else { + StateStore.set({ selectorType: newType }); + } + + // Regenerate selector for the currently selected element + if (STATE.selectedEl) { + setSelection(STATE.selectedEl); + } + // Note: If no selectedEl (e.g., iframe selections or manual input), + // preserve existing selector text instead of clearing it + }); + + // List mode toggle + host.querySelector('#__em_toggle_list')?.addEventListener('click', (e) => { + const listMode = StateStore.get('listMode'); + const newListMode = !listMode; + + // If enabling list mode, force CSS selector type + if (newListMode) { + StateStore.set({ listMode: true, selectorType: 'css' }); + const selectorTypeSelect = host.querySelector('#__em_selector_type'); + if (selectorTypeSelect) selectorTypeSelect.value = 'css'; + } else { + StateStore.set({ listMode: false }); + } + + // Update button active state + const btn = e.currentTarget; + if (btn) { + if (newListMode) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + } + + // Regenerate selector for the currently selected element + if (STATE.selectedEl) { + setSelection(STATE.selectedEl); + } + + clearHighlighter(); + }); + + // Tab toggle (switch between Attributes and Execute) + host.querySelector('#__em_toggle_tab')?.addEventListener('click', () => { + const currentTab = StateStore.get('activeTab'); + StateStore.set({ activeTab: currentTab === 'attributes' ? 'execute' : 'attributes' }); + }); + + // Tab switching + const tabs = host.querySelectorAll('.em-tab'); + tabs.forEach((tab) => { + tab.addEventListener('click', () => { + StateStore.set({ activeTab: tab.dataset.tab }); + }); + }); + + // Navigation buttons + host.querySelector('#__em_nav_up')?.addEventListener('click', () => { + const base = STATE.selectedEl || STATE.hoverEl; + if (base?.parentElement) setSelection(base.parentElement); + }); + + host.querySelector('#__em_nav_down')?.addEventListener('click', () => { + const base = STATE.selectedEl || STATE.hoverEl; + if (base?.firstElementChild) setSelection(base.firstElementChild); + }); + + // Preferences + host.querySelector('#__em_pref_id')?.addEventListener('change', (e) => { + const prefs = { ...StateStore.get('prefs'), preferId: !!e.target.checked }; + StateStore.set({ prefs }); + }); + host.querySelector('#__em_pref_attr')?.addEventListener('change', (e) => { + const prefs = { ...StateStore.get('prefs'), preferStableAttr: !!e.target.checked }; + StateStore.set({ prefs }); + }); + host.querySelector('#__em_pref_class')?.addEventListener('change', (e) => { + const prefs = { ...StateStore.get('prefs'), preferClass: !!e.target.checked }; + StateStore.set({ prefs }); + }); + + // Drag - use entire header as drag handle + const dragHandle = host.querySelector('#__em_drag_handle'); + if (dragHandle) { + DragController.init(dragHandle); + } + + syncUIWithState(); + } + + function updateActionSpecificUI(action) { + const host = STATE.box; + if (!host) return; + + // Hide all action-specific groups + const textGroup = host.querySelector('#__em_action_text_group'); + const keysGroup = host.querySelector('#__em_action_keys_group'); + const scrollOptions = host.querySelector('#__em_scroll_options'); + const clickOptions = host.querySelector('#__em_click_options'); + + if (textGroup) textGroup.style.display = 'none'; + if (keysGroup) keysGroup.style.display = 'none'; + if (scrollOptions) scrollOptions.style.display = 'none'; + if (clickOptions) clickOptions.style.display = 'none'; + + // Show relevant options based on action + if (action === 'type_text') { + if (textGroup) textGroup.style.display = 'block'; + } else if (action === 'press_keys') { + if (keysGroup) keysGroup.style.display = 'block'; + } else if (action === 'scroll') { + if (scrollOptions) scrollOptions.style.display = 'block'; + } else if (['left_click', 'double_click', 'right_click'].includes(action)) { + if (clickOptions) clickOptions.style.display = 'block'; + + // For right_click, button selector is not relevant (always 'right') + // Hide the button field for right_click + const buttonField = host.querySelector('#__em_btn')?.closest('.em-field'); + if (buttonField) { + buttonField.style.display = action === 'right_click' ? 'none' : 'block'; + } + } + // hover: no extra options needed + } + + function syncUIWithState() { + const host = STATE.box; + if (!host) return; + + const state = StateStore.get(); + + const typeSelect = host.querySelector('#__em_selector_type'); + if (typeSelect) typeSelect.value = state.selectorType; + + // Initialize list mode button state + const listModeBtn = host.querySelector('#__em_toggle_list'); + if (listModeBtn) { + if (state.listMode) { + listModeBtn.classList.add('active'); + } else { + listModeBtn.classList.remove('active'); + } + } + + const prefId = host.querySelector('#__em_pref_id'); + const prefAttr = host.querySelector('#__em_pref_attr'); + const prefClass = host.querySelector('#__em_pref_class'); + if (prefId) prefId.checked = state.prefs.preferId; + if (prefAttr) prefAttr.checked = state.prefs.preferStableAttr; + if (prefClass) prefClass.checked = state.prefs.preferClass; + + // Initialize action-specific UI + const actionSelect = host.querySelector('#__em_action'); + if (actionSelect) { + updateActionSpecificUI(actionSelect.value); + } + } + + // ============================================================================ + // Cross-Frame Bridge + // ============================================================================ + + // Register window message listener in all frames (not just main) + // to support cross-frame highlighting from popup validation + window.addEventListener( + 'message', + (ev) => { + try { + const data = ev?.data; + if (!data) return; + + // Handle iframe highlight request (works even when overlay is inactive) + if (data.type === 'em-highlight-request') { + highlightSelectorExternal({ + selector: data.selector, + selectorType: data.selectorType || 'css', + listMode: !!data.listMode, + }) + .then((result) => { + window.parent.postMessage( + { + type: 'em-highlight-result', + reqId: data.reqId, + result, + }, + '*', + ); + }) + .catch((error) => { + window.parent.postMessage( + { + type: 'em-highlight-result', + reqId: data.reqId, + result: { success: false, error: error?.message || String(error) }, + }, + '*', + ); + }); + return; + } + + // Following messages only relevant when overlay is active + if (!STATE.active) return; + + // Only main frame handles these overlay-related messages + if (!IS_MAIN) return; + + const iframes = Array.from(document.querySelectorAll('iframe')); + const host = iframes.find((f) => { + try { + return f.contentWindow === ev.source; + } catch { + return false; + } + }); + + if (!host) return; + + const base = host.getBoundingClientRect(); + + if (data.type === 'em_hover' && Array.isArray(data.rects)) { + // Use pooled rect boxes for better performance + drawRectBoxes(data.rects, { + offsetX: base.left, + offsetY: base.top, + color: CONFIG.COLORS.HOVER, + dashed: true, + }); + } else if (data.type === 'em_click' && data.innerSel) { + const frameSel = generateSelector(host); + const composite = frameSel ? `${frameSel} |> ${data.innerSel}` : data.innerSel; + const selectorText = STATE.box?.querySelector('#__em_selector'); + const selectorDisplay = STATE.box?.querySelector('#__em_selector_text'); + if (selectorText) selectorText.textContent = composite; + if (selectorDisplay) selectorDisplay.textContent = composite; + } + } catch {} + }, + true, + ); + + // ============================================================================ + // Message Handlers + // ============================================================================ + + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request?.action === 'element_marker_start') { + start(); + sendResponse({ ok: true }); + return true; + } else if (request?.action === 'element_marker_ping') { + sendResponse({ status: 'pong' }); + return false; + } else if (request?.action === 'element_marker_highlight') { + highlightSelectorExternal({ + selector: request.selector, + selectorType: request.selectorType, + listMode: !!request.listMode, + }) + .then((result) => sendResponse(result)) + .catch((error) => sendResponse({ success: false, error: error?.message || String(error) })); + return true; + } + return false; + }); +})(); diff --git a/app/chrome-extension/inject-scripts/element-picker.js b/app/chrome-extension/inject-scripts/element-picker.js new file mode 100644 index 0000000..45fdfbd --- /dev/null +++ b/app/chrome-extension/inject-scripts/element-picker.js @@ -0,0 +1,679 @@ +/* eslint-disable */ +/** + * Element Picker Inject Script + * + * Injected script to let the user manually pick elements for chrome_request_element_selection. + * - Writes refs into window.__claudeElementMap (compatible with accessibility-tree-helper.js) + * - Generates stable CSS selectors (prefers id/data-testid/etc.) + * - Supports iframe picking by reporting selection via chrome.runtime.sendMessage (background reads sender.frameId) + */ + +(function () { + 'use strict'; + + // Prevent double initialization + if (window.__MCP_ELEMENT_PICKER_INITIALIZED__) return; + window.__MCP_ELEMENT_PICKER_INITIALIZED__ = true; + + // ============================================================ + // Constants + // ============================================================ + + const UI_HOST_ID = '__mcp_element_picker_host__'; + const HIGHLIGHT_ID = '__mcp_element_picker_highlight__'; + const MAX_TEXT_LEN = 160; + + // Highlight colors matching Editorial accent (terracotta) + const HIGHLIGHT_COLOR = '#d97757'; + const HIGHLIGHT_BG = 'rgba(217, 119, 87, 0.08)'; + const HIGHLIGHT_BORDER = 'rgba(217, 119, 87, 0.4)'; + + // ============================================================ + // State + // ============================================================ + + const STATE = { + active: false, + sessionId: null, + activeRequestId: null, + listenersAttached: false, + hoverRafId: null, + pendingHoverEvent: null, + lastHoverEl: null, + highlighter: null, + }; + + // ============================================================ + // CSS Escape Helper + // ============================================================ + + function cssEscape(value) { + try { + if (window.CSS && typeof window.CSS.escape === 'function') { + return window.CSS.escape(value); + } + } catch { + // Fallback + } + return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`); + } + + // ============================================================ + // UI Detection Helpers + // ============================================================ + + function getUiHost() { + try { + return document.getElementById(UI_HOST_ID); + } catch { + return null; + } + } + + function isOverlayElement(node) { + if (!(node instanceof Node)) return false; + const host = getUiHost(); + if (!host) return false; + if (node === host) return true; + const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null; + return root instanceof ShadowRoot && root.host === host; + } + + function isEventFromUi(ev) { + if (!ev) return false; + try { + if (typeof ev.composedPath === 'function') { + const path = ev.composedPath(); + if (Array.isArray(path)) { + return path.some((n) => isOverlayElement(n)); + } + } + } catch { + // Fallback + } + return isOverlayElement(ev.target); + } + + /** + * Get the deepest page target from an event, handling Shadow DOM. + */ + function getDeepPageTarget(ev) { + if (!ev) return null; + try { + const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null; + if (Array.isArray(path) && path.length > 0) { + for (const node of path) { + if (node instanceof Element && !isOverlayElement(node)) { + return node; + } + } + } + } catch { + // Fallback + } + const fallback = ev.target instanceof Element ? ev.target : null; + if (fallback && !isOverlayElement(fallback)) { + return fallback; + } + return null; + } + + // ============================================================ + // Highlighter + // ============================================================ + + function ensureHighlighter() { + if (STATE.highlighter && STATE.highlighter.isConnected) { + return STATE.highlighter; + } + + // Remove any existing highlighter + try { + const existing = document.getElementById(HIGHLIGHT_ID); + if (existing) existing.remove(); + } catch { + // Best effort + } + + const hl = document.createElement('div'); + hl.id = HIGHLIGHT_ID; + Object.assign(hl.style, { + position: 'fixed', + left: '0px', + top: '0px', + width: '0px', + height: '0px', + border: `2px solid ${HIGHLIGHT_COLOR}`, + borderRadius: '4px', + boxShadow: `0 0 0 1px ${HIGHLIGHT_BORDER}`, + background: HIGHLIGHT_BG, + pointerEvents: 'none', + zIndex: '2147483647', + display: 'none', + transition: 'transform 60ms linear, width 60ms linear, height 60ms linear', + }); + + try { + (document.documentElement || document.body).appendChild(hl); + } catch { + // Best effort + } + + STATE.highlighter = hl; + return hl; + } + + function clearHighlighter() { + const hl = STATE.highlighter; + if (!hl) return; + try { + hl.style.display = 'none'; + } catch { + // Best effort + } + } + + function moveHighlighterTo(el) { + const hl = ensureHighlighter(); + if (!hl || !(el instanceof Element)) return; + + let rect; + try { + rect = el.getBoundingClientRect(); + } catch { + clearHighlighter(); + return; + } + + if (!rect || rect.width <= 0 || rect.height <= 0) { + clearHighlighter(); + return; + } + + try { + hl.style.display = 'block'; + hl.style.transform = `translate(${Math.round(rect.left)}px, ${Math.round(rect.top)}px)`; + hl.style.width = `${Math.round(rect.width)}px`; + hl.style.height = `${Math.round(rect.height)}px`; + } catch { + // Best effort + } + } + + // ============================================================ + // Selector Uniqueness Check (Optimized) + // ============================================================ + + /** + * Check if element is inside a Shadow DOM. + */ + function isInShadowDom(el) { + try { + const root = el.getRootNode(); + return root instanceof ShadowRoot; + } catch { + return false; + } + } + + /** + * Fast uniqueness check using native querySelectorAll. + * For Shadow DOM elements, queries within their shadow root only. + */ + function isSelectorUnique(selector, target) { + if (!selector || !(target instanceof Element)) return false; + + try { + // For elements not in Shadow DOM, use fast native query + if (!isInShadowDom(target)) { + const matches = document.querySelectorAll(selector); + return matches.length === 1 && matches[0] === target; + } + + // For Shadow DOM elements, query within their root + const root = target.getRootNode(); + if (root instanceof ShadowRoot) { + const matches = root.querySelectorAll(selector); + return matches.length === 1 && matches[0] === target; + } + + return false; + } catch { + return false; + } + } + + // ============================================================ + // Selector Generation (Stable & Unique) + // ============================================================ + + function buildPathFromAncestor(ancestor, target) { + const segs = []; + let cur = target; + + const root = target.getRootNode(); + const isShadowElement = root instanceof ShadowRoot; + const boundary = isShadowElement ? root.host : document.body; + + while (cur && cur !== ancestor && cur !== boundary) { + let seg = cur.tagName.toLowerCase(); + const parent = cur.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName); + if (siblings.length > 1) { + seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`; + } + } + segs.unshift(seg); + cur = parent; + if (isShadowElement && cur === boundary) break; + } + + return segs.join(' > '); + } + + function buildFullPath(el) { + let path = ''; + let current = el; + + const root = el.getRootNode(); + const isShadowElement = root instanceof ShadowRoot; + const boundary = isShadowElement ? root.host : document.body; + + while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) { + let sel = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName); + if (siblings.length > 1) { + sel += `:nth-of-type(${siblings.indexOf(current) + 1})`; + } + } + path = path ? `${sel} > ${path}` : sel; + current = parent; + if (isShadowElement && current === boundary) break; + } + + if (isShadowElement) return path || el.tagName.toLowerCase(); + return path ? `body > ${path}` : 'body'; + } + + /** + * Generate a stable CSS selector for an element. + * Prioritizes: id > data-testid/data-test/etc > anchor + relative path > full path + */ + function generateSelector(el) { + if (!(el instanceof Element)) return ''; + + // Prefer unique IDs + try { + if (el.id) { + const idSel = `#${cssEscape(el.id)}`; + if (isSelectorUnique(idSel, el)) return idSel; + } + } catch { + // Continue + } + + // Prefer stable test attributes + try { + const attrNames = [ + 'data-testid', + 'data-testId', + 'data-test', + 'data-qa', + 'data-cy', + 'name', + 'aria-label', + 'title', + 'alt', + ]; + const tag = el.tagName.toLowerCase(); + for (const attr of attrNames) { + const v = el.getAttribute(attr); + if (!v) continue; + const attrSel = `[${attr}="${cssEscape(v)}"]`; + const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel; + if (isSelectorUnique(testSel, el)) return testSel; + } + } catch { + // Continue + } + + // Anchor + relative path + try { + let cur = el; + const anchorAttrs = [ + 'id', + 'data-testid', + 'data-testId', + 'data-test', + 'data-qa', + 'data-cy', + 'name', + ]; + + const root = el.getRootNode(); + const isShadowElement = root instanceof ShadowRoot; + const boundary = isShadowElement ? root.host : document.body; + + while (cur && cur !== boundary) { + if (cur.id) { + const anchor = `#${cssEscape(cur.id)}`; + if (isSelectorUnique(anchor, cur)) { + const rel = buildPathFromAncestor(cur, el); + const composed = rel ? `${anchor} ${rel}` : anchor; + if (isSelectorUnique(composed, el)) return composed; + } + } + + for (const attr of anchorAttrs) { + const val = cur.getAttribute(attr); + if (!val) continue; + const aSel = `[${attr}="${cssEscape(val)}"]`; + if (isSelectorUnique(aSel, cur)) { + const rel = buildPathFromAncestor(cur, el); + const composed = rel ? `${aSel} ${rel}` : aSel; + if (isSelectorUnique(composed, el)) return composed; + } + } + + cur = cur.parentElement; + } + } catch { + // Continue + } + + // Fallback to full path + return buildFullPath(el); + } + + // ============================================================ + // Text Summarization + // ============================================================ + + function summarizeText(el) { + if (!(el instanceof Element)) return ''; + try { + const aria = el.getAttribute('aria-label'); + if (aria && aria.trim()) return aria.trim().slice(0, MAX_TEXT_LEN); + const placeholder = el.getAttribute('placeholder'); + if (placeholder && placeholder.trim()) return placeholder.trim().slice(0, MAX_TEXT_LEN); + const title = el.getAttribute('title'); + if (title && title.trim()) return title.trim().slice(0, MAX_TEXT_LEN); + const alt = el.getAttribute('alt'); + if (alt && alt.trim()) return alt.trim().slice(0, MAX_TEXT_LEN); + } catch { + // Continue + } + try { + const t = (el.textContent || '').trim().replace(/\s+/g, ' '); + return t ? t.slice(0, MAX_TEXT_LEN) : ''; + } catch { + return ''; + } + } + + // ============================================================ + // Ref Management (Compatible with accessibility-tree-helper.js) + // ============================================================ + + function ensureRefForElement(el) { + try { + if (!window.__claudeElementMap) window.__claudeElementMap = {}; + if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; + } catch { + // Best effort + } + + // Check if element already has a ref + let refId = null; + try { + for (const k in window.__claudeElementMap) { + const w = window.__claudeElementMap[k]; + if (w && w.deref && w.deref() === el) { + refId = k; + break; + } + } + } catch { + // Continue + } + + // Create new ref if needed + if (!refId) { + try { + refId = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[refId] = new WeakRef(el); + } catch { + // Continue + } + } + + return refId || ''; + } + + // ============================================================ + // Communication + // ============================================================ + + function sendFrameEvent(payload) { + try { + chrome.runtime.sendMessage(payload); + } catch { + // Best effort + } + } + + // ============================================================ + // Event Handlers + // ============================================================ + + function processMouseMove(ev) { + if (!STATE.active) return; + + // Skip if event is from our UI + if (isEventFromUi(ev)) { + STATE.lastHoverEl = null; + clearHighlighter(); + return; + } + + const target = getDeepPageTarget(ev); + if (!target) { + STATE.lastHoverEl = null; + clearHighlighter(); + return; + } + + // Skip if same element + if (STATE.lastHoverEl === target) return; + STATE.lastHoverEl = target; + moveHighlighterTo(target); + } + + function onMouseMove(ev) { + if (!STATE.active) return; + STATE.pendingHoverEvent = ev; + if (STATE.hoverRafId != null) return; + STATE.hoverRafId = requestAnimationFrame(() => { + STATE.hoverRafId = null; + const latest = STATE.pendingHoverEvent; + STATE.pendingHoverEvent = null; + if (!latest) return; + processMouseMove(latest); + }); + } + + function onClick(ev) { + if (!STATE.active) return; + + // Allow UI interactions without interference + if (isEventFromUi(ev)) return; + + const rawTarget = ev.target instanceof Element ? ev.target : null; + if (!rawTarget) return; + + // Require an active request id so background can map the selection + if (!STATE.sessionId || !STATE.activeRequestId) return; + + ev.preventDefault(); + ev.stopPropagation(); + + const target = getDeepPageTarget(ev) || rawTarget; + if (!(target instanceof Element)) return; + + const ref = ensureRefForElement(target); + const selector = generateSelector(target); + let rect; + try { + rect = target.getBoundingClientRect(); + } catch { + rect = { x: 0, y: 0, width: 0, height: 0, left: 0, top: 0 }; + } + + const center = { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }; + + sendFrameEvent({ + type: 'element_picker_frame_event', + sessionId: STATE.sessionId, + event: 'selected', + requestId: STATE.activeRequestId, + element: { + ref, + selector, + selectorType: 'css', + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + center, + text: summarizeText(target), + tagName: target.tagName ? String(target.tagName).toLowerCase() : '', + }, + }); + } + + function onKeyDown(ev) { + if (!STATE.active) return; + if (ev && ev.key === 'Escape') { + if (isEventFromUi(ev)) return; + ev.preventDefault(); + ev.stopPropagation(); + if (STATE.sessionId) { + sendFrameEvent({ + type: 'element_picker_frame_event', + sessionId: STATE.sessionId, + event: 'cancel', + }); + } + } + } + + // ============================================================ + // Listener Management + // ============================================================ + + function attachListeners() { + if (STATE.listenersAttached) return; + window.addEventListener('mousemove', onMouseMove, true); + window.addEventListener('click', onClick, true); + window.addEventListener('keydown', onKeyDown, true); + STATE.listenersAttached = true; + } + + function detachListeners() { + if (!STATE.listenersAttached) return; + window.removeEventListener('mousemove', onMouseMove, true); + window.removeEventListener('click', onClick, true); + window.removeEventListener('keydown', onKeyDown, true); + STATE.listenersAttached = false; + } + + // ============================================================ + // Session Management API + // ============================================================ + + function startSession(payload) { + const sessionId = payload && payload.sessionId ? String(payload.sessionId) : ''; + if (!sessionId) return; + + STATE.active = true; + STATE.sessionId = sessionId; + STATE.activeRequestId = + payload && payload.activeRequestId ? String(payload.activeRequestId) : null; + ensureHighlighter(); + attachListeners(); + } + + function stopSession(payload) { + const sessionId = payload && payload.sessionId ? String(payload.sessionId) : ''; + // Only stop if session matches or no specific session requested + if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return; + + STATE.active = false; + STATE.sessionId = null; + STATE.activeRequestId = null; + STATE.lastHoverEl = null; + detachListeners(); + clearHighlighter(); + + // Remove highlighter element + try { + const hl = STATE.highlighter; + if (hl && hl.remove) hl.remove(); + } catch { + // Best effort + } + STATE.highlighter = null; + } + + function setActiveRequest(payload) { + const sessionId = payload && payload.sessionId ? String(payload.sessionId) : ''; + if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return; + STATE.activeRequestId = + payload && payload.activeRequestId ? String(payload.activeRequestId) : null; + } + + // ============================================================ + // Expose API for Background Script + // ============================================================ + + window.__mcpElementPicker = { + startSession, + stopSession, + setActiveRequest, + }; + + // ============================================================ + // Message Listener (for direct communication) + // ============================================================ + + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + try { + if (request && request.action === 'chrome_request_element_selection_ping') { + sendResponse({ status: 'pong' }); + return false; + } + if (request && request.action === 'elementPickerStart') { + startSession(request); + sendResponse({ success: true }); + return false; + } + if (request && request.action === 'elementPickerStop') { + stopSession(request); + sendResponse({ success: true }); + return false; + } + if (request && request.action === 'elementPickerSetActiveRequest') { + setActiveRequest(request); + sendResponse({ success: true }); + return false; + } + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return false; + } + return false; + }); +})(); diff --git a/app/chrome-extension/inject-scripts/fill-helper.js b/app/chrome-extension/inject-scripts/fill-helper.js new file mode 100644 index 0000000..1d8663f --- /dev/null +++ b/app/chrome-extension/inject-scripts/fill-helper.js @@ -0,0 +1,350 @@ +/* eslint-disable */ +// fill-helper.js +// This script is injected into the page to handle form filling operations + +if (window.__FILL_HELPER_INITIALIZED__) { + // Already initialized, skip +} else { + window.__FILL_HELPER_INITIALIZED__ = true; + /** + * Fill an input element with the specified value + * @param {string} selector - CSS selector for the element to fill + * @param {string} value - Value to fill into the element + * @returns {Promise} - Result of the fill operation + */ + async function fillElement(selector, value, ref = null) { + try { + // Find the element + let element = null; + if (ref && typeof ref === 'string') { + try { + const map = window.__claudeElementMap; + const weak = map && map[ref]; + element = weak && typeof weak.deref === 'function' ? weak.deref() : null; + } catch (e) { + // ignore + } + if (!element || !(element instanceof Element)) { + return { + error: `Element ref "${ref}" not found. Please call chrome_read_page first and ensure the ref is still valid.`, + }; + } + } else { + element = document.querySelector(selector); + } + if (!element) { + return { + error: selector + ? `Element with selector "${selector}" not found` + : `Element for ref not found`, + }; + } + + // Get element information + const rect = element.getBoundingClientRect(); + const elementInfo = { + tagName: element.tagName, + id: element.id, + className: element.className, + type: element.type || null, + isVisible: isElementVisible(element), + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + }; + + // Check if element is visible + if (!elementInfo.isVisible) { + return { + error: `Element with selector "${selector}" is not visible`, + elementInfo, + }; + } + + // Check if element is an input, textarea, or select + const validTags = ['INPUT', 'TEXTAREA', 'SELECT']; + // Keep a permissive list to allow type-specific branches below to handle behavior + const validInputTypes = [ + 'text', + 'email', + 'password', + 'number', + 'search', + 'tel', + 'url', + 'date', + 'datetime-local', + 'month', + 'time', + 'week', + 'color', + 'checkbox', + 'radio', + 'range', + ]; + + if (!validTags.includes(element.tagName)) { + // If the element is a custom element with open shadow root, try to find a fillable inner control + try { + const anyEl = /** @type {any} */ (element); + const sr = anyEl && anyEl.shadowRoot ? anyEl.shadowRoot : null; + if (sr) { + // Search common fillable targets inside shadow root (breadth-first) + const queue = Array.from(sr.children || []); + const isFillable = (el) => + !!el && + (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'); + while (queue.length) { + const cur = queue.shift(); + if (!cur) continue; + if (isFillable(cur)) { + element = cur; + break; + } + try { + const children = cur.children || []; + for (let i = 0; i < children.length; i++) queue.push(children[i]); + const innerSr = /** @type {any} */ (cur).shadowRoot; + if (innerSr && innerSr.children) { + for (let i = 0; i < innerSr.children.length; i++) queue.push(innerSr.children[i]); + } + } catch (_) {} + } + if (!validTags.includes(element.tagName)) { + return { + error: `Element with selector "${selector}" is not a fillable element (must be INPUT, TEXTAREA, or SELECT)`, + elementInfo, + }; + } + } else { + return { + error: `Element with selector "${selector}" is not a fillable element (must be INPUT, TEXTAREA, or SELECT)`, + elementInfo, + }; + } + } catch (_) { + return { + error: `Element with selector "${selector}" is not a fillable element (must be INPUT, TEXTAREA, or SELECT)`, + elementInfo, + }; + } + } + + // For input elements, check if the type is valid (allow type-specific branches below) + if ( + element.tagName === 'INPUT' && + !validInputTypes.includes(element.type) && + element.type !== null + ) { + return { + error: `Input element with selector "${selector}" has type "${element.type}" which is not fillable`, + elementInfo, + }; + } + + // Scroll element into view + element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Focus the element + element.focus(); + + // Type-specific handling for tricky inputs first + if (element.tagName === 'INPUT' && element.type === 'checkbox') { + // Accept boolean or string-like boolean + let checkedVal; + if (typeof value === 'boolean') { + checkedVal = value; + } else if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(v)) checkedVal = true; + else if (['false', '0', 'no', 'off'].includes(v)) checkedVal = false; + } + if (typeof checkedVal !== 'boolean') { + return { + error: + 'Checkbox requires a boolean (true/false) or a boolean-like string ("true"/"false"/"on"/"off").', + elementInfo, + }; + } + const previous = element.checked; + element.checked = checkedVal; + element.focus(); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + element.blur(); + return { + success: true, + message: `Checkbox set to ${element.checked}`, + elementInfo: { ...elementInfo, checked: element.checked, previousChecked: previous }, + }; + } + + if (element.tagName === 'INPUT' && element.type === 'radio') { + // For radios, the selector/ref should target the specific input to select + const previous = element.checked; + element.checked = true; + element.focus(); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + element.blur(); + return { + success: true, + message: 'Radio selected', + elementInfo: { + ...elementInfo, + checked: element.checked, + previousChecked: previous, + name: element.name || null, + }, + }; + } + + if (element.tagName === 'INPUT' && element.type === 'range') { + const numericValue = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(numericValue)) { + return { error: 'Range input requires a numeric value', elementInfo }; + } + const previous = element.value; + element.value = String(numericValue); + element.focus(); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + element.blur(); + return { + success: true, + message: `Set range to ${element.value} (min: ${element.min}, max: ${element.max})`, + elementInfo: { ...elementInfo, value: element.value }, + }; + } + + if (element.tagName === 'INPUT' && element.type === 'number') { + if (value !== '' && value !== null && value !== undefined && Number.isNaN(Number(value))) { + return { error: 'Number input requires a numeric value', elementInfo }; + } + const previous = element.value; + element.value = String(value ?? ''); + element.focus(); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + element.blur(); + return { + success: true, + message: `Set number input to ${element.value} (previous: ${previous})`, + elementInfo: { ...elementInfo, value: element.value }, + }; + } + + // Fill the element based on its type + if (element.tagName === 'SELECT') { + // For select elements, find the option with matching value or text + let optionFound = false; + for (const option of element.options) { + if (option.value === value || option.text === value) { + element.value = option.value; + optionFound = true; + break; + } + } + + if (!optionFound) { + return { + error: `No option with value or text "${value}" found in select element`, + elementInfo, + }; + } + + // Trigger change event + element.dispatchEvent(new Event('change', { bubbles: true })); + } else { + // For input and textarea elements + // Clear the current value then set new value + element.value = ''; + element.dispatchEvent(new Event('input', { bubbles: true })); + + element.value = String(value); + + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + } + + // Blur the element + element.blur(); + + return { + success: true, + message: 'Element filled successfully', + elementInfo: { + ...elementInfo, + value: element.value, // Include the final value in the response + }, + }; + } catch (error) { + return { + error: `Error filling element: ${error.message}`, + }; + } + } + + /** + * Check if an element is visible + * @param {Element} element - The element to check + * @returns {boolean} - Whether the element is visible + */ + function isElementVisible(element) { + if (!element) return false; + + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return false; + } + + // Check if element is within viewport + if ( + rect.bottom < 0 || + rect.top > window.innerHeight || + rect.right < 0 || + rect.left > window.innerWidth + ) { + return false; + } + + // Check if element is actually visible at its center point + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const elementAtPoint = document.elementFromPoint(centerX, centerY); + if (!elementAtPoint) return false; + + return element === elementAtPoint || element.contains(elementAtPoint); + } + + // Listen for messages from the extension + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.action === 'fillElement') { + fillElement(request.selector, request.value, request.ref) + .then(sendResponse) + .catch((error) => { + sendResponse({ + error: `Unexpected error: ${error.message}`, + }); + }); + return true; // Indicates async response + } else if (request.action === 'chrome_fill_or_select_ping') { + sendResponse({ status: 'pong' }); + return false; + } + }); +} diff --git a/app/chrome-extension/inject-scripts/inject-bridge.js b/app/chrome-extension/inject-scripts/inject-bridge.js new file mode 100644 index 0000000..52fce7f --- /dev/null +++ b/app/chrome-extension/inject-scripts/inject-bridge.js @@ -0,0 +1,65 @@ +/* eslint-disable */ + +(() => { + // Prevent duplicate injection of the bridge itself. + if (window.__INJECT_SCRIPT_TOOL_UNIVERSAL_BRIDGE_LOADED__) return; + window.__INJECT_SCRIPT_TOOL_UNIVERSAL_BRIDGE_LOADED__ = true; + const EVENT_NAME = { + RESPONSE: 'chrome-mcp:response', + CLEANUP: 'chrome-mcp:cleanup', + EXECUTE: 'chrome-mcp:execute', + }; + const pendingRequests = new Map(); + + const messageHandler = (request, _sender, sendResponse) => { + // --- Lifecycle Command --- + if (request.type === EVENT_NAME.CLEANUP) { + window.dispatchEvent(new CustomEvent(EVENT_NAME.CLEANUP)); + // Acknowledge cleanup signal received, but don't hold the connection. + sendResponse({ success: true }); + return true; + } + + // --- Execution Command for MAIN world --- + if (request.targetWorld === 'MAIN') { + const requestId = `req-${Date.now()}-${Math.random()}`; + pendingRequests.set(requestId, sendResponse); + + window.dispatchEvent( + new CustomEvent(EVENT_NAME.EXECUTE, { + detail: { + action: request.action, + payload: request.payload, + requestId: requestId, + }, + }), + ); + return true; // Async response is expected. + } + // Note: Requests for ISOLATED world are handled by the user's isolatedWorldCode script directly. + // This listener won't process them unless it's the only script in ISOLATED world. + }; + + chrome.runtime.onMessage.addListener(messageHandler); + + // Listen for responses coming back from the MAIN world. + const responseHandler = (event) => { + const { requestId, data, error } = event.detail; + if (pendingRequests.has(requestId)) { + const sendResponse = pendingRequests.get(requestId); + sendResponse({ data, error }); + pendingRequests.delete(requestId); + } + }; + window.addEventListener(EVENT_NAME.RESPONSE, responseHandler); + + // --- Self Cleanup --- + // When the cleanup signal arrives, this bridge must also clean itself up. + const cleanupHandler = () => { + chrome.runtime.onMessage.removeListener(messageHandler); + window.removeEventListener(EVENT_NAME.RESPONSE, responseHandler); + window.removeEventListener(EVENT_NAME.CLEANUP, cleanupHandler); + delete window.__INJECT_SCRIPT_TOOL_UNIVERSAL_BRIDGE_LOADED__; + }; + window.addEventListener(EVENT_NAME.CLEANUP, cleanupHandler); +})(); diff --git a/app/chrome-extension/inject-scripts/interactive-elements-helper.js b/app/chrome-extension/inject-scripts/interactive-elements-helper.js new file mode 100644 index 0000000..681b057 --- /dev/null +++ b/app/chrome-extension/inject-scripts/interactive-elements-helper.js @@ -0,0 +1,393 @@ +/* eslint-disable */ +// interactive-elements-helper.js +// This script is injected into the page to find interactive elements. +// Final version by Calvin, featuring a multi-layered fallback strategy +// and comprehensive element support, built on a performant and reliable core. + +(function () { + // Prevent re-initialization + if (window.__INTERACTIVE_ELEMENTS_HELPER_INITIALIZED__) { + return; + } + window.__INTERACTIVE_ELEMENTS_HELPER_INITIALIZED__ = true; + + /** + * @typedef {Object} ElementInfo + * @property {string} type - The type of the element (e.g., 'button', 'link'). + * @property {string} selector - A CSS selector to uniquely identify the element. + * @property {string} text - The visible text or accessible name of the element. + * @property {boolean} isInteractive - Whether the element is currently interactive. + * @property {Object} [coordinates] - The coordinates of the element if requested. + * @property {boolean} [disabled] - For elements that can be disabled. + * @property {string} [href] - For links. + * @property {boolean} [checked] - for checkboxes and radio buttons. + */ + + /** + * Configuration for element types and their corresponding selectors. + * Now more comprehensive with common ARIA roles. + */ + const ELEMENT_CONFIG = { + button: 'button, input[type="button"], input[type="submit"], [role="button"]', + link: 'a[href], [role="link"]', + input: + 'input:not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"])', + checkbox: 'input[type="checkbox"], [role="checkbox"]', + radio: 'input[type="radio"], [role="radio"]', + textarea: 'textarea, [role="textbox"], [role="searchbox"]', + select: 'select, [role="combobox"]', + tab: '[role="tab"]', + // Generic interactive elements: combines tabindex, common roles, and explicit handlers. + // This is the key to finding custom-built interactive components. + interactive: `[onclick], [tabindex]:not([tabindex^="-"]), [role="menuitem"], [role="slider"], [role="option"], [role="treeitem"], [role="switch"]`, + }; + + // A combined selector for ANY interactive element, used in the fallback logic. + const ANY_INTERACTIVE_SELECTOR = Object.values(ELEMENT_CONFIG).join(', '); + + // Query helpers that pierce open shadow roots. These are used only in fallback paths or + // when a selector is explicitly provided, to keep costs bounded. + function* walkAllNodesDeep(root) { + const stack = [root]; + const MAX = 12000; // safety bound + let count = 0; + while (stack.length) { + const node = stack.pop(); + if (!node) continue; + if (++count > MAX) break; + yield node; + const anyNode = /** @type {any} */ (node); + try { + const children = node.children ? Array.from(node.children) : []; + for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]); + const sr = anyNode && anyNode.shadowRoot ? anyNode.shadowRoot : null; + if (sr && sr.children) { + const srChildren = Array.from(sr.children); + for (let i = srChildren.length - 1; i >= 0; i--) stack.push(srChildren[i]); + } + } catch (_) { + /* ignore */ + } + } + } + + function querySelectorAllDeep(selector, root = document) { + const results = []; + for (const node of walkAllNodesDeep(root)) { + if (!(node instanceof Element)) continue; + try { + if (node.matches && node.matches(selector)) results.push(node); + } catch (_) { + /* ignore invalid selectors for given node */ + } + } + return results; + } + + // --- Core Helper Functions --- + + /** + * Checks if an element is genuinely visible on the page. + * "Visible" means it's not styled with display:none, visibility:hidden, etc. + * This check intentionally IGNORES whether the element is within the current viewport. + * @param {Element} el The element to check. + * @returns {boolean} True if the element is visible. + */ + function isElementVisible(el) { + if (!el || !el.isConnected) return false; + + const style = window.getComputedStyle(el); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + parseFloat(style.opacity) === 0 + ) { + return false; + } + + const rect = el.getBoundingClientRect(); + return rect.width > 0 || rect.height > 0 || el.tagName === 'A'; // Allow zero-size anchors as they can still be navigated + } + + /** + * Checks if an element is considered interactive (not disabled or hidden from accessibility). + * @param {Element} el The element to check. + * @returns {boolean} True if the element is interactive. + */ + function isElementInteractive(el) { + if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') { + return false; + } + if (el.closest('[aria-hidden="true"]')) { + return false; + } + return true; + } + + /** + * Generates a reasonably stable CSS selector for a given element. + * @param {Element} el The element. + * @returns {string} A CSS selector. + */ + function generateSelector(el) { + if (!(el instanceof Element)) return ''; + + if (el.id) { + const idSelector = `#${CSS.escape(el.id)}`; + if (document.querySelectorAll(idSelector).length === 1) return idSelector; + } + + for (const attr of ['data-testid', 'data-cy', 'name']) { + const attrValue = el.getAttribute(attr); + if (attrValue) { + const attrSelector = `[${attr}="${CSS.escape(attrValue)}"]`; + if (document.querySelectorAll(attrSelector).length === 1) return attrSelector; + } + } + + let path = ''; + let current = el; + while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') { + let selector = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === current.tagName, + ); + if (siblings.length > 1) { + const index = siblings.indexOf(current) + 1; + selector += `:nth-of-type(${index})`; + } + } + path = path ? `${selector} > ${path}` : selector; + current = parent; + } + return path ? `body > ${path}` : 'body'; + } + + /** + * Finds the accessible name for an element (label, aria-label, etc.). + * @param {Element} el The element. + * @returns {string} The accessible name. + */ + function getAccessibleName(el) { + const labelledby = el.getAttribute('aria-labelledby'); + if (labelledby) { + const labelElement = document.getElementById(labelledby); + if (labelElement) return labelElement.textContent?.trim() || ''; + } + const ariaLabel = el.getAttribute('aria-label'); + if (ariaLabel) return ariaLabel.trim(); + if (el.id) { + const label = document.querySelector(`label[for="${el.id}"]`); + if (label) return label.textContent?.trim() || ''; + } + const parentLabel = el.closest('label'); + if (parentLabel) return parentLabel.textContent?.trim() || ''; + return ( + el.getAttribute('placeholder') || + el.getAttribute('value') || + el.textContent?.trim() || + el.getAttribute('title') || + '' + ); + } + + /** + * Simple subsequence matching for fuzzy search. + * @param {string} text The text to search within. + * @param {string} query The query subsequence. + * @returns {boolean} + */ + function fuzzyMatch(text, query) { + if (!text || !query) return false; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let textIndex = 0; + let queryIndex = 0; + while (textIndex < lowerText.length && queryIndex < lowerQuery.length) { + if (lowerText[textIndex] === lowerQuery[queryIndex]) { + queryIndex++; + } + textIndex++; + } + return queryIndex === lowerQuery.length; + } + + /** + * Creates the standardized info object for an element. + * Modified to handle the new 'text' type from the final fallback. + */ + function createElementInfo(el, type, includeCoordinates, isInteractiveOverride = null) { + const isActuallyInteractive = isElementInteractive(el); + const info = { + type, + selector: generateSelector(el), + text: getAccessibleName(el) || el.textContent?.trim(), + isInteractive: isInteractiveOverride !== null ? isInteractiveOverride : isActuallyInteractive, + disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true', + }; + if (includeCoordinates) { + const rect = el.getBoundingClientRect(); + info.coordinates = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + }; + } + return info; + } + + /** + * [CORE UTILITY] Finds interactive elements based on a set of types. + * This is our high-performance Layer 1 search function. + */ + function findInteractiveElements(options = {}) { + const { textQuery, includeCoordinates = true, types = Object.keys(ELEMENT_CONFIG) } = options; + + const selectorsToFind = types + .map((type) => ELEMENT_CONFIG[type]) + .filter(Boolean) + .join(', '); + if (!selectorsToFind) return []; + + const targetElements = querySelectorAllDeep(selectorsToFind); + const uniqueElements = new Set(targetElements); + const results = []; + + for (const el of uniqueElements) { + if (!isElementVisible(el) || !isElementInteractive(el)) continue; + + const accessibleName = getAccessibleName(el); + if (textQuery && !fuzzyMatch(accessibleName, textQuery)) continue; + + let elementType = 'unknown'; + for (const [type, typeSelector] of Object.entries(ELEMENT_CONFIG)) { + if (el.matches(typeSelector)) { + elementType = type; + break; + } + } + results.push(createElementInfo(el, elementType, includeCoordinates)); + } + return results; + } + + /** + * [ORCHESTRATOR] The main entry point that implements the 3-layer fallback logic. + * @param {object} options - The main search options. + * @returns {ElementInfo[]} + */ + function findElementsByTextWithFallback(options = {}) { + const { textQuery, includeCoordinates = true } = options; + + if (!textQuery) { + return findInteractiveElements({ ...options, types: Object.keys(ELEMENT_CONFIG) }); + } + + // --- Layer 1: High-reliability search for interactive elements matching text --- + let results = findInteractiveElements({ ...options, types: Object.keys(ELEMENT_CONFIG) }); + if (results.length > 0) { + return results; + } + + // --- Layer 2: Find text, then find its interactive ancestor --- + const lowerCaseText = textQuery.toLowerCase(); + const xPath = `//text()[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${lowerCaseText}')]`; + const textNodes = document.evaluate( + xPath, + document, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + + const interactiveElements = new Set(); + if (textNodes.snapshotLength > 0) { + for (let i = 0; i < textNodes.snapshotLength; i++) { + const parentElement = textNodes.snapshotItem(i).parentElement; + if (parentElement) { + const interactiveAncestor = parentElement.closest(ANY_INTERACTIVE_SELECTOR); + if ( + interactiveAncestor && + isElementVisible(interactiveAncestor) && + isElementInteractive(interactiveAncestor) + ) { + interactiveElements.add(interactiveAncestor); + } + } + } + + if (interactiveElements.size > 0) { + return Array.from(interactiveElements).map((el) => { + let elementType = 'interactive'; + for (const [type, typeSelector] of Object.entries(ELEMENT_CONFIG)) { + if (el.matches(typeSelector)) { + elementType = type; + break; + } + } + return createElementInfo(el, elementType, includeCoordinates); + }); + } + } + + // --- Layer 3: Final fallback, return any element containing the text --- + const leafElements = new Set(); + for (let i = 0; i < textNodes.snapshotLength; i++) { + const parentElement = textNodes.snapshotItem(i).parentElement; + if (parentElement && isElementVisible(parentElement)) { + leafElements.add(parentElement); + } + } + + const finalElements = Array.from(leafElements).filter((el) => { + return ![...leafElements].some((otherEl) => el !== otherEl && el.contains(otherEl)); + }); + + return finalElements.map((el) => createElementInfo(el, 'text', includeCoordinates, true)); + } + + // --- Chrome Message Listener --- + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.action === 'getInteractiveElements') { + try { + let elements; + if (request.selector) { + // If a selector is provided, bypass the text-based logic and use a direct query. + const foundEls = querySelectorAllDeep(request.selector); + elements = foundEls.map((el) => + createElementInfo( + el, + 'selected', + request.includeCoordinates !== false, + isElementInteractive(el), + ), + ); + } else { + // Otherwise, use our powerful multi-layered text search + elements = findElementsByTextWithFallback(request); + } + sendResponse({ success: true, elements }); + } catch (error) { + console.error('Error in getInteractiveElements:', error); + sendResponse({ success: false, error: error.message }); + } + return true; // Async response + } else if (request.action === 'chrome_get_interactive_elements_ping') { + sendResponse({ status: 'pong' }); + return false; + } + }); + + console.log('Interactive elements helper script loaded'); +})(); diff --git a/app/chrome-extension/inject-scripts/keyboard-helper.js b/app/chrome-extension/inject-scripts/keyboard-helper.js new file mode 100644 index 0000000..4c9c8c0 --- /dev/null +++ b/app/chrome-extension/inject-scripts/keyboard-helper.js @@ -0,0 +1,291 @@ +/* eslint-disable */ +// keyboard-helper.js +// This script is injected into the page to handle keyboard event simulation + +if (window.__KEYBOARD_HELPER_INITIALIZED__) { + // Already initialized, skip +} else { + window.__KEYBOARD_HELPER_INITIALIZED__ = true; + + // A map for special keys to their KeyboardEvent properties + // Key names should be lowercase for matching + const SPECIAL_KEY_MAP = { + enter: { key: 'Enter', code: 'Enter', keyCode: 13 }, + tab: { key: 'Tab', code: 'Tab', keyCode: 9 }, + esc: { key: 'Escape', code: 'Escape', keyCode: 27 }, + escape: { key: 'Escape', code: 'Escape', keyCode: 27 }, + space: { key: ' ', code: 'Space', keyCode: 32 }, + backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 }, + delete: { key: 'Delete', code: 'Delete', keyCode: 46 }, + del: { key: 'Delete', code: 'Delete', keyCode: 46 }, + up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + arrowright: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + home: { key: 'Home', code: 'Home', keyCode: 36 }, + end: { key: 'End', code: 'End', keyCode: 35 }, + pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 }, + pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 }, + insert: { key: 'Insert', code: 'Insert', keyCode: 45 }, + // Function keys + ...Object.fromEntries( + Array.from({ length: 12 }, (_, i) => [ + `f${i + 1}`, + { key: `F${i + 1}`, code: `F${i + 1}`, keyCode: 112 + i }, + ]), + ), + }; + + const MODIFIER_KEYS = { + ctrl: 'ctrlKey', + control: 'ctrlKey', + alt: 'altKey', + shift: 'shiftKey', + meta: 'metaKey', + command: 'metaKey', + cmd: 'metaKey', + }; + + /** + * Parses a key string (e.g., "Ctrl+Shift+A", "Enter") into a main key and modifiers. + * @param {string} keyString - String representation of a single key press (can include modifiers). + * @returns { {key: string, code: string, keyCode: number, charCode?: number, modifiers: {ctrlKey:boolean, altKey:boolean, shiftKey:boolean, metaKey:boolean}} | null } + * Returns null if the keyString is invalid or represents only modifiers. + */ + function parseSingleKeyCombination(keyString) { + const parts = keyString.split('+').map((part) => part.trim().toLowerCase()); + const modifiers = { + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + }; + let mainKeyPart = null; + + for (const part of parts) { + if (MODIFIER_KEYS[part]) { + modifiers[MODIFIER_KEYS[part]] = true; + } else if (mainKeyPart === null) { + // First non-modifier is the main key + mainKeyPart = part; + } else { + // Invalid format: multiple main keys in a single combination (e.g., "Ctrl+A+B") + console.error(`Invalid key combination string: ${keyString}. Multiple main keys found.`); + return null; + } + } + + if (!mainKeyPart) { + // This case could happen if the keyString is something like "Ctrl+" or just "Ctrl" + // If the intent was to press JUST 'Control', the input should be 'Control' not 'Control+' + // Let's check if mainKeyPart is actually a modifier name used as a main key + if (Object.keys(MODIFIER_KEYS).includes(parts[parts.length - 1]) && parts.length === 1) { + mainKeyPart = parts[parts.length - 1]; // e.g. user wants to press "Control" key itself + // For "Control" key itself, key: "Control", code: "ControlLeft" (or Right) + if (mainKeyPart === 'ctrl' || mainKeyPart === 'control') + return { key: 'Control', code: 'ControlLeft', keyCode: 17, modifiers }; + if (mainKeyPart === 'alt') return { key: 'Alt', code: 'AltLeft', keyCode: 18, modifiers }; + if (mainKeyPart === 'shift') + return { key: 'Shift', code: 'ShiftLeft', keyCode: 16, modifiers }; + if (mainKeyPart === 'meta' || mainKeyPart === 'command' || mainKeyPart === 'cmd') + return { key: 'Meta', code: 'MetaLeft', keyCode: 91, modifiers }; + } else { + console.error(`Invalid key combination string: ${keyString}. No main key specified.`); + return null; + } + } + + const specialKey = SPECIAL_KEY_MAP[mainKeyPart]; + if (specialKey) { + return { ...specialKey, modifiers }; + } + + // For single characters or other unmapped keys + if (mainKeyPart.length === 1) { + const charCode = mainKeyPart.charCodeAt(0); + // If Shift is active and it's a letter, use the uppercase version for 'key' + // This mimics more closely how keyboards behave. + let keyChar = mainKeyPart; + if (modifiers.shiftKey && mainKeyPart.match(/^[a-z]$/i)) { + keyChar = mainKeyPart.toUpperCase(); + } + + return { + key: keyChar, + code: `Key${mainKeyPart.toUpperCase()}`, // 'a' -> KeyA, 'A' -> KeyA + keyCode: charCode, + charCode: charCode, // charCode is legacy, but some old systems might use it + modifiers, + }; + } + + console.error(`Unknown key: ${mainKeyPart} in string "${keyString}"`); + return null; // Or handle as an error + } + + /** + * Simulates a single key press (keydown, (keypress), keyup) for a parsed key. + * @param { {key: string, code: string, keyCode: number, charCode?: number, modifiers: object} } parsedKeyInfo + * @param {Element} element - Target element. + * @returns {{success: boolean, error?: string}} + */ + function dispatchKeyEvents(parsedKeyInfo, element) { + if (!parsedKeyInfo) return { success: false, error: 'Invalid key info provided for dispatch.' }; + + const { key, code, keyCode, charCode, modifiers } = parsedKeyInfo; + + const eventOptions = { + key: key, + code: code, + bubbles: true, + cancelable: true, + composed: true, // Important for shadow DOM + view: window, + ...modifiers, // ctrlKey, altKey, shiftKey, metaKey + // keyCode/which are deprecated but often set for compatibility + keyCode: keyCode || (key.length === 1 ? key.charCodeAt(0) : 0), + which: keyCode || (key.length === 1 ? key.charCodeAt(0) : 0), + }; + + try { + const kdRes = element.dispatchEvent(new KeyboardEvent('keydown', eventOptions)); + + // keypress is deprecated, but simulate if it's a character key or Enter + // Only dispatch if keydown was not cancelled and it's a character producing key + if (kdRes && (key.length === 1 || key === 'Enter' || key === ' ')) { + const keypressOptions = { ...eventOptions }; + if (charCode) keypressOptions.charCode = charCode; + element.dispatchEvent(new KeyboardEvent('keypress', keypressOptions)); + } + + element.dispatchEvent(new KeyboardEvent('keyup', eventOptions)); + return { success: true }; + } catch (error) { + console.error(`Error dispatching key events for "${key}":`, error); + return { + success: false, + error: `Error dispatching key events for "${key}": ${error.message}`, + }; + } + } + + /** + * Simulate keyboard events on an element or document + * @param {string} keysSequenceString - String representation of key(s) (e.g., "Enter", "Ctrl+C, A, B") + * @param {Element} targetElement - Element to dispatch events on (optional) + * @param {number} delay - Delay between key sequences in milliseconds (optional) + * @returns {Promise} - Result of the keyboard operation + */ + async function simulateKeyboard(keysSequenceString, targetElement = null, delay = 0) { + try { + const element = targetElement || document.activeElement || document.body; + + if (element !== document.activeElement && typeof element.focus === 'function') { + element.focus(); + await new Promise((resolve) => setTimeout(resolve, 50)); // Small delay for focus + } + + const keyCombinations = keysSequenceString + .split(',') + .map((k) => k.trim()) + .filter((k) => k.length > 0); + const operationResults = []; + + for (let i = 0; i < keyCombinations.length; i++) { + const comboString = keyCombinations[i]; + const parsedKeyInfo = parseSingleKeyCombination(comboString); + + if (!parsedKeyInfo) { + operationResults.push({ + keyCombination: comboString, + success: false, + error: `Invalid key string or combination: ${comboString}`, + }); + continue; // Skip to next combination in sequence + } + + const dispatchResult = dispatchKeyEvents(parsedKeyInfo, element); + operationResults.push({ + keyCombination: comboString, + ...dispatchResult, + }); + + if (dispatchResult.error) { + // Optionally, decide if sequence should stop on first error + // For now, we continue but log the error in results + console.warn( + `Failed to simulate key combination "${comboString}": ${dispatchResult.error}`, + ); + } + + if (delay > 0 && i < keyCombinations.length - 1) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // Check if all individual operations were successful + const overallSuccess = operationResults.every((r) => r.success); + + return { + success: overallSuccess, + message: overallSuccess + ? `Keyboard events simulated successfully: ${keysSequenceString}` + : `Some keyboard events failed for: ${keysSequenceString}`, + results: operationResults, // Detailed results for each key combination + targetElement: { + tagName: element.tagName, + id: element.id, + className: element.className, + type: element.type, // if applicable e.g. for input + }, + }; + } catch (error) { + console.error('Error in simulateKeyboard:', error); + return { + success: false, + error: `Error simulating keyboard events: ${error.message}`, + results: [], + }; + } + } + + // Listener for messages from the extension + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.action === 'simulateKeyboard') { + let targetEl = null; + if (request.selector) { + targetEl = document.querySelector(request.selector); + if (!targetEl) { + sendResponse({ + success: false, + error: `Element with selector "${request.selector}" not found`, + results: [], + }); + return true; // Keep channel open for async response + } + } + + simulateKeyboard(request.keys, targetEl, request.delay) + .then(sendResponse) + .catch((error) => { + // This catch is for unexpected errors in simulateKeyboard promise chain itself + console.error('Unexpected error in simulateKeyboard promise chain:', error); + sendResponse({ + success: false, + error: `Unexpected error during keyboard simulation: ${error.message}`, + results: [], + }); + }); + return true; // Indicates async response is expected + } else if (request.action === 'chrome_keyboard_ping') { + sendResponse({ status: 'pong', initialized: true }); // Respond that it's initialized + return false; // Synchronous response + } + // Not our message, or no async response needed + return false; + }); +} diff --git a/app/chrome-extension/inject-scripts/network-helper.js b/app/chrome-extension/inject-scripts/network-helper.js new file mode 100644 index 0000000..2b2addf --- /dev/null +++ b/app/chrome-extension/inject-scripts/network-helper.js @@ -0,0 +1,268 @@ +/* eslint-disable */ +/** + * Network Capture Helper + * + * This script helps replay network requests with the original cookies and headers. + */ + +// Prevent duplicate initialization +if (window.__NETWORK_CAPTURE_HELPER_INITIALIZED__) { + // Already initialized, skip +} else { + window.__NETWORK_CAPTURE_HELPER_INITIALIZED__ = true; + + /** + * Replay a network request + * @param {string} url - The URL to send the request to + * @param {string} method - The HTTP method to use + * @param {Object} headers - The headers to include in the request + * @param {any} body - The body of the request + * @param {number} timeout - Timeout in milliseconds (default: 30000) + * @returns {Promise} - The response data + */ + async function replayNetworkRequest( + url, + method, + headers, + body, + timeout = 30000, + formDataDescriptor = null, + ) { + try { + // Create fetch options + const options = { + method: method, + headers: headers || {}, + credentials: 'include', // Include cookies + mode: 'cors', + cache: 'no-cache', + }; + + // Helper: convert base64 to Blob + const base64ToBlob = (base64, contentType = 'application/octet-stream') => { + try { + const decodedString = atob(base64); + const len = decodedString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = decodedString.charCodeAt(i); + return new Blob([bytes], { type: contentType }); + } catch (e) { + return new Blob([]); + } + }; + + // Helper: request native to read filePath into base64 + const readFileBase64 = (path) => + new Promise((resolve) => { + const requestId = `net-helper-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const timeoutId = setTimeout(() => { + cleanup(); + resolve(null); + }, 30000); + function onMessage(msg) { + if ( + msg && + msg.type === 'file_operation_response' && + msg.responseToRequestId === requestId + ) { + cleanup(); + const p = msg.payload || {}; + if (p.success && p.base64Data) + resolve({ base64: p.base64Data, fileName: p.fileName }); + else resolve(null); + } + } + function cleanup() { + clearTimeout(timeoutId); + chrome.runtime.onMessage.removeListener(onMessage); + } + chrome.runtime.onMessage.addListener(onMessage); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { action: 'readBase64File', filePath: path }, + }, + }) + .catch(() => { + cleanup(); + resolve(null); + }); + }); + + // Build multipart/form-data if descriptor is provided + if (method !== 'GET' && method !== 'HEAD' && formDataDescriptor) { + const fd = new FormData(); + try { + if (Array.isArray(formDataDescriptor)) { + for (const item of formDataDescriptor) { + if (!Array.isArray(item) || item.length < 2) continue; + const name = String(item[0] || 'file'); + const spec = String(item[1] || ''); + const filenameHint = item[2] ? String(item[2]) : undefined; + if (/^(https?:\/\/|url:)/i.test(spec)) { + const url = spec.replace(/^url:/i, ''); + const resp = await fetch(url); + const blob = await resp.blob(); + const fn = + filenameHint || url.split('?')[0].split('#')[0].split('/').pop() || 'file'; + fd.append(name, blob, fn); + } else if (/^base64:/i.test(spec)) { + const b64 = spec.replace(/^base64:/i, ''); + const blob = base64ToBlob(b64); + fd.append(name, blob, filenameHint || 'file'); + } else if (/^file:/i.test(spec)) { + const p = spec.replace(/^file:/i, ''); + const res = await readFileBase64(p); + if (res && res.base64) { + const blob = base64ToBlob(res.base64); + fd.append(name, blob, filenameHint || res.fileName || 'file'); + } + } else { + // treat as string field + fd.append(name, spec); + } + } + } else if (typeof formDataDescriptor === 'object') { + const fds = formDataDescriptor; + const fields = fds.fields || {}; + const files = Array.isArray(fds.files) ? fds.files : []; + for (const [k, v] of Object.entries(fields)) fd.append(String(k), String(v)); + for (const file of files) { + const name = String(file.name || 'file'); + if (file.fileUrl) { + const resp = await fetch(String(file.fileUrl)); + const blob = await resp.blob(); + const fn = + file.filename || + String(file.fileUrl).split('?')[0].split('#')[0].split('/').pop() || + 'file'; + fd.append(name, blob, fn); + } else if (file.base64Data) { + const blob = base64ToBlob( + String(file.base64Data), + String(file.contentType || 'application/octet-stream'), + ); + fd.append(name, blob, file.filename || 'file'); + } else if (file.filePath) { + const res = await readFileBase64(String(file.filePath)); + if (res && res.base64) { + const blob = base64ToBlob( + res.base64, + String(file.contentType || 'application/octet-stream'), + ); + fd.append(name, blob, file.filename || res.fileName || 'file'); + } + } + } + } + } catch (e) { + console.warn('Failed to construct FormData:', e); + } + // Let browser set the correct multipart boundary + try { + if (options.headers) { + delete options.headers['content-type']; + delete options.headers['Content-Type']; + } + } catch {} + options.body = fd; + } else if (method !== 'GET' && method !== 'HEAD' && body !== undefined) { + // Fallback to raw body + options.body = body; + } + + // 创建一个带超时的 fetch + const fetchWithTimeout = async (url, options, timeout) => { + const controller = new AbortController(); + const signal = controller.signal; + + // 设置超时 + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { ...options, signal }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + }; + + // 发送带超时的请求 + const response = await fetchWithTimeout(url, options, timeout); + + // Process response + const responseData = { + status: response.status, + statusText: response.statusText, + headers: {}, + }; + + // Get response headers + response.headers.forEach((value, key) => { + responseData.headers[key] = value; + }); + + // Try to get response body based on content type + const contentType = response.headers.get('content-type') || ''; + + try { + if (contentType.includes('application/json')) { + responseData.body = await response.json(); + } else if ( + contentType.includes('text/') || + contentType.includes('application/xml') || + contentType.includes('application/javascript') + ) { + responseData.body = await response.text(); + } else { + // For binary data, just indicate it was received but not parsed + responseData.body = '[Binary data not displayed]'; + } + } catch (error) { + responseData.body = `[Error parsing response body: ${error.message}]`; + } + + return { + success: true, + response: responseData, + }; + } catch (error) { + console.error('Error replaying request:', error); + return { + success: false, + error: `Error replaying request: ${error.message}`, + }; + } + } + + // Listen for messages from the extension + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + // Respond to ping message + if (request.action === 'chrome_network_request_ping') { + sendResponse({ status: 'pong' }); + return false; // Synchronous response + } else if (request.action === 'sendPureNetworkRequest') { + replayNetworkRequest( + request.url, + request.method, + request.headers, + request.body, + request.timeout, + request.formData, + ) + .then(sendResponse) + .catch((error) => { + sendResponse({ + success: false, + error: `Unexpected error: ${error.message}`, + }); + }); + return true; // Indicates async response + } + }); +} diff --git a/app/chrome-extension/inject-scripts/props-agent.js b/app/chrome-extension/inject-scripts/props-agent.js new file mode 100644 index 0000000..1722763 --- /dev/null +++ b/app/chrome-extension/inject-scripts/props-agent.js @@ -0,0 +1,2393 @@ +/* eslint-disable */ +// @ts-nocheck +/** + * Props Agent - MAIN World Script + * + * Runtime hacking agent for React/Vue Props editing. + * Communicates with ISOLATED world via CustomEvent. + * + * Architecture: + * - Transport: CustomEvent-based request/response + * - Locator: Simplified ElementLocator resolution + * - ReactAdapter: DevTools Hook detection/injection + overrideProps + * - VueAdapter: __vueParentComponent + $forceUpdate + * - Serializer: Safe Props serialization with type preservation + * - Handlers: Request operation dispatch + * + * @module props-agent + */ +(() => { + 'use strict'; + + // ============================================================================= + // Constants & Guards + // ============================================================================= + + const GLOBAL_KEY = '__MCP_WEB_EDITOR_PROPS_AGENT__'; + if (window[GLOBAL_KEY]) return; + + const PROTOCOL_VERSION = 1; + const LOG_PREFIX = '[PropsAgent]'; + + const EVENT_NAME = Object.freeze({ + REQUEST: 'web-editor-props:request', + RESPONSE: 'web-editor-props:response', + CLEANUP: 'web-editor-props:cleanup', + }); + + const REACT_HOOK_NAME = '__REACT_DEVTOOLS_GLOBAL_HOOK__'; + + /** @type {'READY' | 'HOOK_PRESENT_NO_RENDERERS' | 'RENDERERS_NO_EDITING' | 'HOOK_MISSING'} */ + const HOOK_STATUS = Object.freeze({ + READY: 'READY', + HOOK_PRESENT_NO_RENDERERS: 'HOOK_PRESENT_NO_RENDERERS', + RENDERERS_NO_EDITING: 'RENDERERS_NO_EDITING', + HOOK_MISSING: 'HOOK_MISSING', + }); + + const SERIALIZE_LIMITS = Object.freeze({ + maxDepth: 4, + maxEntries: 100, + maxArrayLength: 50, + maxStringLength: 1500, + }); + + // ============================================================================= + // Utilities + // ============================================================================= + + function isObject(value) { + return value !== null && typeof value === 'object'; + } + + function safeString(value) { + try { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return ''; + return String(value); + } catch { + return ''; + } + } + + function logWarn(...args) { + try { + console.warn(LOG_PREFIX, ...args); + } catch { + // Silently ignore + } + } + + // ============================================================================= + // Transport Layer + // ============================================================================= + + const Transport = { + dispatchResponse(detail) { + try { + window.dispatchEvent(new CustomEvent(EVENT_NAME.RESPONSE, { detail })); + } catch (err) { + logWarn('Failed to dispatch response:', err); + } + }, + + createResponse(requestId, success, data, error) { + const response = { + v: PROTOCOL_VERSION, + requestId, + success: Boolean(success), + }; + if (data !== undefined) response.data = data; + if (error !== undefined) response.error = safeString(error); + return response; + }, + + normalizeRequest(detail) { + if (!isObject(detail)) return null; + if (detail.v !== PROTOCOL_VERSION) return null; + + const requestId = typeof detail.requestId === 'string' ? detail.requestId : ''; + const op = typeof detail.op === 'string' ? detail.op : ''; + if (!requestId || !op) return null; + + return { + v: PROTOCOL_VERSION, + requestId, + op, + locator: detail.locator, + payload: detail.payload, + }; + }, + }; + + // ============================================================================= + // Locator - Element Resolution + // ============================================================================= + + const Locator = { + safeQuerySelector(root, selector) { + try { + if (!root || typeof selector !== 'string' || !selector.trim()) return null; + return root.querySelector(selector); + } catch { + return null; + } + }, + + safeQuerySelectorAll(root, selector) { + try { + if (!root || typeof selector !== 'string' || !selector.trim()) return []; + return Array.from(root.querySelectorAll(selector)); + } catch { + return []; + } + }, + + isSelectorUnique(root, selector) { + return this.safeQuerySelectorAll(root, selector).length === 1; + }, + + computeFingerprint(element) { + try { + const parts = []; + const tag = element?.tagName ? String(element.tagName).toLowerCase() : 'unknown'; + parts.push(tag); + const id = element?.id ? String(element.id).trim() : ''; + if (id) parts.push(`id=${id}`); + return parts.join('|'); + } catch { + return ''; + } + }, + + verifyFingerprint(element, fingerprint) { + try { + const current = this.computeFingerprint(element); + const storedParts = safeString(fingerprint).split('|'); + const currentParts = current.split('|'); + + // Tag must match + if (storedParts[0] !== currentParts[0]) return false; + + // If stored has id, current must have same id + const storedId = storedParts.find((p) => p.startsWith('id=')); + const currentId = currentParts.find((p) => p.startsWith('id=')); + if (storedId && storedId !== currentId) return false; + + return true; + } catch { + return false; + } + }, + + normalizeStringArray(value) { + if (!Array.isArray(value)) return []; + return value.map((v) => safeString(v).trim()).filter(Boolean); + }, + + /** + * Resolve ElementLocator to DOM element + * Simplified version for MAIN world (no iframe support yet) + */ + locate(locator, rootDocument = document) { + try { + if (!isObject(locator)) return null; + + let queryRoot = rootDocument; + + // Traverse Shadow DOM host chain + const shadowHostChain = this.normalizeStringArray(locator.shadowHostChain); + for (const hostSelector of shadowHostChain) { + if (!this.isSelectorUnique(queryRoot, hostSelector)) return null; + const host = this.safeQuerySelector(queryRoot, hostSelector); + if (!host) return null; + const shadowRoot = host.shadowRoot; + if (!shadowRoot) return null; + queryRoot = shadowRoot; + } + + // Try each selector candidate + const selectors = this.normalizeStringArray(locator.selectors); + for (const selector of selectors) { + if (!this.isSelectorUnique(queryRoot, selector)) continue; + const element = this.safeQuerySelector(queryRoot, selector); + if (!element) continue; + + // Verify fingerprint if provided + const fp = safeString(locator.fingerprint); + if (fp && !this.verifyFingerprint(element, fp)) continue; + + return element; + } + } catch { + // Best-effort + } + return null; + }, + }; + + // ============================================================================= + // React Adapter + // ============================================================================= + + const ReactAdapter = { + /** Store original values for reset (fiber -> { renderer, originals: Map }) */ + overrideStore: typeof WeakMap === 'function' ? new WeakMap() : null, + + /** Flag to avoid repeated hook installation attempts */ + hookInstallAttempted: false, + + getHook() { + try { + return window[REACT_HOOK_NAME] || null; + } catch { + return null; + } + }, + + /** + * Install minimal DevTools hook if missing. + * Note: This only helps if React hasn't initialized yet. + * Only attempts once per session to avoid repeated pollution. + */ + installMinimalHook() { + // Only attempt once per session + if (this.hookInstallAttempted) { + return { installed: false, hook: this.getHook(), skipped: true }; + } + this.hookInstallAttempted = true; + try { + const existing = window[REACT_HOOK_NAME]; + if (existing && typeof existing.inject === 'function') { + return { installed: false, hook: existing }; + } + + const listeners = Object.create(null); + + const hook = { + renderers: new Map(), + supportsFiber: true, + + inject(renderer) { + try { + const id = this.renderers.size + 1; + this.renderers.set(id, renderer); + this.emit('renderer', { id, renderer }); + return id; + } catch { + return 0; + } + }, + + // Required lifecycle callbacks (no-ops) + onCommitFiberRoot() {}, + onCommitFiberUnmount() {}, + onPostCommitFiberRoot() {}, + setStrictMode() {}, + checkDCE() {}, + + // Event emitter + on(event, fn) { + if (typeof event !== 'string' || typeof fn !== 'function') return; + if (!listeners[event]) listeners[event] = new Set(); + listeners[event].add(fn); + }, + + off(event, fn) { + if (typeof event !== 'string' || typeof fn !== 'function') return; + listeners[event]?.delete(fn); + }, + + emit(event, data) { + const set = listeners[event]; + if (!set) return; + for (const fn of Array.from(set)) { + try { + fn(data); + } catch { + // Listener errors must not break the hook + } + } + }, + + sub(event, fn) { + this.on(event, fn); + return () => this.off(event, fn); + }, + }; + + window[REACT_HOOK_NAME] = hook; + return { installed: true, hook }; + } catch (err) { + return { installed: false, hook: null, error: err }; + } + }, + + /** + * Normalize hook.renderers to array format + */ + normalizeRenderers(hook) { + const result = []; + if (!hook) return result; + + try { + const renderers = hook.renderers; + if (renderers instanceof Map) { + for (const [id, renderer] of renderers.entries()) { + result.push({ id, renderer }); + } + } else if (renderers && typeof renderers === 'object') { + for (const [id, renderer] of Object.entries(renderers)) { + result.push({ id, renderer }); + } + } + } catch { + // Best-effort + } + return result; + }, + + /** + * Detect Hook status (4 states) + */ + detectStatus() { + const hook = this.getHook(); + + if (!hook || typeof hook.inject !== 'function') { + return { + hookStatus: HOOK_STATUS.HOOK_MISSING, + hook: null, + renderers: [], + editableRenderers: [], + }; + } + + const renderers = this.normalizeRenderers(hook); + if (!renderers.length) { + return { + hookStatus: HOOK_STATUS.HOOK_PRESENT_NO_RENDERERS, + hook, + renderers, + editableRenderers: [], + }; + } + + const editableRenderers = renderers.filter( + (r) => r?.renderer && typeof r.renderer.overrideProps === 'function', + ); + + if (editableRenderers.length) { + return { + hookStatus: HOOK_STATUS.READY, + hook, + renderers, + editableRenderers, + }; + } + + return { + hookStatus: HOOK_STATUS.RENDERERS_NO_EDITING, + hook, + renderers, + editableRenderers: [], + }; + }, + + /** + * Get React version from renderer or global. + * Prioritizes specific renderer version for multi-renderer scenarios. + * + * @param {object} hookInfo - Result from detectStatus() + * @param {object} [specificRenderer] - Specific renderer to prefer (from resolveFiberWithRenderer) + * @returns {string | undefined} + */ + getVersion(hookInfo, specificRenderer) { + try { + // Priority 1: Specific renderer version (for multi-renderer scenarios) + if (specificRenderer) { + const version = specificRenderer.version; + if (typeof version === 'string' && version.trim()) { + return version.trim(); + } + } + + // Priority 2: Any renderer with version + const renderers = hookInfo?.renderers || []; + for (const item of renderers) { + const version = item?.renderer?.version; + if (typeof version === 'string' && version.trim()) { + return version.trim(); + } + } + + // Priority 3: Global React object (if exposed) + if (typeof window !== 'undefined' && window.React?.version) { + return String(window.React.version).trim(); + } + } catch { + // Best-effort + } + return undefined; + }, + + /** + * Find React fiber from DOM node + */ + findFiberFromDOM(node) { + try { + if (!node || typeof node !== 'object') return null; + const keys = Object.keys(node); + for (const key of keys) { + if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) { + return node[key]; + } + } + } catch { + // Best-effort + } + return null; + }, + + /** + * Check if fiber tag is a component (Function/Class/ForwardRef etc.) + */ + isComponentTag(tag) { + // 0=FunctionComponent, 1=ClassComponent, 2=IndeterminateComponent, + // 11=ForwardRef, 14=MemoComponent, 15=SimpleMemoComponent + return tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 14 || tag === 15; + }, + + /** + * Find nearest component fiber by walking up the fiber tree + */ + findNearestComponentFiber(fiber) { + try { + let current = fiber; + for (let i = 0; i < 60 && current; i++) { + if (this.isComponentTag(current.tag)) return current; + current = current.return; + } + } catch { + // Best-effort + } + return null; + }, + + /** + * Get component display name from fiber + */ + getComponentName(fiber) { + try { + const type = fiber?.type || fiber?.elementType; + if (!type) return 'Anonymous'; + if (typeof type === 'string') return type; + return safeString(type.displayName || type.name) || 'Anonymous'; + } catch { + return 'Anonymous'; + } + }, + + /** + * Extract debug source from React Fiber. + * Walks up the fiber tree checking _debugSource and _debugOwner._debugSource. + * + * @param {object} fiber - React Fiber node + * @returns {{ file: string, line?: number, column?: number, componentName?: string } | null} + */ + getDebugSource(fiber) { + try { + let current = fiber; + for (let i = 0; i < 40 && current; i++) { + if (!isObject(current)) break; + + // Try direct _debugSource first + const src = isObject(current._debugSource) ? current._debugSource : null; + if (src) { + const file = safeString(src.fileName).trim(); + if (file) { + return this.buildDebugSourceResult(file, src.lineNumber, src.columnNumber, current); + } + } + + // Fallback to _debugOwner._debugSource + const owner = isObject(current._debugOwner) ? current._debugOwner : null; + const ownerSrc = owner && isObject(owner._debugSource) ? owner._debugSource : null; + if (ownerSrc) { + const ownerFile = safeString(ownerSrc.fileName).trim(); + if (ownerFile) { + return this.buildDebugSourceResult( + ownerFile, + ownerSrc.lineNumber, + ownerSrc.columnNumber, + owner, + ); + } + } + + current = current.return; + } + } catch { + // Best-effort extraction + } + return null; + }, + + /** + * Build debug source result with validated line/column values. + * @private + */ + buildDebugSourceResult(file, lineNumber, columnNumber, fiberForName) { + const line = Number(lineNumber); + const column = Number(columnNumber); + return { + file, + line: Number.isFinite(line) && line > 0 ? line : undefined, + column: Number.isFinite(column) && column > 0 ? column : undefined, + componentName: this.getComponentName(fiberForName), + }; + }, + + /** + * Resolve fiber using renderer.findFiberByHostInstance when available + */ + resolveFiberWithRenderer(element, hookInfo) { + // Prefer renderer API (returns renderer-owned fiber suitable for overrideProps) + try { + const renderers = hookInfo?.renderers || []; + for (const item of renderers) { + const renderer = item?.renderer; + if (!renderer || typeof renderer.findFiberByHostInstance !== 'function') continue; + try { + const fiber = renderer.findFiberByHostInstance(element); + if (fiber) return { fiber, renderer }; + } catch { + // Try next renderer + } + } + } catch { + // Best-effort + } + + // Fallback: DOM-attached fiber reference + const fallback = this.findFiberFromDOM(element); + return { fiber: fallback, renderer: null }; + }, + + /** + * Record original value for reset + */ + recordOriginal(fiber, renderer, path, existed, value) { + if (!this.overrideStore || !fiber) return; + + try { + const key = JSON.stringify(path); + let store = this.overrideStore.get(fiber); + + if (!store) { + store = { renderer: renderer || null, originals: new Map() }; + this.overrideStore.set(fiber, store); + + // Also store by alternate to improve reset hit rate + if (fiber.alternate && typeof fiber.alternate === 'object') { + this.overrideStore.set(fiber.alternate, store); + } + } + + if (!store.originals.has(key)) { + store.originals.set(key, { path, existed, value }); + } + + if (!store.renderer && renderer) { + store.renderer = renderer; + } + } catch { + // Best-effort + } + }, + + /** + * Get stored originals for fiber + */ + getOriginals(fiber) { + if (!this.overrideStore || !fiber) return null; + return this.overrideStore.get(fiber) || null; + }, + + /** + * Clear stored originals for fiber + */ + clearOriginals(fiber) { + if (!this.overrideStore || !fiber) return; + const store = this.overrideStore.get(fiber); + if (store?.originals) store.originals.clear(); + }, + }; + + // ============================================================================= + // Vue Adapter + // ============================================================================= + + const VueAdapter = { + /** Store original values for reset (instance -> Map) */ + overrideStore: typeof WeakMap === 'function' ? new WeakMap() : null, + + /** + * Find Vue 3 component instance from DOM node + */ + findInstanceFromDOM(node) { + try { + if (!node || typeof node !== 'object') return null; + return node.__vueParentComponent || null; + } catch { + return null; + } + }, + + /** + * Get component name from instance + */ + getComponentName(instance) { + try { + const type = instance?.type; + return safeString(type?.name || type?.__name) || 'Anonymous'; + } catch { + return 'Anonymous'; + } + }, + + /** + * Check if instance appears to be from dev build + */ + isDevBuild(instance) { + try { + const type = instance?.type; + const file = type?.__file; + return typeof file === 'string' && !!file.trim(); + } catch { + return false; + } + }, + + /** + * Parse Vue inspector location attribute value. + * Format: "src/components/Foo.vue:23:7" or "C:\path\file.vue:10:5" (Windows) + * + * Uses trailing regex to safely handle Windows paths with drive letters. + * + * @param {string} value - The data-v-inspector attribute value + * @returns {{ file: string, line?: number, column?: number } | null} + */ + parseVInspector(value) { + if (typeof value !== 'string') return null; + const raw = value.trim(); + if (!raw) return null; + + // Match only trailing :line or :line:column to avoid Windows drive letter issues + const match = raw.match(/:([\d]+)(?::([\d]+))?$/); + if (!match) { + // No line info, return file only + return { file: raw }; + } + + const file = raw.slice(0, match.index).trim(); + if (!file) return null; + + const line = Number.parseInt(match[1], 10); + const column = match[2] ? Number.parseInt(match[2], 10) : undefined; + + return { + file, + line: Number.isFinite(line) && line > 0 ? line : undefined, + column: Number.isFinite(column) && column > 0 ? column : undefined, + }; + }, + + /** + * Walk up DOM tree to find data-v-inspector attribute. + * This attribute is injected by @vitejs/plugin-vue-inspector. + * + * @param {Element} element - Starting DOM element + * @param {number} [maxDepth=15] - Maximum depth to traverse + * @returns {{ file: string, line?: number, column?: number } | null} + */ + findInspectorLocation(element, maxDepth = 15) { + try { + let node = element; + for (let depth = 0; depth < maxDepth && node; depth++) { + if (typeof node.getAttribute === 'function') { + const attr = node.getAttribute('data-v-inspector'); + if (attr) { + const parsed = this.parseVInspector(attr); + if (parsed?.file) return parsed; + } + } + node = node.parentElement; + } + } catch { + // Best-effort extraction + } + return null; + }, + + /** + * Get Vue component debug source. + * Priority: data-v-inspector (has line/column) > type.__file (file only) + * + * @param {object} instance - Vue component instance + * @param {Element} targetElement - DOM element for inspector lookup + * @returns {{ file: string, line?: number, column?: number, componentName?: string } | null} + */ + getDebugSource(instance, targetElement) { + try { + // Priority 1: data-v-inspector attribute (has precise line/column) + const inspector = this.findInspectorLocation(targetElement); + if (inspector?.file) { + return { + file: inspector.file, + line: inspector.line, + column: inspector.column, + componentName: this.getComponentName(instance), + }; + } + + // Priority 2: type.__file (file only, no line/column) + const typeFile = instance?.type?.__file; + if (typeof typeFile === 'string') { + const file = typeFile.trim(); + if (file) { + return { + file, + componentName: this.getComponentName(instance), + }; + } + } + } catch { + // Best-effort extraction + } + return null; + }, + + /** + * Get Vue 3 version from instance. + * Note: This adapter only supports Vue 3 (via __vueParentComponent). + * + * @param {object} instance - Vue 3 component instance + * @returns {string | undefined} + */ + getVersion(instance) { + try { + // Vue 3: Get version from app context + const appVersion = instance?.appContext?.app?.version; + if (typeof appVersion === 'string' && appVersion.trim()) { + return appVersion.trim(); + } + } catch { + // Best-effort + } + return undefined; + }, + + /** + * Get writable props container (vnode.props or instance.props) + * @deprecated Use getWriteContainers for better targeting + */ + getPropsContainer(instance) { + try { + const vnodeProps = instance?.vnode?.props; + if (vnodeProps && typeof vnodeProps === 'object') return vnodeProps; + } catch { + // ignore + } + + try { + const props = instance?.props; + if (props && typeof props === 'object') return props; + } catch { + // ignore + } + + return null; + }, + + /** + * Check if a key is a declared prop (vs fallthrough attr). + * Uses component type definition and runtime props object. + */ + isDeclaredProp(instance, key) { + // Check type.props definition first + try { + const opts = instance?.type?.props; + if (Array.isArray(opts)) return opts.includes(key); + if (isObject(opts)) return Object.prototype.hasOwnProperty.call(opts, key); + } catch { + // ignore + } + + // Fallback: if key exists in instance.props, treat as declared + try { + const props = instance?.props; + if (isObject(props)) { + return Object.prototype.hasOwnProperty.call(props, key); + } + } catch { + // ignore + } + + return false; + }, + + /** + * Get write container candidates for a prop kind ('props' | 'attrs'). + * Returns array of containers to try in order. + */ + getWriteContainers(instance, kind) { + const containers = []; + const seen = typeof Set === 'function' ? new Set() : null; + + const addContainer = (obj) => { + if (!obj || typeof obj !== 'object') return; + if (seen) { + if (seen.has(obj)) return; + seen.add(obj); + } + containers.push(obj); + }; + + if (!instance || typeof instance !== 'object') return containers; + + // Primary container based on kind + if (kind === 'attrs') { + try { + addContainer(instance.attrs); + } catch { + // ignore + } + } else { + try { + addContainer(instance.props); + } catch { + // ignore + } + } + + // Fallback: vnode.props (often more writable) + try { + addContainer(instance?.vnode?.props); + } catch { + // ignore + } + + return containers; + }, + + /** + * Get logical root for reading a prop kind. + */ + getReadRoot(instance, kind) { + if (kind === 'attrs') { + try { + if (isObject(instance?.attrs)) return instance.attrs; + } catch { + // ignore + } + } else { + try { + if (isObject(instance?.props)) return instance.props; + } catch { + // ignore + } + } + + // Fallback + try { + if (isObject(instance?.vnode?.props)) return instance.vnode.props; + } catch { + // ignore + } + + return null; + }, + + /** + * Get raw vnode props object + */ + getVNodeProps(instance) { + try { + const p = instance?.vnode?.props; + return isObject(p) ? p : null; + } catch { + return null; + } + }, + + /** + * Apply new raw props via instance.next + instance.update() so Vue runs its internal + * updateProps/updateSlots pipeline (closest to a parent-driven props update). + * This is the correct way to trigger Vue3 props update. + */ + applyNextProps(instance, nextRawProps) { + try { + const vnode = instance?.vnode; + if (!vnode || typeof vnode !== 'object') return false; + + // Vue3 PatchFlags.FULL_PROPS = 16 + const FULL_PROPS = 16; + const prevFlag = typeof vnode.patchFlag === 'number' ? vnode.patchFlag : 0; + const patchFlag = prevFlag >= 0 ? prevFlag | FULL_PROPS : FULL_PROPS; + + // Create next vnode with updated props + const nextVNode = Object.assign({}, vnode, { + props: nextRawProps, + patchFlag, + dynamicProps: null, + component: instance, + }); + + instance.next = nextVNode; + + // Trigger update + if (instance && typeof instance.update === 'function') { + instance.update(); + return true; + } + + const proxy = instance?.proxy; + if (proxy && typeof proxy.$forceUpdate === 'function') { + proxy.$forceUpdate(); + return true; + } + } catch { + // ignore + } + return false; + }, + + /** + * Trigger Vue re-render (fallback, may not work for props changes) + */ + forceUpdate(instance) { + try { + const proxy = instance?.proxy; + if (proxy && typeof proxy.$forceUpdate === 'function') { + proxy.$forceUpdate(); + return true; + } + } catch { + // ignore + } + + try { + if (instance && typeof instance.update === 'function') { + instance.update(); + return true; + } + } catch { + // ignore + } + + return false; + }, + + /** + * Immutable update helper for nested props + */ + copyWithSet(root, path, value) { + if (!Array.isArray(path) || path.length === 0) return value; + + const seg = path[0]; + const rest = path.slice(1); + const isIndex = typeof seg === 'number'; + + let base = root; + if ( + base === null || + base === undefined || + (typeof base !== 'object' && !Array.isArray(base)) + ) { + base = isIndex ? [] : {}; + } + + const clone = Array.isArray(base) ? base.slice() : { ...base }; + clone[seg] = this.copyWithSet(clone[seg], rest, value); + return clone; + }, + + /** + * Record original value for reset + * @param {object} instance - Vue component instance + * @param {Array} path - Prop path + * @param {boolean} existed - Whether the prop existed before + * @param {*} value - Original value + * @param {'props'|'attrs'} [targetKind] - Target container kind (for accurate reset) + */ + recordOriginal(instance, path, existed, value, targetKind) { + if (!this.overrideStore || !instance) return; + + try { + const key = JSON.stringify(path); + let store = this.overrideStore.get(instance); + + if (!store) { + store = new Map(); + this.overrideStore.set(instance, store); + } + + if (!store.has(key)) { + store.set(key, { path, existed, value, targetKind }); + } + } catch { + // Best-effort + } + }, + + /** + * Get stored originals for instance + */ + getOriginals(instance) { + if (!this.overrideStore || !instance) return null; + return this.overrideStore.get(instance) || null; + }, + + /** + * Clear stored originals for instance + */ + clearOriginals(instance) { + if (!this.overrideStore || !instance) return; + const store = this.overrideStore.get(instance); + if (store) store.clear(); + }, + }; + + // ============================================================================= + // Framework Detector + // ============================================================================= + + const FrameworkDetector = { + /** + * Detect framework for element (walks up DOM tree) + */ + detect(element, maxDepth = 15) { + let node = element; + + for (let depth = 0; depth < maxDepth && node; depth++) { + // React first (more common) + const fiber = ReactAdapter.findFiberFromDOM(node); + if (fiber) { + return { framework: 'react', node, data: fiber }; + } + + // Vue 3 + const vue = VueAdapter.findInstanceFromDOM(node); + if (vue) { + return { framework: 'vue', node, data: vue }; + } + + node = node.parentElement; + } + + return { framework: 'unknown', node: null, data: null }; + }, + }; + + // ============================================================================= + // Serializer + // ============================================================================= + + const Serializer = { + /** + * Check if value is a React element + */ + isReactElement(value) { + try { + if (!value || typeof value !== 'object') return false; + const t = value.$$typeof; + if (!t) return false; + + if (typeof Symbol === 'function' && Symbol.for) { + return ( + t === Symbol.for('react.element') || + t === Symbol.for('react.transitional.element') || + t === Symbol.for('react.portal') + ); + } + + // Fallback heuristic + return !!(value.type && value.props); + } catch { + return false; + } + }, + + /** + * Get React element display string + */ + reactElementDisplay(value) { + try { + const type = value?.type; + if (typeof type === 'string') return `<${type} />`; + if (typeof type === 'function') { + return `<${safeString(type.displayName || type.name) || 'Anonymous'} />`; + } + if (type && typeof type === 'object') { + const name = safeString(type.displayName || type.name) || 'Anonymous'; + return `<${name} />`; + } + } catch { + // ignore + } + return ''; + }, + + /** + * Check if value is an editable primitive + */ + isEditablePrimitive(value) { + if (value === null || value === undefined) return true; + const t = typeof value; + if (t === 'string' || t === 'boolean') return true; + if (t === 'number') return Number.isFinite(value); + return false; + }, + + /** + * Create serialization context for cycle detection + */ + createContext() { + return { + seen: typeof WeakMap === 'function' ? new WeakMap() : null, + nextId: 1, + }; + }, + + /** + * Serialize a value with type information + */ + serializeValue(value, ctx, depth = 0) { + try { + if (value === null) return { kind: 'null' }; + if (value === undefined) return { kind: 'undefined' }; + + const t = typeof value; + + if (t === 'string') { + if (value.length > SERIALIZE_LIMITS.maxStringLength) { + return { + kind: 'string', + value: value.slice(0, SERIALIZE_LIMITS.maxStringLength), + truncated: true, + length: value.length, + }; + } + return { kind: 'string', value }; + } + + if (t === 'number') { + if (Number.isFinite(value)) return { kind: 'number', value }; + if (Number.isNaN(value)) return { kind: 'number', special: 'NaN' }; + return { kind: 'number', special: value > 0 ? 'Infinity' : '-Infinity' }; + } + + if (t === 'boolean') return { kind: 'boolean', value }; + if (t === 'bigint') return { kind: 'bigint', value: value.toString() }; + if (t === 'symbol') return { kind: 'symbol', description: safeString(value) }; + if (t === 'function') + return { kind: 'function', name: safeString(value.name) || undefined }; + + // Object types + if (this.isReactElement(value)) { + return { kind: 'react_element', display: this.reactElementDisplay(value) }; + } + + if (typeof Element !== 'undefined' && value instanceof Element) { + return { + kind: 'dom_element', + tagName: safeString(value.tagName).toLowerCase(), + id: safeString(value.id) || undefined, + className: safeString(value.className) || undefined, + }; + } + + if (value instanceof Date) { + let iso = ''; + try { + iso = value.toISOString(); + } catch { + iso = safeString(value); + } + return { kind: 'date', value: iso }; + } + + if (value instanceof RegExp) { + return { kind: 'regexp', source: value.source, flags: value.flags }; + } + + if (value instanceof Error) { + return { + kind: 'error', + name: safeString(value.name) || 'Error', + message: safeString(value.message), + }; + } + + // Depth limit + if (depth >= SERIALIZE_LIMITS.maxDepth) { + return { + kind: 'max_depth', + type: Object.prototype.toString.call(value), + preview: safeString(value), + }; + } + + // Circular reference detection + if (ctx?.seen) { + const existingId = ctx.seen.get(value); + if (existingId) return { kind: 'circular', refId: existingId }; + ctx.seen.set(value, ctx.nextId++); + } + + // Array + if (Array.isArray(value)) { + const max = Math.min(value.length, SERIALIZE_LIMITS.maxArrayLength); + const items = []; + for (let i = 0; i < max; i++) { + items.push(this.serializeValue(value[i], ctx, depth + 1)); + } + return { + kind: 'array', + length: value.length, + truncated: value.length > max, + items, + }; + } + + // Map + if (value instanceof Map) { + const entries = []; + let count = 0; + for (const [k, v] of value.entries()) { + if (count >= SERIALIZE_LIMITS.maxEntries) break; + entries.push({ + key: this.serializeValue(k, ctx, depth + 1), + value: this.serializeValue(v, ctx, depth + 1), + }); + count++; + } + return { + kind: 'map', + size: value.size, + truncated: value.size > count, + entries, + }; + } + + // Set + if (value instanceof Set) { + const items = []; + let count = 0; + for (const v of value.values()) { + if (count >= SERIALIZE_LIMITS.maxEntries) break; + items.push(this.serializeValue(v, ctx, depth + 1)); + count++; + } + return { + kind: 'set', + size: value.size, + truncated: value.size > count, + items, + }; + } + + // Plain object + const constructorName = value?.constructor?.name; + const name = typeof constructorName === 'string' ? constructorName : undefined; + const keys = Object.keys(value); + const limitedKeys = keys.slice(0, SERIALIZE_LIMITS.maxEntries); + const entries = limitedKeys.map((k) => ({ + key: k, + value: this.serializeValue(value[k], ctx, depth + 1), + })); + + return { + kind: 'object', + name: name !== 'Object' ? name : undefined, + truncated: keys.length > limitedKeys.length, + entries, + }; + } catch (err) { + return { kind: 'unknown', type: typeof value, preview: safeString(err) }; + } + }, + + /** + * Serialize props object to structured format + * @param {object} props - Props object to serialize + * @param {Record>} [enumValuesByKey] - Optional enum values by prop key + */ + serializeProps(props, enumValuesByKey) { + const ctx = this.createContext(); + const entries = []; + const enumMap = isObject(enumValuesByKey) ? enumValuesByKey : null; + + if (!props || (typeof props !== 'object' && typeof props !== 'function')) { + return { kind: 'props', entries: [] }; + } + + const keys = Object.keys(props); + const limited = keys.slice(0, SERIALIZE_LIMITS.maxEntries); + + for (const key of limited) { + let raw; + try { + raw = props[key]; + } catch { + raw = undefined; + } + + const entry = { + key, + editable: this.isEditablePrimitive(raw), + value: this.serializeValue(raw, ctx, 0), + }; + + // Attach enum values if available + const enumValues = enumMap ? enumMap[key] : null; + if (Array.isArray(enumValues) && enumValues.length > 0) { + entry.enumValues = enumValues.slice(0, EnumIntrospection.MAX_ENUM_VALUES); + } + + entries.push(entry); + } + + const result = { kind: 'props', entries }; + if (keys.length > limited.length) result.truncated = true; + return result; + }, + }; + + // ============================================================================= + // Enum Introspection (Best-effort) + // ============================================================================= + + /** + * Best-effort enum value extraction from React/Vue runtime metadata. + * + * React: Relies on __docgenInfo (Storybook/react-docgen output) + * Vue: Relies on explicit values/validator.values in props options + */ + const EnumIntrospection = { + MAX_ENUM_VALUES: 50, + + /** + * Normalize a raw enum value to primitive + */ + normalizeEnumValue(raw) { + if (raw === null || raw === undefined) return null; + + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + + const s = safeString(raw).trim(); + if (!s) return null; + + // Strip surrounding quotes: "'primary'" -> "primary" + const m = s.match(/^(['"])(.*)\1$/); + const unquoted = m ? m[2] : s; + + if (unquoted === 'true') return true; + if (unquoted === 'false') return false; + + if (/^-?(?:\d+|\d*\.\d+)$/.test(unquoted)) { + const n = Number(unquoted); + if (Number.isFinite(n)) return n; + } + + return unquoted; + }, + + /** + * Normalize array of enum values, deduplicate + */ + normalizeEnumList(list) { + if (!Array.isArray(list)) return []; + const out = []; + const seen = new Set(); + + for (const item of list) { + const v = this.normalizeEnumValue(item); + if (v === null) continue; + const key = + typeof v === 'string' ? `s:${v}` : typeof v === 'number' ? `n:${v}` : `b:${v ? 1 : 0}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(v); + if (out.length >= this.MAX_ENUM_VALUES) break; + } + + return out; + }, + + /** + * Extract enum values from React docgen prop info + * (e.g., from Storybook's __docgenInfo) + */ + extractDocgenEnumValues(propInfo) { + if (!isObject(propInfo)) return []; + + // Check type.name === 'enum' with type.value array + const t = propInfo.type; + if (isObject(t) && t.name === 'enum' && Array.isArray(t.value)) { + const rawList = t.value.map((item) => + isObject(item) && 'value' in item ? item.value : item, + ); + return this.normalizeEnumList(rawList); + } + + // Check tsType for TypeScript enums + const ts = propInfo.tsType; + if (isObject(ts) && ts.name === 'union' && Array.isArray(ts.elements)) { + const rawList = ts.elements.map((el) => + isObject(el) && 'value' in el ? el.value : el.name, + ); + return this.normalizeEnumList(rawList); + } + + return []; + }, + + /** + * Get enum values map for React component + */ + getReactEnumValues(componentFiber) { + try { + const type = componentFiber?.type || componentFiber?.elementType; + if (!type) return {}; + + const docgen = type.__docgenInfo; + if (!isObject(docgen) || !isObject(docgen.props)) return {}; + + const result = {}; + for (const [key, info] of Object.entries(docgen.props)) { + const values = this.extractDocgenEnumValues(info); + if (values.length > 0) result[key] = values; + } + return result; + } catch { + return {}; + } + }, + + /** + * Extract enum values from Vue prop option + */ + extractVuePropEnumValues(propOption) { + if (!isObject(propOption)) return []; + + // Check explicit values array + if (Array.isArray(propOption.values)) { + return this.normalizeEnumList(propOption.values); + } + + // Check validator with values/allowedValues + const validator = propOption.validator; + if (validator && Array.isArray(validator.values)) { + return this.normalizeEnumList(validator.values); + } + if (validator && Array.isArray(validator.allowedValues)) { + return this.normalizeEnumList(validator.allowedValues); + } + + return []; + }, + + /** + * Get enum values map for Vue component + */ + getVueEnumValues(instance) { + try { + const propsOptions = instance?.type?.props; + if (!isObject(propsOptions)) return {}; + + const result = {}; + for (const [key, opt] of Object.entries(propsOptions)) { + const values = this.extractVuePropEnumValues(opt); + if (values.length > 0) result[key] = values; + } + return result; + } catch { + return {}; + } + }, + }; + + // ============================================================================= + // Value Access Helpers + // ============================================================================= + + function getValueAtPath(root, path) { + let current = root; + + for (let i = 0; i < path.length; i++) { + const seg = path[i]; + if (!isObject(current) && !Array.isArray(current)) { + return { ok: false, existed: false, value: undefined }; + } + + const has = Object.prototype.hasOwnProperty.call(current, seg); + current = current[seg]; + + if (!has && i === path.length - 1) { + return { ok: true, existed: false, value: undefined }; + } + } + + return { ok: true, existed: true, value: current }; + } + + // Dangerous keys that could cause prototype pollution or unexpected behavior + const DANGEROUS_KEYS = new Set([ + '__proto__', + 'constructor', + 'prototype', + '__defineGetter__', + '__defineSetter__', + '__lookupGetter__', + '__lookupSetter__', + ]); + + function isDangerousKey(key) { + return typeof key === 'string' && DANGEROUS_KEYS.has(key); + } + + function normalizePropPath(value) { + if (!Array.isArray(value) || value.length === 0 || value.length > 32) return null; + + const result = []; + for (const seg of value) { + if (typeof seg === 'string') { + const s = seg.trim(); + if (!s) return null; + // Reject dangerous keys to prevent prototype pollution + if (isDangerousKey(s)) return null; + result.push(s); + } else if (typeof seg === 'number' && Number.isInteger(seg) && seg >= 0 && seg <= 1e6) { + result.push(seg); + } else { + return null; + } + } + return result; + } + + function decodeIncomingValue(raw) { + // Bridge encodes undefined as { $we: 'undefined' } + if (isObject(raw) && raw.$we === 'undefined') return undefined; + return raw; + } + + // ============================================================================= + // Capabilities Builder + // ============================================================================= + + function makeCapabilities(init) { + return { + canRead: Boolean(init?.canRead), + canWrite: Boolean(init?.canWrite), + canWriteHooks: Boolean(init?.canWriteHooks), + }; + } + + function buildResponseData(init) { + const data = {}; + if (init?.hookStatus) data.hookStatus = init.hookStatus; + if (typeof init?.needsRefresh === 'boolean') data.needsRefresh = init.needsRefresh; + if (init?.framework) data.framework = init.framework; + if (init?.frameworkVersion) data.frameworkVersion = init.frameworkVersion; + if (init?.componentName) data.componentName = init.componentName; + if (init?.debugSource) data.debugSource = init.debugSource; + if (init?.props) data.props = init.props; + if (init?.capabilities) data.capabilities = init.capabilities; + if (init?.meta) data.meta = init.meta; + return data; + } + + // ============================================================================= + // Request Handlers + // ============================================================================= + + const Handlers = { + resolveTarget(locator) { + if (!locator) return null; + const el = Locator.locate(locator, document); + // Return element if connected to DOM; otherwise return null + return el?.isConnected ? el : null; + }, + + /** + * Handle 'probe' operation - Detect capabilities without reading props + */ + handleProbe(req) { + // Check initial hook status + const preStatus = ReactAdapter.detectStatus(); + const initialHookStatus = preStatus.hookStatus; + + // Try to install hook if missing (only helps if React hasn't initialized) + if (initialHookStatus === HOOK_STATUS.HOOK_MISSING) { + ReactAdapter.installMinimalHook(); + } + + const hookInfo = ReactAdapter.detectStatus(); + // Report original status if hook was missing (so UI knows refresh is needed) + const hookStatus = + initialHookStatus === HOOK_STATUS.HOOK_MISSING + ? HOOK_STATUS.HOOK_MISSING + : hookInfo.hookStatus; + + const target = this.resolveTarget(req.locator); + const fw = target ? FrameworkDetector.detect(target) : { framework: 'unknown', data: null }; + + let componentName; + let debugSource; + let canRead = false; + let canWrite = false; + let needsRefresh = false; + + let frameworkVersion; + + if (fw.framework === 'react') { + const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo); + const componentFiber = fiberInfo.fiber + ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber) + : null; + + componentName = componentFiber ? ReactAdapter.getComponentName(componentFiber) : undefined; + // Extract debug source from component fiber or raw fiber + const sourceFiber = componentFiber || fiberInfo.fiber; + debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined; + // Pass specific renderer to prioritize its version in multi-renderer scenarios + frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer); + canRead = Boolean(componentFiber); + canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber); + needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY; + } else if (fw.framework === 'vue') { + const instance = fw.data; + componentName = VueAdapter.getComponentName(instance); + debugSource = instance ? VueAdapter.getDebugSource(instance, target) : undefined; + frameworkVersion = VueAdapter.getVersion(instance); + canRead = Boolean(instance); + canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance); + needsRefresh = false; + } + + const data = buildResponseData({ + hookStatus, + framework: fw.framework, + frameworkVersion, + componentName, + debugSource, + capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }), + needsRefresh, + }); + + return Transport.createResponse(req.requestId, true, data); + }, + + /** + * Handle 'read' operation - Read component props + */ + handleRead(req) { + const target = this.resolveTarget(req.locator); + if (!target) { + return Transport.createResponse( + req.requestId, + false, + undefined, + 'Target element not found', + ); + } + + const preStatus = ReactAdapter.detectStatus(); + if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) { + ReactAdapter.installMinimalHook(); + } + + const hookInfo = ReactAdapter.detectStatus(); + const hookStatus = + preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING + ? HOOK_STATUS.HOOK_MISSING + : hookInfo.hookStatus; + + const fw = FrameworkDetector.detect(target); + + if (fw.framework === 'react') { + const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo); + const componentFiber = fiberInfo.fiber + ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber) + : null; + + // Extract debug source even if component fiber not found + const sourceFiber = componentFiber || fiberInfo.fiber; + const debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined; + // Pass specific renderer to prioritize its version in multi-renderer scenarios + const frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer); + + if (!componentFiber) { + const data = buildResponseData({ + hookStatus, + framework: 'react', + frameworkVersion, + debugSource, + capabilities: makeCapabilities({ canRead: false, canWrite: false }), + needsRefresh: false, + }); + return Transport.createResponse( + req.requestId, + false, + data, + 'React component fiber not found', + ); + } + + const props = componentFiber.memoizedProps; + const enumValuesByKey = EnumIntrospection.getReactEnumValues(componentFiber); + const serialized = Serializer.serializeProps(props, enumValuesByKey); + const componentName = ReactAdapter.getComponentName(componentFiber); + const canWrite = hookStatus === HOOK_STATUS.READY; + const needsRefresh = hookStatus !== HOOK_STATUS.READY; + + const data = buildResponseData({ + hookStatus, + framework: 'react', + frameworkVersion, + componentName, + debugSource, + props: serialized, + capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }), + needsRefresh, + }); + + return Transport.createResponse(req.requestId, true, data); + } + + if (fw.framework === 'vue') { + const instance = fw.data; + const frameworkVersion = VueAdapter.getVersion(instance); + + if (!instance) { + const data = buildResponseData({ + hookStatus, + framework: 'vue', + frameworkVersion, + capabilities: makeCapabilities({ canRead: false, canWrite: false }), + needsRefresh: false, + }); + return Transport.createResponse( + req.requestId, + false, + data, + 'Vue component instance not found', + ); + } + + const componentName = VueAdapter.getComponentName(instance); + const debugSource = VueAdapter.getDebugSource(instance, target); + + // Read both props and attrs + let rootProps = null; + let rootAttrs = null; + try { + rootProps = instance.props; + } catch { + rootProps = null; + } + try { + rootAttrs = instance.attrs; + } catch { + rootAttrs = null; + } + + // Serialize props with enum introspection + const enumValuesByKey = EnumIntrospection.getVueEnumValues(instance); + const serializedProps = Serializer.serializeProps(rootProps, enumValuesByKey); + const serializedAttrs = Serializer.serializeProps(rootAttrs, null); + + // Merge entries with source annotation + const mergedEntries = []; + if (Array.isArray(serializedProps.entries)) { + for (const entry of serializedProps.entries) { + mergedEntries.push({ ...entry, source: 'props' }); + } + } + if (Array.isArray(serializedAttrs.entries)) { + for (const entry of serializedAttrs.entries) { + mergedEntries.push({ ...entry, source: 'attrs' }); + } + } + + const serialized = { + kind: 'props', + entries: mergedEntries, + }; + if (serializedProps.truncated || serializedAttrs.truncated) { + serialized.truncated = true; + } + + const canWrite = VueAdapter.isDevBuild(instance); + + const data = buildResponseData({ + hookStatus, + framework: 'vue', + frameworkVersion, + componentName, + debugSource, + props: serialized, + capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }), + needsRefresh: false, + }); + + return Transport.createResponse(req.requestId, true, data); + } + + // Unknown framework + const data = buildResponseData({ + hookStatus, + framework: 'unknown', + capabilities: makeCapabilities({ canRead: false, canWrite: false }), + needsRefresh: false, + }); + + return Transport.createResponse(req.requestId, false, data, 'Not a React/Vue component'); + }, + + /** + * Handle 'write' operation - Modify component props + */ + handleWrite(req) { + const target = this.resolveTarget(req.locator); + if (!target) { + return Transport.createResponse( + req.requestId, + false, + undefined, + 'Target element not found', + ); + } + + const path = normalizePropPath(req.payload?.propPath); + if (!path) { + return Transport.createResponse(req.requestId, false, undefined, 'Invalid propPath'); + } + + const rawValue = req.payload?.propValue; + const value = decodeIncomingValue(rawValue); + if (!Serializer.isEditablePrimitive(value)) { + return Transport.createResponse( + req.requestId, + false, + undefined, + 'Only primitive prop values are supported', + ); + } + + const preStatus = ReactAdapter.detectStatus(); + if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) { + ReactAdapter.installMinimalHook(); + } + + const hookInfo = ReactAdapter.detectStatus(); + const hookStatus = + preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING + ? HOOK_STATUS.HOOK_MISSING + : hookInfo.hookStatus; + + const fw = FrameworkDetector.detect(target); + + if (fw.framework === 'react') { + const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo); + const componentFiber = fiberInfo.fiber + ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber) + : null; + + const componentName = componentFiber + ? ReactAdapter.getComponentName(componentFiber) + : undefined; + const canRead = Boolean(componentFiber); + const canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber); + const needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY; + + const base = buildResponseData({ + hookStatus, + framework: 'react', + componentName, + capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }), + needsRefresh, + }); + + if (!componentFiber) { + return Transport.createResponse( + req.requestId, + false, + base, + 'React component fiber not found', + ); + } + + if (hookStatus !== HOOK_STATUS.READY) { + return Transport.createResponse( + req.requestId, + false, + base, + 'React DevTools editing API unavailable. Use a Development build and refresh the page.', + ); + } + + // Check current value for editability and record original + const props = componentFiber.memoizedProps; + const read = getValueAtPath(props, path); + if (read.ok && read.existed && !Serializer.isEditablePrimitive(read.value)) { + return Transport.createResponse( + req.requestId, + false, + base, + 'Target prop is not a primitive (read-only)', + ); + } + + // Try renderers with overrideProps + const candidates = (hookInfo.editableRenderers || []) + .map((r) => r.renderer) + .filter(Boolean); + const preferred = + fiberInfo.renderer && typeof fiberInfo.renderer.overrideProps === 'function' + ? fiberInfo.renderer + : null; + const ordered = preferred + ? [preferred, ...candidates.filter((r) => r !== preferred)] + : candidates; + + let usedRenderer = null; + let lastErr = null; + + for (const renderer of ordered) { + try { + renderer.overrideProps(componentFiber, path, value); + usedRenderer = renderer; + break; + } catch (err) { + lastErr = err; + } + } + + if (!usedRenderer) { + base.meta = { write: { method: 'overrideProps', error: safeString(lastErr) } }; + return Transport.createResponse( + req.requestId, + false, + base, + 'Failed to write props via overrideProps', + ); + } + + ReactAdapter.recordOriginal(componentFiber, usedRenderer, path, read.existed, read.value); + base.meta = { write: { method: 'overrideProps' } }; + + return Transport.createResponse(req.requestId, true, base); + } + + if (fw.framework === 'vue') { + const instance = fw.data; + const componentName = VueAdapter.getComponentName(instance); + const canRead = Boolean(instance); + const canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance); + + const base = buildResponseData({ + hookStatus, + framework: 'vue', + componentName, + capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }), + needsRefresh: false, + }); + + if (!instance) { + return Transport.createResponse( + req.requestId, + false, + base, + 'Vue component instance not found', + ); + } + + if (!VueAdapter.isDevBuild(instance)) { + return Transport.createResponse( + req.requestId, + false, + base, + 'Vue dev metadata missing. Use a Development build.', + ); + } + + // Vue props keys must be strings at top level + if (typeof path[0] !== 'string') { + return Transport.createResponse( + req.requestId, + false, + base, + 'Vue propPath must start with a string key', + ); + } + + const propName = path[0]; + const subPath = path.slice(1); + + // Infer target kind based on whether key is declared prop + const targetKind = VueAdapter.isDeclaredProp(instance, propName) ? 'props' : 'attrs'; + + // Check current value from logical root + const readRoot = VueAdapter.getReadRoot(instance, targetKind) || {}; + const read = getValueAtPath(readRoot, path); + if (read.ok && read.existed && !Serializer.isEditablePrimitive(read.value)) { + return Transport.createResponse( + req.requestId, + false, + base, + 'Target prop is not a primitive (read-only)', + ); + } + + // Build next vnode props (the correct way to update Vue3 props) + const currentRawProps = VueAdapter.getVNodeProps(instance) || {}; + const nextRawProps = { ...currentRawProps }; + + try { + if (subPath.length === 0) { + nextRawProps[propName] = value; + } else { + const prev = nextRawProps[propName]; + nextRawProps[propName] = VueAdapter.copyWithSet(prev, subPath, value); + } + } catch (err) { + base.meta = { + write: { method: 'vueNextVNode', target: targetKind, error: safeString(err) }, + }; + return Transport.createResponse( + req.requestId, + false, + base, + 'Failed to build Vue props patch', + ); + } + + // Apply via instance.next + update() to trigger Vue's internal updateProps pipeline + if (!VueAdapter.applyNextProps(instance, nextRawProps)) { + base.meta = { + write: { method: 'vueNextVNode', target: targetKind, error: 'No update method' }, + }; + return Transport.createResponse( + req.requestId, + false, + base, + 'Vue update method not available', + ); + } + + // Record original for reset only after successful write (include targetKind for accurate reset) + VueAdapter.recordOriginal(instance, path, read.existed, read.value, targetKind); + + base.meta = { write: { method: 'vueNextVNode', target: targetKind } }; + return Transport.createResponse(req.requestId, true, base); + } + + return Transport.createResponse(req.requestId, false, undefined, 'Not a React/Vue component'); + }, + + /** + * Handle 'reset' operation - Restore original props values + */ + handleReset(req) { + const target = this.resolveTarget(req.locator); + if (!target) { + return Transport.createResponse( + req.requestId, + false, + undefined, + 'Target element not found', + ); + } + + const preStatus = ReactAdapter.detectStatus(); + if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) { + ReactAdapter.installMinimalHook(); + } + + const hookInfo = ReactAdapter.detectStatus(); + const hookStatus = + preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING + ? HOOK_STATUS.HOOK_MISSING + : hookInfo.hookStatus; + + const fw = FrameworkDetector.detect(target); + + if (fw.framework === 'react') { + const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo); + const componentFiber = fiberInfo.fiber + ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber) + : null; + + const componentName = componentFiber + ? ReactAdapter.getComponentName(componentFiber) + : undefined; + const canRead = Boolean(componentFiber); + const canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber); + const needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY; + + const base = buildResponseData({ + hookStatus, + framework: 'react', + componentName, + capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }), + needsRefresh, + }); + + if (!componentFiber) { + return Transport.createResponse( + req.requestId, + false, + base, + 'React component fiber not found', + ); + } + + const store = ReactAdapter.getOriginals(componentFiber); + if (!store?.originals?.size) { + base.meta = { reset: { method: 'refresh', reason: 'noOverrides' } }; + base.needsRefresh = true; + return Transport.createResponse(req.requestId, true, base); + } + + if (hookStatus !== HOOK_STATUS.READY) { + base.meta = { reset: { method: 'refresh', reason: 'hookNotReady' } }; + base.needsRefresh = true; + return Transport.createResponse(req.requestId, true, base); + } + + const renderer = store.renderer; + if (!renderer || typeof renderer.overrideProps !== 'function') { + base.meta = { reset: { method: 'refresh', reason: 'missingRenderer' } }; + base.needsRefresh = true; + return Transport.createResponse(req.requestId, true, base); + } + + let reverted = 0; + for (const entry of store.originals.values()) { + try { + renderer.overrideProps(componentFiber, entry.path, entry.value); + reverted++; + } catch { + // Continue reverting others + } + } + + ReactAdapter.clearOriginals(componentFiber); + base.meta = { reset: { method: 'overrideProps', reverted } }; + + return Transport.createResponse(req.requestId, true, base); + } + + if (fw.framework === 'vue') { + const instance = fw.data; + const componentName = VueAdapter.getComponentName(instance); + const canRead = Boolean(instance); + const canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance); + + const base = buildResponseData({ + hookStatus, + framework: 'vue', + componentName, + capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }), + needsRefresh: false, + }); + + if (!instance) { + return Transport.createResponse( + req.requestId, + false, + base, + 'Vue component instance not found', + ); + } + + const store = VueAdapter.getOriginals(instance); + if (!store?.size) { + base.meta = { reset: { method: 'refresh', reason: 'noOverrides' } }; + base.needsRefresh = true; + return Transport.createResponse(req.requestId, true, base); + } + + // Build next vnode props with all originals restored + const currentRawProps = VueAdapter.getVNodeProps(instance) || {}; + const nextRawProps = { ...currentRawProps }; + + let reverted = 0; + for (const entry of store.values()) { + const path = entry.path; + if (!Array.isArray(path) || typeof path[0] !== 'string') continue; + + const propName = path[0]; + const subPath = path.slice(1); + + try { + if (subPath.length === 0) { + if (entry.existed) { + nextRawProps[propName] = entry.value; + } else { + delete nextRawProps[propName]; + } + } else { + const prev = nextRawProps[propName]; + nextRawProps[propName] = VueAdapter.copyWithSet(prev, subPath, entry.value); + } + reverted++; + } catch { + // Continue with other entries + } + } + + // Apply via instance.next + update() to trigger Vue's internal updateProps pipeline + if (!VueAdapter.applyNextProps(instance, nextRawProps)) { + base.meta = { reset: { method: 'refresh', reason: 'noUpdate' } }; + base.needsRefresh = true; + return Transport.createResponse(req.requestId, true, base); + } + + VueAdapter.clearOriginals(instance); + base.meta = { reset: { method: 'vueNextVNode', reverted } }; + + return Transport.createResponse(req.requestId, true, base); + } + + return Transport.createResponse(req.requestId, false, undefined, 'Not a React/Vue component'); + }, + + /** + * Handle 'cleanup' operation - Dispose agent + */ + handleCleanup(req) { + const resp = Transport.createResponse(req.requestId, true, { + meta: { cleanup: { ok: true } }, + }); + Lifecycle.dispose('request'); + return resp; + }, + + /** + * Route request to appropriate handler + */ + handle(req) { + switch (req.op) { + case 'probe': + return this.handleProbe(req); + case 'read': + return this.handleRead(req); + case 'write': + return this.handleWrite(req); + case 'reset': + return this.handleReset(req); + case 'cleanup': + return this.handleCleanup(req); + default: + return Transport.createResponse( + req.requestId, + false, + undefined, + `Unsupported op: ${safeString(req.op)}`, + ); + } + }, + }; + + // ============================================================================= + // Lifecycle Management + // ============================================================================= + + const Lifecycle = { + disposed: false, + + onRequestEvent(event) { + try { + if (Lifecycle.disposed) return; + + const detail = event?.detail; + const req = Transport.normalizeRequest(detail); + if (!req) return; + + const resp = Handlers.handle(req); + Transport.dispatchResponse(resp); + } catch (err) { + try { + const requestId = event?.detail?.requestId; + if (typeof requestId === 'string' && requestId) { + Transport.dispatchResponse( + Transport.createResponse(requestId, false, undefined, safeString(err)), + ); + } + } catch { + // ignore + } + } + }, + + onCleanupEvent() { + Lifecycle.dispose('external-event'); + }, + + dispose(reason) { + if (this.disposed) return; + this.disposed = true; + + try { + window.removeEventListener(EVENT_NAME.REQUEST, this.onRequestEvent, true); + window.removeEventListener(EVENT_NAME.CLEANUP, this.onCleanupEvent, true); + } catch { + // ignore + } + + try { + delete window[GLOBAL_KEY]; + } catch { + // ignore + } + + if (reason) { + logWarn('Disposed:', reason); + } + }, + + init() { + // Use capture phase to avoid page stopPropagation interfering + window.addEventListener(EVENT_NAME.REQUEST, this.onRequestEvent, true); + window.addEventListener(EVENT_NAME.CLEANUP, this.onCleanupEvent, true); + + window[GLOBAL_KEY] = { + version: PROTOCOL_VERSION, + dispose: () => this.dispose('manual'), + }; + + // Early injection: install minimal hook before React loads (document_start) + // This is critical for capturing React renderers that initialize early + if (document.readyState === 'loading') { + try { + const status = ReactAdapter.detectStatus(); + if (status.hookStatus === HOOK_STATUS.HOOK_MISSING) { + ReactAdapter.installMinimalHook(); + logWarn('Installed minimal hook during early injection'); + } + } catch (err) { + // Best-effort: early injection may fail in some environments + logWarn('Early hook injection failed:', err); + } + } + }, + }; + + // Initialize + Lifecycle.init(); +})(); diff --git a/app/chrome-extension/inject-scripts/recorder.js b/app/chrome-extension/inject-scripts/recorder.js new file mode 100644 index 0000000..8c334f7 --- /dev/null +++ b/app/chrome-extension/inject-scripts/recorder.js @@ -0,0 +1,1950 @@ +/* eslint-disable */ +// recorder.js - content script for recording user interactions into steps + +(function () { + if (window.__RR_RECORDER_INSTALLED__) return; + window.__RR_RECORDER_INSTALLED__ = true; + + // ================================================================ + // 1) CONFIG + STATELESS HELPERS (namespaced) + // ================================================================ + const CONFIG = { + // Increase debounce to improve step merging for slow/DOM-replacing inputs + INPUT_DEBOUNCE_MS: 800, + BATCH_SEND_MS: 100, + SCROLL_DEBOUNCE_MS: 350, + SENSITIVE_INPUT_TYPES: new Set(['password']), + UI_MAX_STEPS: 30, + // Maximum time to hold flush while user is typing (prevents unbounded batch accumulation) + MAX_TYPING_HOLD_MS: 1500, + }; + // Cross-frame event channel + const FRAME_EVENT = 'rr_iframe_event'; + + // Memoization caches for selector computations during recording + const __cacheUnique = new WeakMap(); + const __cachePath = new WeakMap(); + + const SelectorEngine = { + buildTarget(el) { + const candidates = []; + const attrNames = ['data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy']; + for (const an of attrNames) { + const v = el.getAttribute && el.getAttribute(an); + if (v) candidates.push({ type: 'attr', value: `[${an}="${CSS.escape(v)}"]` }); + } + const classSel = this._uniqueClassSelector(el); + if (classSel) candidates.push({ type: 'css', value: classSel }); + const css = this._generateSelector(el); + if (css) candidates.push({ type: 'css', value: css }); + const name = el.getAttribute && el.getAttribute('name'); + if (name) candidates.push({ type: 'attr', value: `[name="${CSS.escape(name)}"]` }); + const title = el.getAttribute && el.getAttribute('title'); + if (title) candidates.push({ type: 'attr', value: `[title="${CSS.escape(title)}"]` }); + const alt = el.getAttribute && el.getAttribute('alt'); + if (alt) candidates.push({ type: 'attr', value: `[alt="${CSS.escape(alt)}"]` }); + const aria = el.getAttribute && el.getAttribute('aria-label'); + const role = el.getAttribute && el.getAttribute('role'); + if (aria) { + if (role) candidates.push({ type: 'aria', value: `${role}[name=${aria}]` }); + else candidates.push({ type: 'aria', value: `textbox[name=${aria}]` }); + } + const tag = el.tagName?.toLowerCase?.() || ''; + if (['button', 'a', 'summary'].includes(tag)) { + const text = (el.textContent || '').trim(); + if (text) candidates.push({ type: 'text', value: text.substring(0, 64) }); + } + const selector = SelectorEngine._choosePrimary(el, candidates); + return { selector, candidates, tag }; + }, + + _choosePrimary(el, candidates) { + if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) { + return `#${CSS.escape(el.id)}`; + } + const priority = ['attr', 'css']; + for (const p of priority) { + const c = candidates.find((c) => c.type === p); + if (c) { + try { + const tag = el.tagName ? el.tagName.toLowerCase() : ''; + if (p === 'attr' && (tag === 'input' || tag === 'textarea' || tag === 'select')) { + const val = String(c.value || '').trim(); + if (val.startsWith('[')) return `${tag}${val}`; + } + } catch {} + return c.value; + } + } + if (candidates.length) return candidates[0].value; + return SelectorEngine._generateSelector(el) || ''; + }, + + _uniqueClassSelector(el) { + if (__cacheUnique.has(el)) return __cacheUnique.get(el); + let result = ''; + try { + const classes = Array.from(el.classList || []).filter( + (c) => c && /^[a-zA-Z0-9_-]+$/.test(c), + ); + for (const cls of classes) { + const sel = `.${CSS.escape(cls)}`; + if (document.querySelectorAll(sel).length === 1) { + result = sel; + break; + } + } + if (!result) { + const tag = el.tagName ? el.tagName.toLowerCase() : ''; + for (const cls of classes) { + const sel = `${tag}.${CSS.escape(cls)}`; + if (document.querySelectorAll(sel).length === 1) { + result = sel; + break; + } + } + } + if (!result) { + for (let i = 0; i < Math.min(classes.length, 3) && !result; i++) { + for (let j = i + 1; j < Math.min(classes.length, 3); j++) { + const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`; + if (document.querySelectorAll(sel).length === 1) { + result = sel; + break; + } + } + } + } + } catch {} + __cacheUnique.set(el, result); + return result; + }, + + _generateSelector(el) { + if (!(el instanceof Element)) return ''; + if (__cachePath.has(el)) return __cachePath.get(el); + if (el.id) { + const idSel = `#${CSS.escape(el.id)}`; + if (document.querySelectorAll(idSel).length === 1) return idSel; + } + for (const attr of ['data-testid', 'data-cy', 'name']) { + const attrValue = el.getAttribute(attr); + if (attrValue) { + const s = `[${attr}="${CSS.escape(attrValue)}"]`; + if (document.querySelectorAll(s).length === 1) return s; + } + } + let path = ''; + let current = el; + while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') { + let selector = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === current.tagName, + ); + if (siblings.length > 1) { + const index = siblings.indexOf(current) + 1; + selector += `:nth-of-type(${index})`; + } + } + path = path ? `${selector} > ${path}` : selector; + current = parent; + } + const res = path ? `body > ${path}` : 'body'; + __cachePath.set(el, res); + return res; + }, + }; + // Extend SelectorEngine with a shared ref helper (attached after declaration) + SelectorEngine._ensureGlobalRef = function (el) { + try { + if (!window.__claudeElementMap) window.__claudeElementMap = {}; + if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; + for (const k in window.__claudeElementMap) { + const w = window.__claudeElementMap[k]; + if (w && typeof w.deref === 'function' && w.deref() === el) return k; + } + const id = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[id] = new WeakRef(el); + return id; + } catch { + return null; + } + }; + + // ================================================================ + // 2) UI CLASS (injected via constructor) + // ================================================================ + class UI { + constructor(recorder) { + this.recorder = recorder; + this._box = null; + // Timeline elements state + this._timeline = null; + this._count = 0; + this._timelineBox = null; + this._collapsed = false; + } + ensure() { + const rec = this.recorder; + if (window !== window.top) return; + let root = document.getElementById('__rr_rec_overlay'); + if (root) return; + root = document.createElement('div'); + root.id = '__rr_rec_overlay'; + Object.assign(root.style, { + position: 'fixed', + top: '10px', + right: '10px', + zIndex: 2147483646, + fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', + }); + root.innerHTML = ` +
+ 录制中 + + + + + +
`; + document.documentElement.appendChild(root); + // Build timeline container just below the panel + const timeline = document.createElement('div'); + timeline.id = '__rr_rec_timeline'; + Object.assign(timeline.style, { + marginTop: '8px', + width: '360px', + maxHeight: '220px', + overflow: 'auto', + background: 'rgba(17,24,39,0.85)', + color: '#F9FAFB', + border: '1px solid rgba(255,255,255,0.2)', + borderRadius: '8px', + boxShadow: '0 4px 16px rgba(0,0,0,0.18)', + padding: '8px 10px', + fontSize: '12px', + lineHeight: '1.4', + }); + const header = document.createElement('div'); + header.textContent = '已录制步骤'; + header.style.opacity = '0.8'; + header.style.marginBottom = '4px'; + const list = document.createElement('ol'); + list.id = '__rr_rec_timeline_list'; + list.style.listStyle = 'none'; + list.style.margin = '0'; + list.style.padding = '0'; + list.style.display = 'flex'; + list.style.flexDirection = 'column'; + list.style.gap = '4px'; + timeline.appendChild(header); + timeline.appendChild(list); + root.appendChild(timeline); + this._timeline = list; + this._timelineBox = timeline; + const btnPause = root.querySelector('#__rr_pause'); + const btnStop = root.querySelector('#__rr_stop'); + const hideChk = root.querySelector('#__rr_hide_values'); + const highlightChk = root.querySelector('#__rr_enable_highlight'); + const btnToggle = root.querySelector('#__rr_toggle_timeline'); + hideChk.checked = !!rec.hideInputValues; + hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked)); + highlightChk.checked = !!rec.highlightEnabled; + highlightChk.addEventListener('change', () => { + rec.highlightEnabled = !!highlightChk.checked; + rec._updateHoverListener(); + }); + if (btnToggle) { + btnToggle.addEventListener('click', () => { + this._collapsed = !this._collapsed; + if (this._timelineBox) + this._timelineBox.style.display = this._collapsed ? 'none' : 'block'; + btnToggle.textContent = this._collapsed ? '展开' : '折叠'; + }); + } + btnPause.addEventListener('click', () => { + if (!rec.isPaused) rec.pause(); + else rec.resume(); + }); + btnStop.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'rr_stop_recording' }); + }); + this._box = document.createElement('div'); + Object.assign(this._box.style, { + position: 'fixed', + border: '2px solid rgba(59,130,246,0.9)', + borderRadius: '4px', + background: 'rgba(59,130,246,0.15)', + pointerEvents: 'none', + zIndex: 2147483645, + }); + document.documentElement.appendChild(this._box); + if (rec.highlightEnabled) + document.addEventListener('mousemove', rec._onMouseMove, { capture: true, passive: true }); + this.updateStatus(); + } + remove() { + if (window === window.top) { + const root = document.getElementById('__rr_rec_overlay'); + if (root) root.remove(); + if (this._box) this._box.remove(); + this._timeline = null; + this._timelineBox = null; + } + } + updateStatus() { + const badge = document.getElementById('__rr_badge'); + const pauseBtn = document.getElementById('__rr_pause'); + if (badge) badge.textContent = this.recorder.isPaused ? '已暂停' : '录制中'; + if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? '继续' : '暂停'; + } + + // Reset the timeline list content + resetTimeline() { + this._count = 0; + const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; + if (list) list.innerHTML = ''; + } + + // Append a new recorded step into the timeline UI + appendStep(step) { + const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; + if (!list) return; + this._count += 1; + const item = document.createElement('li'); + const text = this._formatStepText(step, this._count); + item.setAttribute('data-step-id', step.id || ''); + item.style.display = 'flex'; + item.style.alignItems = 'flex-start'; + item.style.gap = '6px'; + item.innerHTML = ` + ${this._count}. + ${text} + `; + list.appendChild(item); + while (list.children.length > CONFIG.UI_MAX_STEPS) { + list.removeChild(list.firstChild); + } + const container = list.parentElement; + if (container) container.scrollTop = container.scrollHeight; + } + + /** + * Apply a full timeline update from background. + * Steps can be upserted in place (same id, updated fields) during fill debouncing. + * Uses smart diffing to minimize DOM operations while ensuring fill values are accurate. + */ + applyTimelineUpdate(steps) { + try { + if (window !== window.top) return; + const list = Array.isArray(steps) ? steps : []; + const total = list.length; + // Ensure UI exists + if (!this._timeline) this.ensure(); + if (!this._timeline) return; + if (total === 0) { + this.resetTimeline(); + return; + } + + // Calculate the window of steps to display (last N steps) + const windowStart = Math.max(0, total - CONFIG.UI_MAX_STEPS); + const windowSteps = list.slice(windowStart); + + // Get current displayed step IDs + const currentItems = this._timeline.children; + const currentIds = []; + for (let i = 0; i < currentItems.length; i++) { + currentIds.push(currentItems[i].getAttribute('data-step-id') || ''); + } + + // Check if we need a full rebuild or can do incremental update + const newIds = windowSteps.map((s) => s.id || ''); + const needsRebuild = + currentIds.length !== newIds.length || currentIds.some((id, i) => id !== newIds[i]); + + if (needsRebuild) { + // Full rebuild: either structure changed or it's simpler to rebuild + this.resetTimeline(); + for (let i = 0; i < windowSteps.length; i++) { + this._appendStepWithIndex(windowSteps[i], windowStart + i + 1); + } + } else { + // Incremental update: same steps, just update values + for (let i = 0; i < windowSteps.length; i++) { + const step = windowSteps[i]; + const item = currentItems[i]; + if (item) { + // Update the text content for this step + const textSpan = item.querySelector('span:last-child'); + if (textSpan) { + const newText = this._formatStepText(step, windowStart + i + 1); + if (textSpan.textContent !== newText) { + textSpan.textContent = newText; + } + } + } + } + } + this._count = total; + } catch {} + } + + /** + * Internal method to append a step with a specific display index. + * Used by applyTimelineUpdate for proper numbering. + */ + _appendStepWithIndex(step, displayIndex) { + const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; + if (!list) return; + const item = document.createElement('li'); + const text = this._formatStepText(step, displayIndex); + item.setAttribute('data-step-id', step.id || ''); + item.style.display = 'flex'; + item.style.alignItems = 'flex-start'; + item.style.gap = '6px'; + item.innerHTML = ` + ${displayIndex}. + ${text} + `; + list.appendChild(item); + const container = list.parentElement; + if (container) container.scrollTop = container.scrollHeight; + } + + // Create a short, human-readable text for a recorded step + _formatStepText(step, _idx) { + try { + if (!step || typeof step !== 'object') return '未知步骤'; + const t = step.type; + const sel = step.target && step.target.selector ? step.target.selector : ''; + if (t === 'click' || t === 'dblclick') { + return `${t === 'dblclick' ? '双击' : '点击'}: ${sel || '(document)'}`; + } + if (t === 'fill') { + const val = step.value; + const shown = typeof val === 'string' && val.length > 0 ? val : String(val); + return `输入: ${sel} = ${shown}`; + } + if (t === 'scroll') { + const mode = step.mode === 'container' ? '容器' : '页面'; + const off = step.offset || {}; + return `滚动(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`; + } + if (t === 'openTab') return `打开标签页: ${step.url || ''}`; + if (t === 'switchTab') return `切换标签页: 包含 ${step.urlContains || ''}`; + if (t === 'switchFrame') + return `切换Frame: 包含 ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`; + if (t === 'waitFor') return `等待: ${sel || step.until || ''}`; + return `${t}`; + } catch (_) { + return '步骤'; + } + } + } + + // ================================================================ + // 3) MAIN CLASS: ContentRecorder (stateful) + // ================================================================ + class ContentRecorder { + constructor() { + // State + this.isRecording = false; + this.isPaused = false; + this.hideInputValues = false; + this.highlightEnabled = true; + this.hoverRAF = 0; + this.frameSwitchPushed = false; + this.batch = []; + this.batchTimer = null; + this.scrollTimer = null; + + // Local, content-side buffer for batching/merging steps during recording. + // Not the authoritative Flow (background holds the real one). + this.sessionBuffer = this._createSessionBuffer(); + // lastFill tracks the most recent fill step for debounce/merge + // el: DOM element reference for reading final value on finalize + this.lastFill = { step: null, ts: 0, el: null }; + // Input activity tracking for flush gate (separate from merge state) + // Updated by both local input and iframe upsert messages + this._lastInputActivityTs = 0; + // Flush gate: tracks when a typing burst started to enforce MAX_TYPING_HOLD_MS + this._typingBurstStartTs = 0; + // Force flush timer: ensures MAX_TYPING_HOLD_MS is a hard upper bound + // This timer is NOT reset on each input, only cleared on actual flush + this._forceFlushTimer = null; + // Recording-time element identity map (not persisted) + this.el2ref = new WeakMap(); + this.refCounter = 0; + + // Bind handlers + this._onClick = this._onClick.bind(this); + this._onInput = this._onInput.bind(this); + this._onDocInput = this._onDocInput.bind(this); + this._onChange = this._onChange.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + this._onScroll = this._onScroll.bind(this); + this._onFocusIn = this._onFocusIn.bind(this); + this._onFocusOut = this._onFocusOut.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); + this._onWindowMessage = this._onWindowMessage.bind(this); + // Page lifecycle handlers for best-effort flush on navigation/close + this._onPageHide = this._onPageHide.bind(this); + this._onVisibilityChange = this._onVisibilityChange.bind(this); + this.ui = new UI(this); + this._scrollPending = null; + + // Focus tracking for per-element input listening + this._focusedEl = null; + // Keyboard state for combo recording + this._pressed = new Set(); + this._lastKeyTs = 0; + // Map to avoid duplicate switchFrame per iframe source (keyed by frame selector) + this._frameSwitchMap = new Set(); + } + + // Lifecycle + start(flowMeta) { + // Idempotent start: if already recording (and not paused), just ensure UI and listeners + if (this.isRecording && !this.isPaused) { + this.ui.ensure(); + this._updateHoverListener(); + return; + } + // If paused, treat start as resume to avoid resetting local buffer/UI timeline + if (this.isPaused) { + this.resume(); + return; + } + this._reset(flowMeta || {}); + this.isRecording = true; + this.isPaused = false; + this._attach(); + this.ui.ensure(); + this.ui.resetTimeline(); + } + + /** + * Stop recording and flush all pending data. + * This is the reliable stop that ensures no data is lost. + * Waits for background to acknowledge receipt of all data before returning. + * @returns {Promise<{ack: boolean, steps: number, variables: number}>} + */ + async stop() { + if (!this.isRecording) { + return { ack: true, steps: 0, variables: 0 }; + } + + this.isRecording = false; + // Stop should clear paused state so detach fully cleans up (and barrier works consistently) + this.isPaused = false; + + // Step 1: Finalize pending click (dblclick detector) + this._finalizePendingClick(); + + // Step 2: Finalize any pending input (draft mode) + this._finalizePendingInput(); + + // Step 3: Finalize any pending scroll + this._finalizePendingScroll(); + + // Step 4: In iframes, ensure the top-frame aggregator has processed our final postMessages + // before we ACK the background stop (prevents missing iframe steps) + let topSyncOk = true; + if (window !== window.top) { + topSyncOk = await this._syncStopBarrierToTop(); + } + + // Step 5: Clear timers BEFORE flush (prevent race conditions) + if (this.batchTimer) clearTimeout(this.batchTimer); + this.batchTimer = null; + if (this.scrollTimer) clearTimeout(this.scrollTimer); + this.scrollTimer = null; + if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF); + this.hoverRAF = 0; + + // Step 6: Flush any remaining batched steps and WAIT for ack + const stepsCount = this.batch.length; + let stepsAck = true; + if (stepsCount > 0) { + stepsAck = await this._flush(); + } + + // Step 7: Send all collected variables and WAIT for ack + const variablesCount = this.sessionBuffer.variables?.length || 0; + let variablesAck = true; + if (variablesCount > 0) { + variablesAck = await this._sendVariables(); + } + + // Step 8: Detach listeners and clean up UI + this._detach(); + this.ui.remove(); + + // Step 9: Reset state + this.lastFill = { step: null, ts: 0, el: null }; + this._lastInputActivityTs = 0; + this._typingBurstStartTs = 0; + if (this._forceFlushTimer) { + clearTimeout(this._forceFlushTimer); + this._forceFlushTimer = null; + } + this.sessionBuffer.steps = []; + + // Return acknowledgment with stats + // ack is true only if all sends were acknowledged + return { + ack: stepsAck && variablesAck && topSyncOk, + steps: stepsCount, + variables: variablesCount, + }; + } + + /** + * Finalize a pending click that hasn't been emitted yet. + * The dblclick detector holds single clicks temporarily to detect double-clicks. + * This ensures stop/pause flush includes the last single click. + */ + _finalizePendingClick() { + try { + if (this._pendingClickTimer) clearTimeout(this._pendingClickTimer); + } catch {} + this._pendingClickTimer = null; + + try { + if (this._pendingClick) this._pushStep(this._pendingClick); + } catch {} + this._pendingClick = null; + } + + /** + * Finalize any pending input that hasn't been flushed yet. + * This ensures the last input value is captured before stop/pause/navigation. + * Uses lastFill.el (DOM reference) to read the current value. + */ + _finalizePendingInput() { + const last = this.lastFill; + if (!last || !last.step) return; + + // Commit the latest value from the DOM element + try { + const el = last.el; + if (el) { + const freshValue = this._getElementValue(el, last.step.value); + if (freshValue !== last.step.value) { + last.step.value = freshValue; + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + } + } + } catch { + // Element may no longer exist, that's OK - we keep the last known value + } + + // Enqueue for upsert to ensure background gets the final value + try { + this._enqueueForUpsert(last.step); + } catch {} + + // Reset state + this.lastFill = { step: null, ts: 0, el: null }; + this._typingBurstStartTs = 0; + } + + /** + * Get the current value from an element, handling sensitive fields and contenteditable. + * @param {Element} el - The element to read from + * @param {string} existingValue - The existing recorded value (may be a variable placeholder) + * @returns {string} The value to record + */ + _getElementValue(el, existingValue) { + if (!el) return existingValue || ''; + + const isContentEditable = + el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true; + + // If existing value is already a variable placeholder, preserve it + // Use strict pattern to avoid false positives for user input like "{abc}" + const existing = typeof existingValue === 'string' ? existingValue : ''; + const varPlaceholderPattern = + /^\{(?:var_[a-z0-9]{4}|file_[a-z0-9]{4}|[a-zA-Z_][a-zA-Z0-9_]*)\}$/; + if (varPlaceholderPattern.test(existing)) { + return existing; + } + + // Check if this is a sensitive field + const isSensitive = + this.hideInputValues || + (!isContentEditable && + CONFIG.SENSITIVE_INPUT_TYPES.has( + ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), + )); + + if (isSensitive) { + // Return existing variable or create new one (should already exist from initial capture) + return existing; + } + + // Read fresh value from DOM + try { + if (isContentEditable) { + return /** @type {HTMLElement} */ (el).innerText || ''; + } + if ( + el instanceof HTMLInputElement || + el instanceof HTMLTextAreaElement || + el instanceof HTMLSelectElement + ) { + return el.value || ''; + } + } catch {} + + return existing || ''; + } + + /** + * Finalize any pending scroll that hasn't been committed yet. + * Converts the pending scroll data into a proper scroll step. + */ + _finalizePendingScroll() { + if (!this._scrollPending) return; + + const pending = this._scrollPending; + this._scrollPending = null; + + const { isDoc, target, top, left } = pending; + + // Try merge with last step (same logic as _onScroll timer callback) + const steps = this.sessionBuffer.steps; + const last = steps.length ? steps[steps.length - 1] : null; + if (last && last.type === 'scroll') { + const sameDoc = isDoc && !last.target && last.mode === 'offset'; + const sameEl = + !isDoc && + last.target && + last.target.selector && + target && + last.target.selector === target.selector && + last.mode === 'container'; + if (sameDoc || sameEl) { + last.offset = { y: top, x: left }; + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + return; + } + } + + // Create new scroll step + if (isDoc) { + this._pushStep({ + type: 'scroll', + mode: 'offset', + offset: { y: top, x: left }, + screenshotOnFail: false, + }); + } else { + this._pushStep({ + type: 'scroll', + mode: 'container', + target: target, + offset: { y: top, x: left }, + screenshotOnFail: false, + }); + } + } + + /** + * Send all collected variables to background. + * @returns {Promise} - Resolves when background acknowledges receipt + */ + async _sendVariables() { + if (!this.sessionBuffer.variables || this.sessionBuffer.variables.length === 0) { + return true; + } + return this._send({ kind: 'variables', variables: this.sessionBuffer.variables }); + } + + /** + * Pause recording. Flushes pending data before pausing. + */ + pause() { + if (!this.isRecording || this.isPaused) return; + + // Finalize pending data before pausing + this._finalizePendingClick(); + this._finalizePendingInput(); + this._finalizePendingScroll(); + + // Flush batched steps + if (this.batch.length > 0) { + this._flush(); + } + + // Clear timers + if (this.batchTimer) clearTimeout(this.batchTimer); + this.batchTimer = null; + if (this.scrollTimer) clearTimeout(this.scrollTimer); + this.scrollTimer = null; + + this.isPaused = true; + this._detach(); + this.ui.updateStatus(); + } + + /** + * Resume recording after pause. + */ + resume() { + if (!this.isPaused) return; + + this.isRecording = true; + this.isPaused = false; + this._attach(); + this.ui.ensure(); + this.ui.updateStatus(); + } + + // DOM listeners + _attach() { + document.addEventListener('click', this._onClick, true); + // Use focusin/out to attach input listener only to focused element + document.addEventListener('focusin', this._onFocusIn, true); + document.addEventListener('focusout', this._onFocusOut, true); + // Document-level input capture to support Shadow DOM (custom elements) + // Use capture phase + composedPath to find inner editable control + document.addEventListener('input', this._onDocInput, true); + document.addEventListener('change', this._onChange, true); + // capture-phase scroll to catch non-bubbling events on any container (passive to avoid jank) + document.addEventListener('scroll', this._onScroll, { capture: true, passive: true }); + // Keyboard: record Enter and modifier combos + document.addEventListener('keydown', this._onKeyDown, true); + document.addEventListener('keyup', this._onKeyUp, true); + // Page lifecycle: best-effort flush on navigation/close + window.addEventListener('pagehide', this._onPageHide, true); + document.addEventListener('visibilitychange', this._onVisibilityChange, true); + // Cross-frame: top window aggregates iframe-recorded steps + if (window === window.top) window.addEventListener('message', this._onWindowMessage, true); + this._updateHoverListener(); + } + + _detach() { + document.removeEventListener('click', this._onClick, true); + document.removeEventListener('focusin', this._onFocusIn, true); + document.removeEventListener('focusout', this._onFocusOut, true); + document.removeEventListener('input', this._onDocInput, true); + document.removeEventListener('change', this._onChange, true); + document.removeEventListener('scroll', this._onScroll, { capture: true }); + document.removeEventListener('keydown', this._onKeyDown, true); + document.removeEventListener('keyup', this._onKeyUp, true); + window.removeEventListener('pagehide', this._onPageHide, true); + document.removeEventListener('visibilitychange', this._onVisibilityChange, true); + document.removeEventListener('mousemove', this._onMouseMove, { capture: true }); + // Keep top-frame aggregator alive during pause; stop() clears isPaused and will remove it + if (window === window.top && !this.isPaused) + window.removeEventListener('message', this._onWindowMessage, true); + // Detach per-element input listener if any + if (this._focusedEl) this._focusedEl.removeEventListener('input', this._onInput, true); + this._focusedEl = null; + // Best-effort cleanup for timers/raf when detaching + if (this.batchTimer) clearTimeout(this.batchTimer); + this.batchTimer = null; + if (this.scrollTimer) clearTimeout(this.scrollTimer); + this.scrollTimer = null; + if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF); + this.hoverRAF = 0; + // Clear pending click state (stop/pause flush it before detach) + if (this._pendingClickTimer) { + clearTimeout(this._pendingClickTimer); + } + this._pendingClickTimer = null; + this._pendingClick = null; + } + + _updateHoverListener() { + if (window !== window.top) return; + document.removeEventListener('mousemove', this._onMouseMove, { capture: true }); + if (this.isRecording && !this.isPaused && this.highlightEnabled) { + document.addEventListener('mousemove', this._onMouseMove, { capture: true, passive: true }); + } + } + + // Flow helpers (content-side buffer only) + _createSessionBuffer() { + const nowIso = new Date().toISOString(); + return { + id: `flow_${Date.now()}`, + name: '未命名录制', + version: 1, + steps: [], + variables: [], + meta: { createdAt: nowIso, updatedAt: nowIso }, + }; + } + + _reset(meta) { + this.sessionBuffer = this._createSessionBuffer(); + try { + if (meta && typeof meta === 'object') { + if (meta.id) this.sessionBuffer.id = String(meta.id); + if (meta.name) this.sessionBuffer.name = String(meta.name); + if (meta.description) this.sessionBuffer.description = String(meta.description); + } + } catch {} + this.lastFill = { step: null, ts: 0, el: null }; + this._lastInputActivityTs = 0; + this._typingBurstStartTs = 0; + if (this._forceFlushTimer) { + clearTimeout(this._forceFlushTimer); + this._forceFlushTimer = null; + } + this.frameSwitchPushed = false; + } + + /** + * Update input activity timestamp (used for flush gate). + * Called on local input and iframe upsert messages. + */ + _updateInputActivity() { + const now = Date.now(); + const prevActivityTs = this._lastInputActivityTs || 0; + this._lastInputActivityTs = now; + // Start a new burst if previous one expired (or this is first input) + if (!this._typingBurstStartTs || now - prevActivityTs > CONFIG.INPUT_DEBOUNCE_MS) { + this._typingBurstStartTs = now; + // Start force flush timer (hard upper bound for MAX_TYPING_HOLD_MS) + this._startForceFlushTimer(); + } + } + + /** + * Start the force flush timer. + * This timer ensures MAX_TYPING_HOLD_MS is a hard upper bound. + * Unlike batchTimer, this timer is NOT reset on each input. + */ + _startForceFlushTimer() { + // Don't restart if already running + if (this._forceFlushTimer) return; + this._forceFlushTimer = setTimeout(() => { + this._forceFlushTimer = null; + // Force flush regardless of current input state + if (this.batch.length > 0) { + this._flush(); + } + }, CONFIG.MAX_TYPING_HOLD_MS); + } + + /** + * Clear the force flush timer (called on actual flush). + */ + _clearForceFlushTimer() { + if (this._forceFlushTimer) { + clearTimeout(this._forceFlushTimer); + this._forceFlushTimer = null; + } + this._typingBurstStartTs = 0; + } + + /** + * Unified commit and flush logic. + * Called at commit points: focusout, Enter key, pagehide, visibilitychange. + * @param {Object} options + * @param {boolean} [options.bestEffort=false] - If true, don't await (for unload events) + */ + _commitAndFlush(options = {}) { + if (!this.isRecording || this.isPaused) return; + + try { + this._finalizePendingInput(); + this._finalizePendingScroll(); + } catch {} + + // Reset flush gate to allow immediate flush + this._lastInputActivityTs = 0; + this._typingBurstStartTs = 0; + this._clearForceFlushTimer(); + + // Flush (best-effort for unload events) + try { + if (this.batch.length > 0) this._flush(); + } catch {} + try { + const variablesCount = this.sessionBuffer.variables?.length || 0; + if (variablesCount > 0) this._sendVariables(); + } catch {} + + // If in iframe, ask top to flush too + this._requestTopFlush(); + } + + _pushStep(step) { + step.id = step.id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + // In iframes, forward to top for aggregation (compute frame selector there) + if (window !== window.top) { + try { + const payload = { + kind: 'iframeStep', + href: String(location && location.href ? location.href : ''), + step, + }; + window.top.postMessage({ type: FRAME_EVENT, payload }, '*'); + return; // Do not push locally in subframe + } catch {} + } + // Top window: optionally insert a switchFrame if this step originated from an iframe message + this.sessionBuffer.steps.push(step); + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + this.batch.push(step); + + // Track input activity for fill steps (to enforce flush gate) + if (step && step.type === 'fill') { + this._updateInputActivity(); + } + + this._scheduleFlush(); + } + + /** + * Calculate the appropriate flush delay based on typing activity. + * During active typing, delay flush to avoid sending incomplete values. + * Note: MAX_TYPING_HOLD_MS is enforced by _forceFlushTimer, not here. + * @returns {number} Delay in milliseconds before next flush + */ + _getFlushDelayMs() { + const now = Date.now(); + const lastInputTs = this._lastInputActivityTs || 0; + + // If no recent input activity, use default batch delay + if (!lastInputTs || now - lastInputTs >= CONFIG.INPUT_DEBOUNCE_MS) { + return CONFIG.BATCH_SEND_MS; + } + + // Wait for input debounce to complete + const notBefore = lastInputTs + CONFIG.INPUT_DEBOUNCE_MS; + const delay = Math.max(CONFIG.BATCH_SEND_MS, notBefore - now); + + return delay; + } + + /** + * Schedule a batch flush with appropriate delay. + * Respects typing gate to avoid flushing incomplete fill values. + */ + _scheduleFlush() { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + const delay = this._getFlushDelayMs(); + this.batchTimer = setTimeout(() => { + this.batchTimer = null; + this._flush(); + }, delay); + } + + /** + * Request top frame to immediately flush its aggregated buffer. + * Used by iframes on commit points (focusout, navigation) to ensure + * their updates are sent to background promptly. + */ + _requestTopFlush() { + if (window === window.top) return; + try { + const payload = { + kind: 'iframeFlush', + href: String(location && location.href ? location.href : ''), + }; + window.top.postMessage({ type: FRAME_EVENT, payload }, '*'); + } catch {} + } + + /** + * Iframe -> top stop barrier sync. + * Ensures the top frame has processed all prior iframe postMessages (steps/upserts) + * before this iframe responds to background STOP. + * @returns {Promise} + */ + _syncStopBarrierToTop() { + if (window === window.top) return Promise.resolve(true); + const id = `sb_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const href = String(location && location.href ? location.href : ''); + const timeoutMs = 400; + + return new Promise((resolve) => { + let done = false; + const cleanup = (ok) => { + if (done) return; + done = true; + try { + window.removeEventListener('message', onMessage, true); + } catch {} + try { + clearTimeout(t); + } catch {} + resolve(!!ok); + }; + + const onMessage = (ev) => { + try { + if (ev.source !== window.top) return; + const d = ev && ev.data; + if (!d || d.type !== FRAME_EVENT || !d.payload) return; + const p = d.payload || {}; + if (p.kind !== 'iframeStopBarrierAck' || p.id !== id) return; + cleanup(true); + } catch {} + }; + + const t = setTimeout(() => cleanup(false), timeoutMs); + try { + window.addEventListener('message', onMessage, true); + window.top.postMessage( + { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrier', id, href } }, + '*', + ); + } catch { + cleanup(false); + } + }); + } + + /** + * Best-effort drain and flush on page navigation/close. + * Called by pagehide/visibilitychange handlers. + * Does not await - unload events are time-constrained. + */ + _bestEffortDrainAndFlush() { + if (!this.isRecording || this.isPaused) return; + + // Flush pending single click (dblclick detector) before we may be unloaded + this._finalizePendingClick(); + + // Cancel timers (unload may not wait for them) + try { + if (this.batchTimer) clearTimeout(this.batchTimer); + this.batchTimer = null; + if (this.scrollTimer) clearTimeout(this.scrollTimer); + this.scrollTimer = null; + } catch {} + + // Use unified commit and flush + this._commitAndFlush({ bestEffort: true }); + } + + /** + * Handle pagehide event - best-effort flush before navigation/close. + */ + _onPageHide() { + this._bestEffortDrainAndFlush(); + } + + /** + * Handle visibilitychange event - flush when page becomes hidden. + * This catches some cases that pagehide misses (e.g., tab switch before navigation). + */ + _onVisibilityChange() { + try { + if (document.visibilityState === 'hidden') { + this._bestEffortDrainAndFlush(); + } + } catch {} + } + + /** + * Flush batched steps to background. + * @returns {Promise} - Resolves when background acknowledges receipt + */ + async _flush() { + if (!this.batch.length) return true; + + // Clear force flush timer since we're flushing now + this._clearForceFlushTimer(); + + const steps = this.batch.map((s) => { + // sanitize internal fields before sending to background + const { _recordingRef, ...rest } = s || {}; + return rest; + }); + this.batch.length = 0; + return this._send({ kind: 'steps', steps }); + } + + /** + * Send payload to background and wait for acknowledgment. + * @param {Object} payload - The payload to send + * @returns {Promise} - Resolves true if background acknowledged, false otherwise + */ + _send(payload) { + return new Promise((resolve) => { + try { + chrome.runtime.sendMessage({ type: 'rr_recorder_event', payload }, (response) => { + // Check for runtime error (e.g., no receiver) + if (chrome.runtime.lastError) { + console.warn('Recorder: send failed', chrome.runtime.lastError.message); + resolve(false); + return; + } + resolve(response && response.ok); + }); + } catch (e) { + console.warn('Recorder: send exception', e); + resolve(false); + } + }); + } + + _addVariable(key, sensitive, defVal) { + if (!this.sessionBuffer.variables) this.sessionBuffer.variables = []; + if (this.sessionBuffer.variables.find((v) => v.key === key)) return; + this.sessionBuffer.variables.push({ key, sensitive: !!sensitive, default: defVal || '' }); + } + + // Handlers + // Pending click state for dblclick detection + _pendingClick = null; + _pendingClickTimer = null; + _DBLCLICK_THRESHOLD_MS = 300; + + _onClick(e) { + if (!this.isRecording || this.isPaused) return; + const el = e.target instanceof Element ? e.target : null; + if (!el) return; + try { + if (el instanceof HTMLInputElement) { + const t = (el.getAttribute && el.getAttribute('type')) || ''; + const tt = String(t).toLowerCase(); + if (tt === 'checkbox' || tt === 'radio') return; // avoid duplicate with change + } + const overlay = document.getElementById('__rr_rec_overlay'); + if (overlay && (el === overlay || (el.closest && el.closest('#__rr_rec_overlay')))) return; + const a = el.closest && el.closest('a[href]'); + const href = a && a.getAttribute && a.getAttribute('href'); + const tgt = a && a.getAttribute && a.getAttribute('target'); + if (a && href && tgt && tgt.toLowerCase() === '_blank') { + try { + const abs = new URL(href, location.href).href; + this._pushStep({ type: 'openTab', url: abs }); + this._pushStep({ type: 'switchTab', urlContains: abs }); + return; + } catch (_) { + this._pushStep({ type: 'openTab', url: href }); + this._pushStep({ type: 'switchTab', urlContains: href }); + return; + } + } + } catch {} + + const target = SelectorEngine.buildTarget(el); + try { + const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el); + if (gref) target.ref = gref; + } catch {} + + // Double-click detection: if e.detail >= 2 means this is the second click of a dblclick + if (e.detail >= 2) { + // Cancel pending single click and record dblclick instead + if (this._pendingClickTimer) { + clearTimeout(this._pendingClickTimer); + this._pendingClickTimer = null; + } + this._pendingClick = null; + this._pushStep({ + type: 'dblclick', + target, + screenshotOnFail: true, + }); + return; + } + + // Single click: wait briefly to see if it becomes a dblclick + // Cancel any previous pending click first + if (this._pendingClickTimer) { + clearTimeout(this._pendingClickTimer); + // Flush previous pending click before starting new one + if (this._pendingClick) { + this._pushStep(this._pendingClick); + } + } + + this._pendingClick = { + type: 'click', + target, + screenshotOnFail: true, + }; + + this._pendingClickTimer = setTimeout(() => { + if (this._pendingClick) { + this._pushStep(this._pendingClick); + this._pendingClick = null; + } + this._pendingClickTimer = null; + }, this._DBLCLICK_THRESHOLD_MS); + } + + // Per-element input handler (attached on focusin for native inputs/textarea/contenteditable) + _onInput(e) { + if (!this.isRecording || this.isPaused) return; + // Avoid mid-composition spam (IME): handle final committed value + try { + if (e && typeof e.isComposing === 'boolean' && e.isComposing) return; + } catch {} + const target = e.target; + // Support input/textarea and contenteditable elements + const el = + target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement + ? target + : target && + target.nodeType === 1 && + /** @type {HTMLElement} */ (target).isContentEditable === true + ? /** @type {HTMLElement} */ (target) + : null; + if (!el) return; + this._handleInputForElement(el); + } + + // Document-level input handler: supports composed events from Shadow DOM (custom elements) + _onDocInput(e) { + if (!this.isRecording || this.isPaused) return; + try { + if (e && typeof e.isComposing === 'boolean' && e.isComposing) return; + } catch {} + // Avoid double handling when per-element listener already attached to same element + if (this._focusedEl && e.target === this._focusedEl) return; + // Find the innermost editable element from composedPath + const path = typeof e.composedPath === 'function' ? e.composedPath() : []; + let el = null; + for (let i = 0; i < path.length; i++) { + const n = path[i]; + if (n instanceof HTMLInputElement || n instanceof HTMLTextAreaElement) { + el = n; + break; + } + // Also check for contenteditable + if (n && n.nodeType === 1 && /** @type {HTMLElement} */ (n).isContentEditable === true) { + el = /** @type {HTMLElement} */ (n); + break; + } + } + // As a fallback, walk down activeElement chain (deep active element via shadow roots) + if (!el) { + try { + let ae = document.activeElement; + let guard = 0; + while (ae && guard++ < 10) { + if (ae instanceof HTMLInputElement || ae instanceof HTMLTextAreaElement) { + el = ae; + break; + } + // Check contenteditable in shadow DOM traversal + if ( + ae && + ae.nodeType === 1 && + /** @type {HTMLElement} */ (ae).isContentEditable === true + ) { + el = /** @type {HTMLElement} */ (ae); + break; + } + const anyAe = ae; + if (anyAe && anyAe.shadowRoot && anyAe.shadowRoot.activeElement) { + ae = anyAe.shadowRoot.activeElement; + continue; + } + break; + } + } catch {} + } + if (!el) return; + this._handleInputForElement(el); + } + + // Shared input processing logic (debounce/merge/sensitivity) + // Uses Draft/Upsert model: updates are re-enqueued to ensure background gets final value + _handleInputForElement(el) { + try { + const t = (el.getAttribute && el.getAttribute('type')) || ''; + const tt = String(t).toLowerCase(); + if (tt === 'checkbox' || tt === 'radio' || tt === 'file') return; + } catch {} + const elRef = this._getElRef(el); + const target = SelectorEngine.buildTarget(el); + + // Check if element is contenteditable + const isContentEditable = + el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true; + + const isSensitive = + this.hideInputValues || + (!isContentEditable && + CONFIG.SENSITIVE_INPUT_TYPES.has( + ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), + )); + + // Get value: use .value for input/textarea, .innerText for contenteditable + let value = isContentEditable + ? /** @type {HTMLElement} */ (el).innerText || '' + : el.value || ''; + if (isSensitive) { + const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`; + this._addVariable(varKey, true, ''); + value = `{${varKey}}`; + } + const nowTs = Date.now(); + const last = this.lastFill.step; + const sameRef = !!(last && last._recordingRef === elRef); + const sameSelector = !!( + last && + last.target && + last.target.selector && + target && + target.selector && + last.target.selector === target.selector + ); + const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS; + if ((sameRef || sameSelector) && within) { + // Update existing step's value + this.lastFill.step.value = value; + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + this.lastFill.ts = nowTs; + this.lastFill.el = el; // Keep DOM reference updated for finalize + // Keep flush gate aligned to the latest keystroke + this._updateInputActivity(); + // Re-enqueue the updated step for upsert (ensures background gets final value) + this._enqueueForUpsert(this.lastFill.step); + return; + } + const newStep = { type: 'fill', target, value, screenshotOnFail: true }; + newStep._recordingRef = elRef; + this._pushStep(newStep); + this.lastFill = { step: newStep, ts: nowTs, el: el }; + } + + /** + * Enqueue a step for upsert - if step with same id exists in batch, update it. + * This ensures the background receives the final value for fill steps. + * In iframes, forwards to top window to maintain selector composition consistency. + */ + _enqueueForUpsert(step) { + if (!step || !step.id) return; + + // In iframes, forward upsert updates to top so we don't lose composed selectors. + // The top window aggregates iframe steps and computes "frame |> inner" selectors. + // If iframe sends directly to background, it would overwrite the composed selector. + if (window !== window.top) { + try { + const payload = { + kind: 'iframeStepUpsert', + href: String(location && location.href ? location.href : ''), + step, + }; + window.top.postMessage({ type: FRAME_EVENT, payload }, '*'); + } catch {} + return; + } + + // Check if step already in batch + const existingIdx = this.batch.findIndex((s) => s.id === step.id); + if (existingIdx >= 0) { + // Update existing entry in batch + this.batch[existingIdx] = step; + } else { + // Add to batch (step was already flushed, so we need to send update) + this.batch.push(step); + } + + // Schedule flush with appropriate delay (respects typing gate) + this._scheduleFlush(); + } + + _onChange(e) { + if (!this.isRecording || this.isPaused) return; + const el = e.target; + if (el instanceof HTMLSelectElement) { + const val = el.value; + const nowTs = Date.now(); + const elRef = this._getElRef(el); + const sameRef = !!(this.lastFill.step && this.lastFill.step._recordingRef === elRef); + const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS; + if (sameRef && within) { + this.lastFill.step.value = val; + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + this.lastFill.ts = nowTs; + this.lastFill.el = el; // Keep DOM reference updated + // Re-enqueue for upsert + this._enqueueForUpsert(this.lastFill.step); + return; + } + const target = SelectorEngine.buildTarget(el); + try { + const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el); + if (gref) target.ref = gref; + } catch {} + const st = { type: 'fill', target, value: val, screenshotOnFail: true }; + st._recordingRef = elRef; + this._pushStep(st); + this.lastFill = { step: st, ts: nowTs, el: el }; + return; + } + if (el instanceof HTMLInputElement) { + const t = (el.getAttribute && el.getAttribute('type')) || ''; + const tt = String(t).toLowerCase(); + const target = SelectorEngine.buildTarget(el); + try { + const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el); + if (gref) target.ref = gref; + } catch {} + const elRef = this._getElRef(el); + if (tt === 'checkbox') { + const st = { type: 'fill', target, value: !!el.checked, screenshotOnFail: true }; + st._recordingRef = elRef; + this._pushStep(st); + return; + } + if (tt === 'radio') { + const st = { type: 'fill', target, value: true, screenshotOnFail: true }; + st._recordingRef = elRef; + this._pushStep(st); + return; + } + if (tt === 'file') { + const varKey = el.name ? el.name : `file_${Math.random().toString(36).slice(2, 6)}`; + this._addVariable(varKey, false, ''); + this._pushStep({ type: 'fill', target, value: `{${varKey}}`, screenshotOnFail: true }); + return; + } + } + } + + _getElRef(el) { + try { + let ref = this.el2ref.get(el); + if (ref) return ref; + ref = `ref_${++this.refCounter}`; + this.el2ref.set(el, ref); + return ref; + } catch { + // Fallback to timestamp-based ref if WeakMap fails (should not happen) + return `ref_${Date.now()}`; + } + } + + // UI handled by injected UI class + + _onFocusIn(e) { + if (!this.isRecording || this.isPaused) return; + const el = e.target; + const isEditable = + el instanceof HTMLInputElement || + el instanceof HTMLTextAreaElement || + (el && el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true); + if (!isEditable) return; + if (this._focusedEl && this._focusedEl !== el) + this._focusedEl.removeEventListener('input', this._onInput, true); + el.addEventListener('input', this._onInput, true); + this._focusedEl = el; + } + + _onFocusOut(e) { + const el = e.target; + if (!el) return; + if (this._focusedEl === el) { + // Commit point: leaving an input field - finalize and flush pending input + // This ensures we don't lose values when user tabs away or clicks elsewhere + this._commitAndFlush(); + el.removeEventListener('input', this._onInput, true); + this._focusedEl = null; + } + } + + _onMouseMove(e) { + if (!this.highlightEnabled || !this.ui._box || !this.isRecording || this.isPaused) return; + if (this.hoverRAF) return; + const el = e.target instanceof Element ? e.target : null; + if (!el) return; + this.hoverRAF = requestAnimationFrame(() => { + try { + const r = el.getBoundingClientRect(); + Object.assign(this.ui._box.style, { + left: `${Math.round(r.left)}px`, + top: `${Math.round(r.top)}px`, + width: `${Math.round(Math.max(0, r.width))}px`, + height: `${Math.round(Math.max(0, r.height))}px`, + display: r.width > 0 && r.height > 0 ? 'block' : 'none', + }); + } catch {} + this.hoverRAF = 0; + }); + } + + _onScroll(e) { + if (!this.isRecording || this.isPaused) return; + try { + const overlay = document.getElementById('__rr_rec_overlay'); + if (overlay) { + // Use composedPath for shadow DOM compatibility, fallback to target + const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target]; + for (const element of path) { + // If the event path contains our overlay, ignore this scroll event + if (element === overlay) { + return; + } + } + } + } catch { + // ignore + } + // Determine scroll source and positions + const isDoc = e.target === document; + const el = isDoc ? document.documentElement : e.target instanceof Element ? e.target : null; + if (!el) return; + let top = 0, + left = 0; + try { + if (isDoc) { + top = + typeof window.scrollY === 'number' + ? window.scrollY + : document.documentElement.scrollTop || 0; + left = + typeof window.scrollX === 'number' + ? window.scrollX + : document.documentElement.scrollLeft || 0; + } else { + top = el.scrollTop || 0; + left = el.scrollLeft || 0; + } + } catch {} + const target = isDoc ? null : SelectorEngine.buildTarget(el); + // Debounce/coalesce + this._scrollPending = { isDoc, target, top, left }; + if (this.scrollTimer) { + clearTimeout(this.scrollTimer); + } + this.scrollTimer = setTimeout(() => { + this.scrollTimer = null; + const pending = this._scrollPending; + this._scrollPending = null; + if (!pending) return; + const { isDoc: pDoc, target: pTarget, top: pTop, left: pLeft } = pending; + // Try merge with last step + const steps = this.sessionBuffer.steps; + const last = steps.length ? steps[steps.length - 1] : null; + if (last && last.type === 'scroll') { + const sameDoc = pDoc && !last.target && last.mode === 'offset'; + const sameEl = + !pDoc && + last.target && + last.target.selector && + pTarget && + last.target.selector === pTarget.selector && + last.mode === 'container'; + if (sameDoc || sameEl) { + last.offset = { y: pTop, x: pLeft }; + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + return; + } + } + // New scroll step + if (pDoc) { + this._pushStep({ + type: 'scroll', + mode: 'offset', + offset: { y: pTop, x: pLeft }, + screenshotOnFail: false, + }); + } else { + this._pushStep({ + type: 'scroll', + mode: 'container', + target: pTarget, + offset: { y: pTop, x: pLeft }, + screenshotOnFail: false, + }); + } + }, CONFIG.SCROLL_DEBOUNCE_MS); + } + + // Minimal key recorder: record Enter and modifier combos; avoid plain typing + _onKeyDown(e) { + if (!this.isRecording || this.isPaused) return; + try { + // Ignore autorepeat to prevent spam + if (e.repeat) return; + const key = String(e.key || '').toLowerCase(); + const isModifier = key === 'shift' || key === 'control' || key === 'meta' || key === 'alt'; + const isEditable = + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + (e.target && + e.target.nodeType === 1 && + /** @type {HTMLElement} */ (e.target).isContentEditable === true); + const enterKey = key === 'enter'; + + // Track pressed modifiers + if (isModifier) this._pressed.add(key); + + // Handle Enter in editable contexts (including contenteditable) + if (isEditable && enterKey) { + // Commit point: Enter may trigger form submission/navigation + // Record explicit key action with target first + const target = SelectorEngine.buildTarget(/** @type {Element} */ (e.target)); + const combo = this._formatKeysCombo(e, 'Enter'); + this._pushStep({ type: 'key', keys: combo, target, screenshotOnFail: false }); + + // Then commit and flush (form submit may navigate away) + this._commitAndFlush(); + + this._lastKeyTs = Date.now(); + return; + } + + // For non-text fields: record modifier combos and special keys + const special = enterKey || key === 'escape' || key === 'tab'; + if (special || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { + const comboName = this._formatKeysCombo(e, e.key); + this._pushStep({ type: 'key', keys: comboName, screenshotOnFail: false }); + this._lastKeyTs = Date.now(); + } + } catch {} + } + + _onKeyUp(e) { + const key = String(e.key || '').toLowerCase(); + if (key === 'shift' || key === 'control' || key === 'meta' || key === 'alt') + this._pressed.delete(key); + } + + _formatKeysCombo(e, mainKey) { + const parts = []; + if (e.ctrlKey) parts.push('Ctrl'); + if (e.altKey) parts.push('Alt'); + if (e.shiftKey) parts.push('Shift'); + if (e.metaKey) parts.push('Meta'); + const mk = String(mainKey || '').trim(); + // Normalize common names to match keyboard-helper parsing + const norm = (s) => { + const k = s.toLowerCase(); + if (k === 'escape') return 'Esc'; + if (k === ' ') return 'Space'; + if (k.length === 1) return k.toUpperCase(); + return s; + }; + parts.push(norm(mk)); + return parts.join('+'); + } + + // Top-level aggregator: receives iframe events and merges into session + _onWindowMessage(ev) { + try { + const d = ev && ev.data; + if (!d || d.type !== FRAME_EVENT || !d.payload) return; + + // Security: validate message source is from a known iframe in our page + // ev.source must match contentWindow of an iframe element we control + let frameEl = null; + try { + const frames = document.querySelectorAll('iframe,frame'); + for (let i = 0; i < frames.length; i++) { + const f = frames[i]; + if (f && f.contentWindow === ev.source) { + frameEl = f; + break; + } + } + } catch {} + + // Reject messages not from a recognized iframe in our document + if (!frameEl) { + // Message source is not from a child iframe we control - ignore + return; + } + + // Additional origin check: only accept from same origin or about:blank iframes + // (cross-origin iframes legitimately send from their origin) + try { + const selfOrigin = window.location.origin; + const msgOrigin = ev.origin; + // Allow same-origin, null (for sandboxed iframes), or if iframe src is same-origin + const frameSrc = frameEl.getAttribute('src') || ''; + let iframeSameOrigin = false; + try { + if (!frameSrc || frameSrc === 'about:blank') { + iframeSameOrigin = true; + } else { + const frameUrl = new URL(frameSrc, selfOrigin); + iframeSameOrigin = frameUrl.origin === selfOrigin; + } + } catch { + // Invalid URL - assume cross-origin + } + // If iframe is same-origin, message origin should match + if (iframeSameOrigin && msgOrigin !== selfOrigin && msgOrigin !== 'null') { + return; // Origin mismatch for same-origin iframe - suspicious + } + } catch {} + + const payload = d.payload || {}; + const kind = payload.kind; + + // Stop barrier sync: ACK back to the iframe so it can finish stop only after + // its final postMessages have been processed by the top aggregator + if (kind === 'iframeStopBarrier') { + try { + const id = payload.id; + if (id && ev.source && typeof ev.source.postMessage === 'function') { + ev.source.postMessage( + { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrierAck', id } }, + '*', + ); + } + } catch {} + return; + } + + // Handle iframe flush request: immediately flush top's aggregated buffer + if (kind === 'iframeFlush') { + this._lastInputActivityTs = 0; + this._typingBurstStartTs = 0; + this._clearForceFlushTimer(); + if (this.batchTimer) clearTimeout(this.batchTimer); + this.batchTimer = null; + if (this.batch.length > 0) this._flush(); + return; + } + + const { step, href } = payload; + if (!step || typeof step !== 'object') return; + + // Compose frame selector for iframe steps + const frameTarget = SelectorEngine.buildTarget(frameEl); + const frameSel = frameTarget?.selector || ''; + + // For upsert: find existing step in session and update it + if (kind === 'iframeStepUpsert') { + // Update input activity for iframe fills (enables flush gate for iframe input) + if (step.type === 'fill') { + this._updateInputActivity(); + } + + // Find step by id in session buffer and update its value + const existingIdx = this.sessionBuffer.steps.findIndex((s) => s.id === step.id); + if (existingIdx >= 0) { + // Update value but preserve the composed selector + this.sessionBuffer.steps[existingIdx].value = step.value; + this.sessionBuffer.meta.updatedAt = new Date().toISOString(); + // Also update in batch if present + const batchIdx = this.batch.findIndex((s) => s.id === step.id); + if (batchIdx >= 0) { + this.batch[batchIdx].value = step.value; + } else { + // Step was already flushed, add updated version to batch + const updatedStep = { ...this.sessionBuffer.steps[existingIdx] }; + this.batch.push(updatedStep); + } + this._scheduleFlush(); + } + return; + } + + // Regular iframe step: compose composite selector and push + if (step.target) { + const inner = String(step.target.selector || '').trim(); + if (frameSel && inner) { + const composite = `${frameSel} |> ${inner}`; + step.target.selector = composite; + if (Array.isArray(step.target.candidates)) { + step.target.candidates.unshift({ type: 'css', value: composite }); + } + } + } + this._pushStep(step); + } catch {} + } + } + + // ================================================================ + // 3) SINGLETON + MESSAGE HANDLERS + // ================================================================ + let recorderInstance = null; + function getRecorder() { + if (!recorderInstance) recorderInstance = new ContentRecorder(); + return recorderInstance; + } + + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + try { + if (!request || !request.action) return false; + if (request.action === 'rr_timeline_update') { + const rec = getRecorder(); + // Only respond to timeline updates when recording is active + if (!rec.isRecording) { + sendResponse({ ok: true, ignored: true }); + return true; + } + // Replace entire timeline to avoid divergence across tabs + const steps = Array.isArray(request.steps) ? request.steps : []; + rec.ui.applyTimelineUpdate(steps); + sendResponse({ ok: true }); + return true; + } + if (request.action === 'rr_recorder_control') { + const rec = getRecorder(); + const cmd = request.cmd; + if (cmd === 'start') { + rec.start(request.meta || {}); + sendResponse({ success: true }); + return true; + } + if (cmd === 'pause') { + rec.pause(); + sendResponse({ success: true }); + return true; + } + if (cmd === 'resume') { + rec.resume(); + sendResponse({ success: true }); + return true; + } + if (cmd === 'stop') { + // Stop is now async - flush all data and wait for ack before responding + rec + .stop() + .then((result) => { + sendResponse({ success: true, ack: result.ack, stats: result }); + }) + .catch((err) => { + sendResponse({ success: false, ack: false, error: String(err) }); + }); + return true; // Keep channel open for async response + } + sendResponse({ success: false, error: 'Unknown command' }); + return true; + } + // Handle direct stop message with ack (sent by recorder-manager) + if (request.action === 'stop' && request.requireAck) { + const rec = getRecorder(); + rec + .stop() + .then((result) => { + sendResponse({ ack: result.ack, stats: result }); + }) + .catch(() => { + sendResponse({ ack: false }); + }); + return true; + } + if (request.action === 'rr_recorder_ping') { + sendResponse({ status: 'pong' }); + return false; + } + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + return false; + }); + + console.log('Record & Replay recorder.js loaded'); +})(); diff --git a/app/chrome-extension/inject-scripts/screenshot-helper.js b/app/chrome-extension/inject-scripts/screenshot-helper.js new file mode 100644 index 0000000..04e6501 --- /dev/null +++ b/app/chrome-extension/inject-scripts/screenshot-helper.js @@ -0,0 +1,160 @@ +/* eslint-disable */ +/** + * Screenshot helper content script + * Handles page preparation, scrolling, element positioning, etc. + */ + +if (window.__SCREENSHOT_HELPER_INITIALIZED__) { + // Already initialized, skip +} else { + window.__SCREENSHOT_HELPER_INITIALIZED__ = true; + + // Save original styles + let originalOverflowStyle = ''; + let hiddenFixedElements = []; + + /** + * Get fixed/sticky positioned elements + * @returns Array of fixed/sticky elements + */ + function getFixedElements() { + const fixed = []; + + document.querySelectorAll('*').forEach((el) => { + const htmlEl = el; + const style = window.getComputedStyle(htmlEl); + if (style.position === 'fixed' || style.position === 'sticky') { + // Filter out tiny or invisible elements, and elements that are part of the extension UI + if ( + htmlEl.offsetWidth > 1 && + htmlEl.offsetHeight > 1 && + !htmlEl.id.startsWith('chrome-mcp-') + ) { + fixed.push({ + element: htmlEl, + originalDisplay: htmlEl.style.display, + originalVisibility: htmlEl.style.visibility, + }); + } + } + }); + return fixed; + } + + /** + * Hide fixed/sticky elements + */ + function hideFixedElements() { + hiddenFixedElements = getFixedElements(); + hiddenFixedElements.forEach((item) => { + item.element.style.display = 'none'; + }); + } + + /** + * Restore fixed/sticky elements + */ + function showFixedElements() { + hiddenFixedElements.forEach((item) => { + item.element.style.display = item.originalDisplay || ''; + }); + hiddenFixedElements = []; + } + + // Listen for messages from the extension + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + // Respond to ping message + if (request.action === 'chrome_screenshot_ping') { + sendResponse({ status: 'pong' }); + return false; // Synchronous response + } + + // Prepare page for capture + else if (request.action === 'preparePageForCapture') { + originalOverflowStyle = document.documentElement.style.overflow; + document.documentElement.style.overflow = 'hidden'; // Hide main scrollbar + if (request.options?.fullPage) { + // Only hide fixed elements for full page to avoid flicker + hideFixedElements(); + } + // Give styles a moment to apply + setTimeout(() => { + sendResponse({ success: true }); + }, 50); + return true; // Async response + } + + // Get page details + else if (request.action === 'getPageDetails') { + const body = document.body; + const html = document.documentElement; + sendResponse({ + totalWidth: Math.max( + body.scrollWidth, + body.offsetWidth, + html.clientWidth, + html.scrollWidth, + html.offsetWidth, + ), + totalHeight: Math.max( + body.scrollHeight, + body.offsetHeight, + html.clientHeight, + html.scrollHeight, + html.offsetHeight, + ), + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio || 1, + currentScrollX: window.scrollX, + currentScrollY: window.scrollY, + }); + } + + // Get element details + else if (request.action === 'getElementDetails') { + const element = document.querySelector(request.selector); + if (element) { + element.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' }); + setTimeout(() => { + // Wait for scroll + const rect = element.getBoundingClientRect(); + sendResponse({ + rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + devicePixelRatio: window.devicePixelRatio || 1, + }); + }, 200); // Increased delay for scrollIntoView + return true; // Async response + } else { + sendResponse({ error: `Element with selector "${request.selector}" not found.` }); + } + return true; // Async response + } + + // Scroll page + else if (request.action === 'scrollPage') { + window.scrollTo({ left: request.x, top: request.y, behavior: 'instant' }); + // Wait for scroll and potential reflows/lazy-loading + setTimeout(() => { + sendResponse({ + success: true, + newScrollX: window.scrollX, + newScrollY: window.scrollY, + }); + }, request.scrollDelay || 300); // Configurable delay + return true; // Async response + } + + // Reset page + else if (request.action === 'resetPageAfterCapture') { + document.documentElement.style.overflow = originalOverflowStyle; + showFixedElements(); + if (typeof request.scrollX !== 'undefined' && typeof request.scrollY !== 'undefined') { + window.scrollTo({ left: request.scrollX, top: request.scrollY, behavior: 'instant' }); + } + sendResponse({ success: true }); + } + + return false; // Synchronous response + }); +} diff --git a/app/chrome-extension/inject-scripts/wait-helper.js b/app/chrome-extension/inject-scripts/wait-helper.js new file mode 100644 index 0000000..77ffb11 --- /dev/null +++ b/app/chrome-extension/inject-scripts/wait-helper.js @@ -0,0 +1,234 @@ +/* eslint-disable */ +// wait-helper.js +// Listen for text appearance/disappearance in the current document using MutationObserver. +// Returns a stable ref (compatible with accessibility-tree-helper) for the first matching element. + +(function () { + if (window.__WAIT_HELPER_INITIALIZED__) return; + window.__WAIT_HELPER_INITIALIZED__ = true; + + // Ensure ref mapping infra exists (compatible with accessibility-tree-helper.js) + if (!window.__claudeElementMap) window.__claudeElementMap = {}; + if (!window.__claudeRefCounter) window.__claudeRefCounter = 0; + + function isVisible(el) { + try { + if (!(el instanceof Element)) return false; + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') + return false; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + return true; + } catch { + return false; + } + } + + function normalize(str) { + return String(str || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + function matchesText(el, needle) { + const t = normalize(needle); + if (!t) return false; + try { + if (!isVisible(el)) return false; + const aria = el.getAttribute('aria-label'); + if (aria && normalize(aria).includes(t)) return true; + const title = el.getAttribute('title'); + if (title && normalize(title).includes(t)) return true; + const alt = el.getAttribute('alt'); + if (alt && normalize(alt).includes(t)) return true; + const placeholder = el.getAttribute('placeholder'); + if (placeholder && normalize(placeholder).includes(t)) return true; + // input/textarea value + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + const value = el.value || el.getAttribute('value'); + if (value && normalize(value).includes(t)) return true; + } + const text = el.innerText || el.textContent || ''; + if (normalize(text).includes(t)) return true; + } catch {} + return false; + } + + function findElementByText(text) { + // Fast path: query common interactive elements first + const prioritized = Array.from( + document.querySelectorAll('a,button,input,textarea,select,label,summary,[role]'), + ); + for (const el of prioritized) if (matchesText(el, text)) return el; + + // Fallback: broader scan with cap to avoid blocking on huge pages + const walker = document.createTreeWalker( + document.body || document.documentElement, + NodeFilter.SHOW_ELEMENT, + ); + let count = 0; + while (walker.nextNode()) { + const el = /** @type {Element} */ (walker.currentNode); + if (matchesText(el, text)) return el; + if (++count > 5000) break; // Hard cap to avoid long scans + } + return null; + } + + function ensureRefForElement(el) { + // Try to reuse an existing ref + for (const k in window.__claudeElementMap) { + const weak = window.__claudeElementMap[k]; + if (weak && typeof weak.deref === 'function' && weak.deref() === el) return k; + } + const refId = `ref_${++window.__claudeRefCounter}`; + window.__claudeElementMap[refId] = new WeakRef(el); + return refId; + } + + function centerOf(el) { + const r = el.getBoundingClientRect(); + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; + } + + function waitFor({ text, appear = true, timeout = 5000 }) { + return new Promise((resolve) => { + const start = Date.now(); + let resolved = false; + + const check = () => { + try { + const match = findElementByText(text); + if (appear) { + if (match) { + const ref = ensureRefForElement(match); + const center = centerOf(match); + done({ success: true, matched: { ref, center }, tookMs: Date.now() - start }); + } + } else { + // wait for disappearance + if (!match) { + done({ success: true, matched: null, tookMs: Date.now() - start }); + } + } + } catch {} + }; + + const done = (result) => { + if (resolved) return; + resolved = true; + obs && obs.disconnect(); + clearTimeout(timer); + resolve(result); + }; + + const obs = new MutationObserver(() => check()); + try { + obs.observe(document.documentElement || document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + }); + } catch {} + + // Initial check + check(); + const timer = setTimeout( + () => { + done({ success: false, reason: 'timeout', tookMs: Date.now() - start }); + }, + Math.max(0, timeout), + ); + }); + } + + function waitForSelector({ selector, visible = true, timeout = 5000 }) { + return new Promise((resolve) => { + const start = Date.now(); + let resolved = false; + + const isMatch = () => { + try { + const el = document.querySelector(selector); + if (!el) return null; + if (!visible) return el; + return isVisible(el) ? el : null; + } catch { + return null; + } + }; + + const done = (result) => { + if (resolved) return; + resolved = true; + obs && obs.disconnect(); + clearTimeout(timer); + resolve(result); + }; + + const check = () => { + const el = isMatch(); + if (el) { + const ref = ensureRefForElement(el); + const center = centerOf(el); + done({ success: true, matched: { ref, center }, tookMs: Date.now() - start }); + } + }; + + const obs = new MutationObserver(check); + try { + obs.observe(document.documentElement || document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + }); + } catch {} + + // initial check + check(); + const timer = setTimeout( + () => done({ success: false, reason: 'timeout', tookMs: Date.now() - start }), + Math.max(0, timeout), + ); + }); + } + + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + try { + if (request && request.action === 'wait_helper_ping') { + sendResponse({ status: 'pong' }); + return false; + } + if (request && request.action === 'waitForText') { + const text = String(request.text || '').trim(); + const appear = request.appear !== false; // default true + const timeout = Number(request.timeout || 5000); + if (!text) { + sendResponse({ success: false, error: 'text is required' }); + return true; + } + waitFor({ text, appear, timeout }).then((res) => sendResponse(res)); + return true; // async + } + if (request && request.action === 'waitForSelector') { + const selector = String(request.selector || '').trim(); + const visible = request.visible !== false; // default true + const timeout = Number(request.timeout || 5000); + if (!selector) { + sendResponse({ success: false, error: 'selector is required' }); + return true; + } + waitForSelector({ selector, visible, timeout }).then((res) => sendResponse(res)); + return true; // async + } + } catch (e) { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + return true; + } + return false; + }); +})(); diff --git a/app/chrome-extension/inject-scripts/web-editor.js b/app/chrome-extension/inject-scripts/web-editor.js new file mode 100644 index 0000000..286c013 --- /dev/null +++ b/app/chrome-extension/inject-scripts/web-editor.js @@ -0,0 +1,848 @@ +/* eslint-disable */ + +(() => { + const GLOBAL_KEY = '__MCP_WEB_EDITOR__'; + if (window[GLOBAL_KEY]) return; + + const IS_MAIN = window === window.top; + const COLORS = { + hover: '#3b82f6', // blue-500 + selected: '#22c55e', // green-500 + backdrop: 'rgba(15, 23, 42, 0.15)', // slate-900 @ 15% + }; + + const clamp = (v, min, max) => Math.min(max, Math.max(min, v)); + + const normalizeTextSnippet = (value, maxLen) => { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLen || 80); + }; + + const containsPoint = (rect, x, y) => { + if (!rect) return false; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }; + + const getElementLabel = (el) => { + if (!(el instanceof Element)) return ''; + const tag = String(el.tagName || '').toLowerCase(); + const id = el.id ? `#${el.id}` : ''; + const classes = + el.classList && el.classList.length + ? `.${Array.from(el.classList).slice(0, 3).join('.')}` + : ''; + return `${tag}${id}${classes}`; + }; + + const detectTailwind = (classes) => { + try { + const patterns = [ + /^bg-/, + /^text-/, + /^p[trblxy]?-/, + /^m[trblxy]?-/, + /^flex$/, + /^grid$/, + /^items-/, + /^justify-/, + /^gap-/, + /^rounded/, + /^shadow/, + /^border/, + ]; + for (const cls of classes || []) { + if (patterns.some((p) => p.test(cls))) return true; + } + } catch {} + return false; + }; + + const findReactFileFromFiber = (fiber) => { + try { + let current = fiber; + for (let i = 0; i < 40 && current; i++) { + const src = current._debugSource; + if (src && src.fileName && typeof src.fileName === 'string') return src.fileName; + const owner = current._debugOwner; + const ownerSrc = owner && owner._debugSource; + if (ownerSrc && ownerSrc.fileName && typeof ownerSrc.fileName === 'string') + return ownerSrc.fileName; + current = current.return; + } + } catch {} + return ''; + }; + + const findReactSourceFile = (el) => { + try { + let node = el; + for (let depth = 0; depth < 15 && node; depth++) { + const keys = Object.keys(node); + for (const k of keys) { + if (k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) { + const fiber = node[k]; + const found = findReactFileFromFiber(fiber); + if (found) return found; + } + } + node = node.parentElement; + } + } catch {} + return ''; + }; + + const findVueSourceFile = (el) => { + try { + let node = el; + for (let depth = 0; depth < 15 && node; depth++) { + const inst = node.__vueParentComponent; + if (inst && inst.type && inst.type.__file) return String(inst.type.__file); + node = node.parentElement; + } + } catch {} + return ''; + }; + + const resolveTargetFile = (el) => { + try { + let node = el; + for (let depth = 0; depth < 20 && node; depth++) { + const reactFile = findReactSourceFile(node); + if (reactFile && !reactFile.includes('node_modules')) return reactFile; + const vueFile = findVueSourceFile(node); + if (vueFile && !vueFile.includes('node_modules')) return vueFile; + node = node.parentElement; + } + } catch {} + return ''; + }; + + const findMeaningfulElement = (el, clientX, clientY) => { + try { + let current = el instanceof Element ? el : null; + for (let i = 0; i < 8 && current; i++) { + const tag = String(current.tagName || '').toUpperCase(); + if (tag === 'HTML' || tag === 'BODY') { + const deeper = document.elementFromPoint(clientX, clientY); + if (deeper && deeper !== current && deeper instanceof Element) { + current = deeper; + continue; + } + return current; + } + + let style; + try { + style = window.getComputedStyle(current); + } catch { + return current; + } + + const bg = String(style.backgroundColor || '').toLowerCase(); + const isTransparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)'; + const borderWidth = [ + style.borderTopWidth, + style.borderRightWidth, + style.borderBottomWidth, + style.borderLeftWidth, + ] + .map((x) => String(x || '0px')) + .join(','); + const hasBorder = borderWidth !== '0px,0px,0px,0px'; + + if (!isTransparentBg || hasBorder) return current; + + const rect = current.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return current; + + let bestChild = null; + let bestArea = Infinity; + const children = Array.from(current.children || []); + for (const child of children) { + if (!(child instanceof Element)) continue; + const r = child.getBoundingClientRect(); + if (!r || r.width <= 0 || r.height <= 0) continue; + if (!containsPoint(r, clientX, clientY)) continue; + const area = r.width * r.height; + if (area < bestArea) { + bestArea = area; + bestChild = child; + } + } + + if (!bestChild) return current; + + const childRect = bestChild.getBoundingClientRect(); + const sameSize = + Math.abs(rect.width - childRect.width) < 2 && + Math.abs(rect.height - childRect.height) < 2; + if (!sameSize) return current; + + current = bestChild; + } + } catch {} + return el instanceof Element ? el : null; + }; + + const createToastHost = () => { + const host = document.createElement('div'); + Object.assign(host.style, { + position: 'fixed', + left: '12px', + bottom: '12px', + zIndex: 2147483647, + display: 'flex', + flexDirection: 'column', + gap: '8px', + pointerEvents: 'none', + }); + return host; + }; + + const showToast = (state, message, kind) => { + try { + if (!state.toastHost) return; + const item = document.createElement('div'); + const bg = + kind === 'error' + ? 'rgba(220, 38, 38, 0.92)' + : kind === 'success' + ? 'rgba(22, 163, 74, 0.92)' + : 'rgba(15, 23, 42, 0.92)'; + Object.assign(item.style, { + background: bg, + color: '#fff', + padding: '8px 10px', + borderRadius: '10px', + fontSize: '12px', + fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', + boxShadow: '0 6px 18px rgba(0,0,0,0.22)', + maxWidth: '340px', + lineHeight: '1.35', + }); + item.textContent = String(message || ''); + state.toastHost.appendChild(item); + setTimeout(() => { + try { + item.remove(); + } catch {} + }, 2800); + } catch {} + }; + + const buildStyleMapFromInput = (raw) => { + const out = {}; + const text = String(raw || '').trim(); + if (!text) return out; + const parts = text + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + for (const part of parts) { + const idx = part.indexOf(':'); + if (idx <= 0) continue; + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!key || !value) continue; + out[key] = value; + } + return out; + }; + + const applyInlineStyleMap = (el, styles) => { + try { + if (!(el instanceof Element)) return; + const entries = Object.entries(styles || {}); + for (const [key, value] of entries) { + if (!key || !value) continue; + try { + el.style.setProperty(key, value); + } catch {} + } + } catch {} + }; + + const state = { + active: false, + root: null, + canvas: null, + ctx: null, + raf: 0, + dpr: 1, + viewport: { w: 0, h: 0 }, + hoveredEl: null, + selectedEl: null, + hoverRect: null, + selectedRect: null, + toolbar: null, + toastHost: null, + inputText: '', + inputStyle: '', + lastPointer: { x: 0, y: 0 }, + }; + + const ensureCanvas = () => { + if (!state.canvas || !state.ctx) return; + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1); + const h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1); + if (state.viewport.w === w && state.viewport.h === h && Math.abs(state.dpr - dpr) < 0.01) + return; + state.dpr = dpr; + state.viewport = { w, h }; + state.canvas.width = Math.round(w * dpr); + state.canvas.height = Math.round(h * dpr); + state.canvas.style.width = `${w}px`; + state.canvas.style.height = `${h}px`; + state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + const drawRect = (rect, color, dashed) => { + if (!rect || !state.ctx) return; + const ctx = state.ctx; + const x = Math.round(rect.left) + 0.5; + const y = Math.round(rect.top) + 0.5; + const w = Math.max(0, Math.round(rect.width)); + const h = Math.max(0, Math.round(rect.height)); + if (w <= 0 || h <= 0) return; + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.fillStyle = `${color}22`; + if (dashed) ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; + + const draw = () => { + if (!state.active || !state.ctx) return; + ensureCanvas(); + const ctx = state.ctx; + ctx.clearRect(0, 0, state.viewport.w, state.viewport.h); + + // Keep selected rect fresh in case HMR/layout changes. + try { + if (state.selectedEl && state.selectedEl instanceof Element) { + state.selectedRect = state.selectedEl.getBoundingClientRect(); + } + } catch {} + try { + if (state.hoveredEl && state.hoveredEl instanceof Element) { + state.hoverRect = state.hoveredEl.getBoundingClientRect(); + } + } catch {} + + drawRect(state.hoverRect, COLORS.hover, true); + drawRect(state.selectedRect, COLORS.selected, false); + + // Keep toolbar anchored to the selected element. + positionToolbar(); + }; + + const tick = () => { + if (!state.active) return; + draw(); + state.raf = requestAnimationFrame(tick); + }; + + const isInToolbar = (target) => { + try { + if (!target || !(target instanceof Node)) return false; + if (!state.toolbar) return false; + return state.toolbar.contains(target); + } catch { + return false; + } + }; + + const updateHover = (el, clientX, clientY) => { + const picked = findMeaningfulElement(el, clientX, clientY); + state.hoveredEl = picked; + try { + state.hoverRect = picked ? picked.getBoundingClientRect() : null; + } catch { + state.hoverRect = null; + } + }; + + const positionToolbar = () => { + try { + if (!state.toolbar || !state.selectedRect) return; + const pad = 10; + const maxW = 420; + const rect = state.selectedRect; + const preferredLeft = clamp( + Math.round(rect.left), + pad, + Math.max(pad, window.innerWidth - maxW - pad), + ); + const preferredTop = Math.round(rect.top - 12); + const top = preferredTop < 80 ? Math.round(rect.bottom + 12) : preferredTop; + Object.assign(state.toolbar.style, { + left: `${preferredLeft}px`, + top: `${clamp(top, pad, Math.max(pad, window.innerHeight - 180))}px`, + }); + } catch {} + }; + + const updateToolbarHeader = () => { + try { + if (!state.toolbar) return; + const label = state.toolbar.querySelector('[data-role="label"]'); + if (!label) return; + label.textContent = state.selectedEl ? getElementLabel(state.selectedEl) : 'No selection'; + } catch {} + }; + + const buildApplyPayload = (instruction) => { + const el = state.selectedEl; + const tag = el && el.tagName ? String(el.tagName || '').toLowerCase() : 'unknown'; + const id = el && el.id ? String(el.id) : undefined; + const classes = el && el.classList ? Array.from(el.classList).slice(0, 24) : []; + const text = normalizeTextSnippet(el ? el.textContent : '', 96); + const fingerprint = { tag, id, classes, text }; + const targetFile = el ? resolveTargetFile(el) : ''; + const hints = []; + try { + if (el) { + const r = findReactSourceFile(el); + const v = findVueSourceFile(el); + if (r) hints.push('React'); + if (v) hints.push('Vue'); + } + if (detectTailwind(classes)) hints.push('Tailwind'); + } catch {} + return { + pageUrl: String(location && location.href ? location.href : ''), + targetFile: targetFile || undefined, + fingerprint, + techStackHint: hints.length ? hints : undefined, + instruction, + }; + }; + + const onMouseMove = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + state.lastPointer = { x: e.clientX, y: e.clientY }; + const el = e.target instanceof Element ? e.target : null; + if (!el) return; + updateHover(el, e.clientX, e.clientY); + }; + + const onClick = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + try { + e.preventDefault(); + e.stopPropagation(); + } catch {} + const el = state.hoveredEl; + if (!el) return; + state.selectedEl = el; + try { + state.selectedRect = el.getBoundingClientRect(); + } catch { + state.selectedRect = null; + } + updateToolbarHeader(); + positionToolbar(); + }; + + const intercept = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + // Allow scroll/wheel to keep navigation usable in edit mode. + if (e.type === 'wheel') return; + try { + e.preventDefault(); + e.stopPropagation(); + } catch {} + }; + + const onKeyDown = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + if (e.key === 'Escape') { + try { + e.preventDefault(); + e.stopPropagation(); + } catch {} + stop(); + return; + } + }; + + const buildToolbar = () => { + const box = document.createElement('div'); + state.toolbar = box; + Object.assign(box.style, { + position: 'fixed', + left: '12px', + top: '12px', + zIndex: 2147483647, + pointerEvents: 'auto', + width: 'min(420px, calc(100vw - 24px))', + background: 'rgba(255,255,255,0.96)', + border: '1px solid rgba(148, 163, 184, 0.6)', + borderRadius: '12px', + boxShadow: '0 10px 30px rgba(0,0,0,0.18)', + fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', + color: '#0f172a', + overflow: 'hidden', + }); + + const header = document.createElement('div'); + Object.assign(header.style, { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '10px', + padding: '10px 12px', + background: 'rgba(248,250,252,0.9)', + borderBottom: '1px solid rgba(148, 163, 184, 0.35)', + }); + const label = document.createElement('div'); + label.setAttribute('data-role', 'label'); + Object.assign(label.style, { + fontSize: '12px', + fontWeight: '600', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '280px', + }); + label.textContent = 'Select an element'; + + const btnExit = document.createElement('button'); + btnExit.textContent = 'Exit (Esc)'; + Object.assign(btnExit.style, { + fontSize: '12px', + padding: '6px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + background: '#fff', + cursor: 'pointer', + }); + btnExit.addEventListener('click', () => stop()); + + header.appendChild(label); + header.appendChild(btnExit); + + const body = document.createElement('div'); + Object.assign(body.style, { + padding: '10px 12px 12px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + }); + + const mkRow = (titleText) => { + const row = document.createElement('div'); + Object.assign(row.style, { + display: 'flex', + flexDirection: 'column', + gap: '6px', + }); + const title = document.createElement('div'); + title.textContent = titleText; + Object.assign(title.style, { fontSize: '12px', fontWeight: '600', color: '#334155' }); + row.appendChild(title); + return { row, title }; + }; + + const mkActions = () => { + const actions = document.createElement('div'); + Object.assign(actions.style, { + display: 'flex', + gap: '8px', + alignItems: 'center', + flexWrap: 'wrap', + }); + return actions; + }; + + const mkButton = (text, variant) => { + const btn = document.createElement('button'); + btn.textContent = text; + const bg = variant === 'primary' ? '#0f172a' : '#fff'; + const color = variant === 'primary' ? '#fff' : '#0f172a'; + Object.assign(btn.style, { + fontSize: '12px', + padding: '7px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + background: bg, + color, + cursor: 'pointer', + }); + return btn; + }; + + // Text edit + const textRow = mkRow('Text'); + const textInput = document.createElement('input'); + textInput.type = 'text'; + textInput.placeholder = 'New text…'; + Object.assign(textInput.style, { + width: '100%', + padding: '8px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + fontSize: '12px', + outline: 'none', + }); + textInput.addEventListener('input', () => { + state.inputText = textInput.value; + }); + const textActions = mkActions(); + const btnApplyText = mkButton('Apply (DOM)', 'secondary'); + btnApplyText.addEventListener('click', () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const v = String(state.inputText || '').trim(); + if (!v) return showToast(state, 'Text is empty', 'error'); + try { + state.selectedEl.textContent = v; + showToast(state, 'Text applied (DOM)', 'success'); + } catch { + showToast(state, 'Failed to apply text', 'error'); + } + }); + const btnSyncText = mkButton('Sync to Code', 'primary'); + btnSyncText.addEventListener('click', async () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const v = String(state.inputText || '').trim(); + if (!v) return showToast(state, 'Text is empty', 'error'); + const payload = buildApplyPayload({ + type: 'update_text', + description: `Set the element text to: ${JSON.stringify(v)}`, + text: v, + }); + try { + const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload }); + if (resp && resp.success) { + showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success'); + } else { + showToast(state, resp?.error || 'Agent request failed', 'error'); + } + } catch (err) { + showToast(state, String(err && err.message ? err.message : err), 'error'); + } + }); + textActions.appendChild(btnApplyText); + textActions.appendChild(btnSyncText); + textRow.row.appendChild(textInput); + textRow.row.appendChild(textActions); + + // Style edit + const styleRow = mkRow('Style (CSS declarations)'); + const styleInput = document.createElement('input'); + styleInput.type = 'text'; + styleInput.placeholder = 'e.g. background-color: #f3f4f6; padding: 12px'; + Object.assign(styleInput.style, { + width: '100%', + padding: '8px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + fontSize: '12px', + outline: 'none', + }); + styleInput.addEventListener('input', () => { + state.inputStyle = styleInput.value; + }); + const styleActions = mkActions(); + const btnApplyStyle = mkButton('Apply (DOM)', 'secondary'); + btnApplyStyle.addEventListener('click', () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const map = buildStyleMapFromInput(state.inputStyle); + const keys = Object.keys(map); + if (!keys.length) return showToast(state, 'No valid declarations', 'error'); + applyInlineStyleMap(state.selectedEl, map); + showToast(state, 'Style applied (DOM)', 'success'); + }); + const btnSyncStyle = mkButton('Sync to Code', 'primary'); + btnSyncStyle.addEventListener('click', async () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const map = buildStyleMapFromInput(state.inputStyle); + const keys = Object.keys(map); + if (!keys.length) return showToast(state, 'No valid declarations', 'error'); + const decl = keys.map((k) => `${k}: ${map[k]}`).join('; '); + const payload = buildApplyPayload({ + type: 'update_style', + description: `Apply CSS declarations: ${decl}`, + style: map, + }); + try { + const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload }); + if (resp && resp.success) { + showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success'); + } else { + showToast(state, resp?.error || 'Agent request failed', 'error'); + } + } catch (err) { + showToast(state, String(err && err.message ? err.message : err), 'error'); + } + }); + styleActions.appendChild(btnApplyStyle); + styleActions.appendChild(btnSyncStyle); + styleRow.row.appendChild(styleInput); + styleRow.row.appendChild(styleActions); + + body.appendChild(textRow.row); + body.appendChild(styleRow.row); + box.appendChild(header); + box.appendChild(body); + return box; + }; + + const start = () => { + if (!IS_MAIN) return; + if (state.active) return; + state.active = true; + + const root = document.createElement('div'); + state.root = root; + root.id = '__mcp_web_editor_root'; + Object.assign(root.style, { + position: 'fixed', + inset: '0', + zIndex: 2147483647, + pointerEvents: 'none', + }); + + const canvas = document.createElement('canvas'); + state.canvas = canvas; + Object.assign(canvas.style, { + position: 'fixed', + inset: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }); + root.appendChild(canvas); + + try { + const ctx = canvas.getContext('2d'); + state.ctx = ctx; + } catch { + state.ctx = null; + } + + const toolbar = buildToolbar(); + root.appendChild(toolbar); + + const toastHost = createToastHost(); + state.toastHost = toastHost; + root.appendChild(toastHost); + + document.documentElement.appendChild(root); + + document.addEventListener('mousemove', onMouseMove, { capture: true, passive: true }); + document.addEventListener('click', onClick, true); + document.addEventListener('mousedown', intercept, true); + document.addEventListener('mouseup', intercept, true); + document.addEventListener('dblclick', intercept, true); + document.addEventListener('contextmenu', intercept, true); + document.addEventListener('submit', intercept, true); + document.addEventListener('keydown', onKeyDown, true); + + // Visual cue + showToast(state, 'Web Editor: ON (Esc to exit)', 'info'); + + // Start RAF + state.raf = requestAnimationFrame(tick); + }; + + const stop = () => { + if (!IS_MAIN) return; + if (!state.active) return; + state.active = false; + + try { + if (state.raf) cancelAnimationFrame(state.raf); + } catch {} + state.raf = 0; + + try { + document.removeEventListener('mousemove', onMouseMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('mousedown', intercept, true); + document.removeEventListener('mouseup', intercept, true); + document.removeEventListener('dblclick', intercept, true); + document.removeEventListener('contextmenu', intercept, true); + document.removeEventListener('submit', intercept, true); + document.removeEventListener('keydown', onKeyDown, true); + } catch {} + + try { + state.root && state.root.remove(); + } catch {} + + state.root = null; + state.canvas = null; + state.ctx = null; + state.hoveredEl = null; + state.selectedEl = null; + state.hoverRect = null; + state.selectedRect = null; + state.toolbar = null; + state.toastHost = null; + state.inputText = ''; + state.inputStyle = ''; + }; + + const toggle = () => { + if (!IS_MAIN) return false; + if (state.active) { + stop(); + return false; + } + start(); + return true; + }; + + // Expose minimal API for debugging + window[GLOBAL_KEY] = { + start, + stop, + toggle, + getState: () => ({ active: state.active }), + }; + + // Message handler (background -> tab) + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + try { + if (!IS_MAIN) return false; + if (request && request.action === 'web_editor_ping') { + sendResponse({ status: 'pong' }); + return false; + } + if (request && request.action === 'web_editor_toggle') { + const active = toggle(); + sendResponse({ active }); + return true; + } + if (request && request.action === 'web_editor_start') { + start(); + sendResponse({ active: true }); + return true; + } + if (request && request.action === 'web_editor_stop') { + stop(); + sendResponse({ active: false }); + return true; + } + } catch (e) { + try { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + } catch {} + return true; + } + return false; + }); +})(); diff --git a/app/chrome-extension/inject-scripts/web-fetcher-helper.js b/app/chrome-extension/inject-scripts/web-fetcher-helper.js new file mode 100644 index 0000000..9231374 --- /dev/null +++ b/app/chrome-extension/inject-scripts/web-fetcher-helper.js @@ -0,0 +1,3062 @@ +/* eslint-disable */ + +if (window.__WEB_FETCHER_HELPER_INITIALIZED__) { + // Already initialized, skip +} else { + window.__WEB_FETCHER_HELPER_INITIALIZED__ = true; + + /* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + /* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + + /** + * Public constructor. + * @param {HTMLDocument} doc The document to parse. + * @param {Object} options The options object. + */ + function Readability(doc, options) { + // In some older versions, people passed a URI as the first argument. Cope: + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error('First argument to Readability constructor should be a document object.'); + } + options = options || {}; + + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + + // Configurable options + this._debug = !!options.debug; + this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat(options.classesToPreserve || []); + this._keepClasses = !!options.keepClasses; + this._serializer = + options.serializer || + function (el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + + // Start with all flags set + this._flags = + this.FLAG_STRIP_UNLIKELYS | this.FLAG_WEIGHT_CLASSES | this.FLAG_CLEAN_CONDITIONALLY; + + // Control whether log messages are sent to the console + if (this._debug) { + let logNode = function (node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; + } + let attrPairs = Array.from(node.attributes || [], function (attr) { + return `${attr.name}="${attr.value}"`; + }).join(' '); + return `<${node.localName} ${attrPairs}>`; + }; + this.log = function () { + if (typeof console !== 'undefined') { + let args = Array.from(arguments, (arg) => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift('Reader: (Readability)'); + + // Debug logging removed + } else if (typeof dump !== 'undefined') { + /* global dump */ + var msg = Array.prototype.map + .call(arguments, function (x) { + return x && x.nodeName ? logNode(x) : x; + }) + .join(' '); + dump('Reader: (Readability) ' + msg + '\n'); + } + }; + } else { + this.log = function () {}; + } + } + + Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 0x1, + FLAG_WEIGHT_CLASSES: 0x2, + FLAG_CLEAN_CONDITIONALLY: 0x4, + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: 'section,h2,h3,h4,h5,h6,p,td,pre'.toUpperCase().split(','), + + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: + /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + + positive: + /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: + /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: + /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: + /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, + whitespace: /^\s*$/, + hasContent: /\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, + b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: + /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu, + }, + + UNLIKELY_ROLES: [ + 'menu', + 'menubar', + 'complementary', + 'navigation', + 'alert', + 'alertdialog', + 'dialog', + ], + + DIV_TO_P_ELEMS: new Set(['BLOCKQUOTE', 'DL', 'DIV', 'IMG', 'OL', 'P', 'PRE', 'TABLE', 'UL']), + + ALTER_TO_DIV_EXCEPTIONS: ['DIV', 'ARTICLE', 'SECTION', 'P', 'OL', 'UL'], + + PRESENTATIONAL_ATTRIBUTES: [ + 'align', + 'background', + 'bgcolor', + 'border', + 'cellpadding', + 'cellspacing', + 'frame', + 'hspace', + 'rules', + 'style', + 'valign', + 'vspace', + ], + + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ['TABLE', 'TH', 'TD', 'HR', 'PRE'], + + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + 'ABBR', + 'AUDIO', + 'B', + 'BDO', + 'BR', + 'BUTTON', + 'CITE', + 'CODE', + 'DATA', + 'DATALIST', + 'DFN', + 'EM', + 'EMBED', + 'I', + 'IMG', + 'INPUT', + 'KBD', + 'LABEL', + 'MARK', + 'MATH', + 'METER', + 'NOSCRIPT', + 'OBJECT', + 'OUTPUT', + 'PROGRESS', + 'Q', + 'RUBY', + 'SAMP', + 'SCRIPT', + 'SELECT', + 'SMALL', + 'SPAN', + 'STRONG', + 'SUB', + 'SUP', + 'TEXTAREA', + 'TIME', + 'VAR', + 'WBR', + ], + + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ['page'], + + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: '<', + gt: '>', + amp: '&', + quot: '"', + apos: "'", + }, + + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent(articleContent) { + // Readability cannot open relative uris so we convert them to absolute uris. + this._fixRelativeUris(articleContent); + + this._simplifyNestedElements(articleContent); + + if (!this._keepClasses) { + // Remove classes. + this._cleanClasses(articleContent); + } + }, + + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes(nodeList, filterFn) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error('Do not pass live node lists to _removeNodes'); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags(nodeList, newTagName) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error('Do not pass live node lists to _replaceNodeTags'); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode(nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + + _getAllNodesWithTag(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(',')); + } + return [].concat.apply( + [], + tagNames.map(function (tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }), + ); + }, + + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute('class') || '') + .split(/\s+/) + .filter((cls) => classesToPreserve.includes(cls)) + .join(' '); + + if (className) { + node.setAttribute('class', className); + } else { + node.removeAttribute('class'); + } + + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + + /** + * Tests whether a string is a URL or not. + * + * @param {string} str The string to test + * @return {boolean} true if str is a URL, false if not + */ + _isUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } + }, + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + // Leave hash links alone if the base URI matches the document URI: + if (baseURI == documentURI && uri.charAt(0) == '#') { + return uri; + } + + // Otherwise, resolve against base URI: + try { + return new URL(uri, baseURI).href; + } catch (ex) { + // Something went wrong, just return the original: + } + return uri; + } + + var links = this._getAllNodesWithTag(articleContent, ['a']); + this._forEachNode(links, function (link) { + var href = link.getAttribute('href'); + if (href) { + // Remove links with javascript: URIs, since + // they won't work after scripts have been removed from the page. + if (href.indexOf('javascript:') === 0) { + // if the link only contains simple text content, it can be converted to a text node + if (link.childNodes.length === 1 && link.childNodes[0].nodeType === this.TEXT_NODE) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + // if the link has multiple children, they should all be preserved + var container = this._doc.createElement('span'); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute('href', toAbsoluteURI(href)); + } + } + }); + + var medias = this._getAllNodesWithTag(articleContent, [ + 'img', + 'picture', + 'figure', + 'video', + 'audio', + 'source', + ]); + + this._forEachNode(medias, function (media) { + var src = media.getAttribute('src'); + var poster = media.getAttribute('poster'); + var srcset = media.getAttribute('srcset'); + + if (src) { + media.setAttribute('src', toAbsoluteURI(src)); + } + + if (poster) { + media.setAttribute('poster', toAbsoluteURI(poster)); + } + + if (srcset) { + var newSrcset = srcset.replace(this.REGEXPS.srcsetUrl, function (_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || '') + p3; + }); + + media.setAttribute('srcset', newSrcset); + } + }); + }, + + _simplifyNestedElements(articleContent) { + var node = articleContent; + + while (node) { + if ( + node.parentNode && + ['DIV', 'SECTION'].includes(node.tagName) && + !(node.id && node.id.startsWith('readability')) + ) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if ( + this._hasSingleTagInsideElement(node, 'DIV') || + this._hasSingleTagInsideElement(node, 'SECTION') + ) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttributeNode(node.attributes[i].cloneNode()); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + + node = this._getNextNode(node); + } + }, + + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle() { + var doc = this._doc; + var curTitle = ''; + var origTitle = ''; + + try { + curTitle = origTitle = doc.title.trim(); + + // If they had an element with id "title" in their HTML + if (typeof curTitle !== 'string') { + curTitle = origTitle = this._getInnerText(doc.getElementsByTagName('title')[0]); + } + } catch (e) { + /* ignore exceptions setting the title. */ + } + + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\s+/).length; + } + + // If there's a separator in the title, first remove the final part + if (/ [\|\-\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + let allSeparators = Array.from(origTitle.matchAll(/ [\|\-\\\/>»] /gi)); + curTitle = origTitle.substring(0, allSeparators.pop().index); + + // If the resulting title is too short, remove the first part instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/^[^\|\-\\\/>»]*[\|\-\\\/>»]/gi, ''); + } + } else if (curTitle.includes(': ')) { + // Check if we have an heading containing this exact string, so we + // could assume it's the full title. + var headings = this._getAllNodesWithTag(doc, ['h1', 'h2']); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function (heading) { + return heading.textContent.trim() === trimmedTitle; + }); + + // If we don't, let's extract the title out of the original title string. + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(':') + 1); + + // If the title is now too short, try the first colon instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(':') + 1); + // But if we have too many words before the colon there's something weird + // with the titles and the H tags so let's just use the original title instead + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(':'))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName('h1'); + + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, ' '); + // If we now have 4 words or fewer as our title, and either no + // 'hierarchical' separators (\, /, > or ») were found in the original + // title or we decreased the number of words by more than 1 word, use + // the original title. + var curTitleWordCount = wordCount(curTitle); + if ( + curTitleWordCount <= 4 && + (!titleHadHierarchicalSeparators || + curTitleWordCount != wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, '')) - 1) + ) { + curTitle = origTitle; + } + + return curTitle; + }, + + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument() { + var doc = this._doc; + + // Remove all style tags in head + this._removeNodes(this._getAllNodesWithTag(doc, ['style'])); + + if (doc.body) { + this._replaceBrs(doc.body); + } + + this._replaceNodeTags(this._getAllNodesWithTag(doc, ['font']), 'SPAN'); + }, + + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode(node) { + var next = node; + while ( + next && + next.nodeType != this.ELEMENT_NODE && + this.REGEXPS.whitespace.test(next.textContent) + ) { + next = next.nextSibling; + } + return next; + }, + + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs(elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ['br']), function (br) { + var next = br.nextSibling; + + // Whether 2 or more
elements have been found and replaced with a + //

block. + var replaced = false; + + // If we find a
chain, remove the
s until we hit another node + // or non-whitespace. This leaves behind the first
in the chain + // (which will be replaced with a

later). + while ((next = this._nextNode(next)) && next.tagName == 'BR') { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + + // If we removed a
chain, replace the remaining
with a

. Add + // all sibling nodes as children of the

until we hit another
+ // chain. + if (replaced) { + var p = this._doc.createElement('p'); + br.parentNode.replaceChild(p, br); + + next = p.nextSibling; + while (next) { + // If we've hit another

, we're done adding children to this

. + if (next.tagName == 'BR') { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == 'BR') { + break; + } + } + + if (!this._isPhrasingContent(next)) { + break; + } + + // Otherwise, make this node a child of the new

. + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + + if (p.parentNode.tagName === 'P') { + this._setNodeTag(p.parentNode, 'DIV'); + } + } + }); + }, + + _setNodeTag(node, tag) { + this.log('_setNodeTag', node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + + for (var i = 0; i < node.attributes.length; i++) { + replacement.setAttributeNode(node.attributes[i].cloneNode()); + } + return replacement; + }, + + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle(articleContent) { + this._cleanStyles(articleContent); + + // Check for data tables before we continue, to avoid removing items in + // those tables, which will often be isolated even though they're + // visually linked to other content-ful elements (text, images, etc.). + this._markDataTables(articleContent); + + this._fixLazyImages(articleContent); + + // Clean out junk from the article content + this._cleanConditionally(articleContent, 'form'); + this._cleanConditionally(articleContent, 'fieldset'); + this._clean(articleContent, 'object'); + this._clean(articleContent, 'embed'); + this._clean(articleContent, 'footer'); + this._clean(articleContent, 'link'); + this._clean(articleContent, 'aside'); + + // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, + // which means we don't remove the top candidates even they have "share". + + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + + this._forEachNode(articleContent.children, function (topCandidate) { + this._cleanMatchedNodes(topCandidate, function (node, matchString) { + return ( + this.REGEXPS.shareElements.test(matchString) && + node.textContent.length < shareElementThreshold + ); + }); + }); + + this._clean(articleContent, 'iframe'); + this._clean(articleContent, 'input'); + this._clean(articleContent, 'textarea'); + this._clean(articleContent, 'select'); + this._clean(articleContent, 'button'); + this._cleanHeaders(articleContent); + + // Do these last as the previous stuff may have removed junk + // that will affect these + this._cleanConditionally(articleContent, 'table'); + this._cleanConditionally(articleContent, 'ul'); + this._cleanConditionally(articleContent, 'div'); + + // replace H1 with H2 as H1 should be only title that is displayed separately + this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ['h1']), 'h2'); + + // Remove extra paragraphs + this._removeNodes(this._getAllNodesWithTag(articleContent, ['p']), function (paragraph) { + // At this point, nasty iframes have been removed; only embedded video + // ones remain. + var contentElementCount = this._getAllNodesWithTag(paragraph, [ + 'img', + 'embed', + 'object', + 'iframe', + ]).length; + return contentElementCount === 0 && !this._getInnerText(paragraph, false); + }); + + this._forEachNode(this._getAllNodesWithTag(articleContent, ['br']), function (br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == 'P') { + br.remove(); + } + }); + + // Remove single-cell tables + this._forEachNode(this._getAllNodesWithTag(articleContent, ['table']), function (table) { + var tbody = this._hasSingleTagInsideElement(table, 'TBODY') + ? table.firstElementChild + : table; + if (this._hasSingleTagInsideElement(tbody, 'TR')) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, 'TD')) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) ? 'P' : 'DIV', + ); + table.parentNode.replaceChild(cell, table); + } + } + }); + }, + + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode(node) { + node.readability = { contentScore: 0 }; + + switch (node.tagName) { + case 'DIV': + node.readability.contentScore += 5; + break; + + case 'PRE': + case 'TD': + case 'BLOCKQUOTE': + node.readability.contentScore += 3; + break; + + case 'ADDRESS': + case 'OL': + case 'UL': + case 'DL': + case 'DD': + case 'DT': + case 'LI': + case 'FORM': + node.readability.contentScore -= 3; + break; + + case 'H1': + case 'H2': + case 'H3': + case 'H4': + case 'H5': + case 'H6': + case 'TH': + node.readability.contentScore -= 5; + break; + } + + node.readability.contentScore += this._getClassWeight(node); + }, + + _removeAndGetNext(node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + * + * @param {Element} node + * @param {boolean} ignoreSelfAndKids + * @return {Element} + */ + _getNextNode(node, ignoreSelfAndKids) { + // First check for kids if those aren't being ignored + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + // Then for siblings... + if (node.nextElementSibling) { + return node.nextElementSibling; + } + // And finally, move up the parent chain *and* find a sibling + // (because this is depth-first traversal, we will have already + // seen the parent nodes themselves). + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity(textA, textB) { + var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter((token) => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(' ').length / tokensB.join(' ').length; + return 1 - distanceB; + }, + + /** + * Checks whether an element node contains a valid byline + * + * @param node {Element} + * @param matchString {string} + * @return boolean + */ + _isValidByline(node, matchString) { + var rel = node.getAttribute('rel'); + var itemprop = node.getAttribute('itemprop'); + var bylineLength = node.textContent.trim().length; + + return ( + (rel === 'author' || + (itemprop && itemprop.includes('author')) || + this.REGEXPS.byline.test(matchString)) && + !!bylineLength && + bylineLength < 100 + ); + }, + + _getNodeAncestors(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, + ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + + _grabArticle(page) { + this.log('**** grabArticle ****'); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + + // We can't grab an article if we don't have a page! + if (!page) { + this.log('No body found in document. Abort.'); + return null; + } + + var pageCacheHtml = page.innerHTML; + + while (true) { + this.log('Starting grabArticle loop'); + var stripUnlikelyCandidates = this._flagIsActive(this.FLAG_STRIP_UNLIKELYS); + + // First, node prepping. Trash nodes that look cruddy (like ones with the + // class name "comment", etc), and turn divs into P tags where they have been + // used inappropriately (as in, where they contain no other block level elements.) + var elementsToScore = []; + var node = this._doc.documentElement; + + let shouldRemoveTitleHeader = true; + + while (node) { + if (node.tagName === 'HTML') { + this._articleLang = node.getAttribute('lang'); + } + + var matchString = node.className + ' ' + node.id; + + if (!this._isProbablyVisible(node)) { + this.log('Removing hidden node - ' + matchString); + node = this._removeAndGetNext(node); + continue; + } + + // User is not able to see elements applied with both "aria-modal = true" and "role = dialog" + if (node.getAttribute('aria-modal') == 'true' && node.getAttribute('role') == 'dialog') { + node = this._removeAndGetNext(node); + continue; + } + + // If we don't have a byline yet check to see if this node is a byline; if it is store the byline and remove the node. + if ( + !this._articleByline && + !this._metadata.byline && + this._isValidByline(node, matchString) + ) { + // Find child node matching [itemprop="name"] and use that if it exists for a more accurate author name byline + var endOfSearchMarkerNode = this._getNextNode(node, true); + var next = this._getNextNode(node); + var itemPropNameNode = null; + while (next && next != endOfSearchMarkerNode) { + var itemprop = next.getAttribute('itemprop'); + if (itemprop && itemprop.includes('name')) { + itemPropNameNode = next; + break; + } else { + next = this._getNextNode(next); + } + } + this._articleByline = (itemPropNameNode ?? node).textContent.trim(); + node = this._removeAndGetNext(node); + continue; + } + + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log('Removing header: ', node.textContent.trim(), this._articleTitle.trim()); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + + // Remove unlikely candidates + if (stripUnlikelyCandidates) { + if ( + this.REGEXPS.unlikelyCandidates.test(matchString) && + !this.REGEXPS.okMaybeItsACandidate.test(matchString) && + !this._hasAncestorTag(node, 'table') && + !this._hasAncestorTag(node, 'code') && + node.tagName !== 'BODY' && + node.tagName !== 'A' + ) { + this.log('Removing unlikely candidate - ' + matchString); + node = this._removeAndGetNext(node); + continue; + } + + if (this.UNLIKELY_ROLES.includes(node.getAttribute('role'))) { + this.log( + 'Removing content with role ' + node.getAttribute('role') + ' - ' + matchString, + ); + node = this._removeAndGetNext(node); + continue; + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if ( + (node.tagName === 'DIV' || + node.tagName === 'SECTION' || + node.tagName === 'HEADER' || + node.tagName === 'H1' || + node.tagName === 'H2' || + node.tagName === 'H3' || + node.tagName === 'H4' || + node.tagName === 'H5' || + node.tagName === 'H6') && + this._isElementWithoutContent(node) + ) { + node = this._removeAndGetNext(node); + continue; + } + + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + + // Turn all divs that don't have children block level elements into p's + if (node.tagName === 'DIV') { + // Put phrasing content into paragraphs. + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement('p'); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if (this._hasSingleTagInsideElement(node, 'P') && this._getLinkDensity(node) < 0.25) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, 'P'); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + + /** + * Loop through all paragraphs, and assign a score to them based on how content-y they look. + * Then add their score to their parent node. + * + * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. + **/ + var candidates = []; + this._forEachNode(elementsToScore, function (elementToScore) { + if ( + !elementToScore.parentNode || + typeof elementToScore.parentNode.tagName === 'undefined' + ) { + return; + } + + // If this paragraph is less than 25 characters, don't even count it. + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + + // Exclude nodes with no ancestor. + var ancestors = this._getNodeAncestors(elementToScore, 5); + if (ancestors.length === 0) { + return; + } + + var contentScore = 0; + + // Add a point for the paragraph itself as a base. + contentScore += 1; + + // Add points for any commas within this paragraph. + contentScore += innerText.split(this.REGEXPS.commas).length; + + // For every 100 characters in this paragraph, add another point. Up to 3 points. + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + + // Initialize and score ancestors. + this._forEachNode(ancestors, function (ancestor, level) { + if ( + !ancestor.tagName || + !ancestor.parentNode || + typeof ancestor.parentNode.tagName === 'undefined' + ) { + return; + } + + if (typeof ancestor.readability === 'undefined') { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + + // Node score divider: + // - parent: 1 (no division) + // - grandparent: 2 + // - great grandparent+: ancestor level * 3 + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + + // After we've calculated scores, loop through all of the possible + // candidate nodes we found and find the one with the highest score. + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + + // Scale the final candidates score based on link density. Good content + // should have a relatively small link density (5% or less) and be mostly + // unaffected by this operation. + var candidateScore = + candidate.readability.contentScore * (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + + this.log('Candidate:', candidate, 'with score ' + candidateScore); + + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + + if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + + // If we still have no top candidate, just use the body as a last resort. + // We also have to copy the body node so it is something we can modify. + if (topCandidate === null || topCandidate.tagName === 'BODY') { + // Move all of the page's children into topCandidate + topCandidate = doc.createElement('DIV'); + neededToCreateTopCandidate = true; + // Move everything (not just elements, also text nodes etc.) into the container + // so we even include text directly in the body: + while (page.firstChild) { + this.log('Moving child out:', page.firstChild); + topCandidate.appendChild(page.firstChild); + } + + page.appendChild(topCandidate); + + this._initializeNode(topCandidate); + } else if (topCandidate) { + // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array + // and whose scores are quite closed with current `topCandidate` node. + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if ( + topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >= + 0.75 + ) { + alternativeCandidateAncestors.push(this._getNodeAncestors(topCandidates[i])); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate && parentOfTopCandidate.tagName !== 'BODY') { + var listsContainingThisAncestor = 0; + for ( + var ancestorIndex = 0; + ancestorIndex < alternativeCandidateAncestors.length && + listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; + ancestorIndex++ + ) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes(parentOfTopCandidate), + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + + // Because of our bonus system, parents of candidates might have scores + // themselves. They get half of the node. There won't be nodes with higher + // scores than our topCandidate, but if we see the score going *up* in the first + // few steps up the tree, that's a decent sign that there might be more content + // lurking in other places that we want to unify in. The sibling stuff + // below does some of that - but only if we've looked high enough up the DOM + // tree. + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + // The scores shouldn't get too low. + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate && parentOfTopCandidate.tagName !== 'BODY') { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + // Alright! We found a better parent to use. + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + + // If the top candidate is the only child, use parent instead. This will help sibling + // joining logic when adjacent content is actually located in parent's sibling node. + parentOfTopCandidate = topCandidate.parentNode; + while ( + parentOfTopCandidate && + parentOfTopCandidate.tagName != 'BODY' && + parentOfTopCandidate.children.length == 1 + ) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + + // Now that we have the top candidate, look through its siblings for content + // that might also be related. Things like preambles, content split by ads + // that we removed, etc. + var articleContent = doc.createElement('DIV'); + if (isPaging) { + articleContent.id = 'readability-content'; + } + + var siblingScoreThreshold = Math.max(10, topCandidate.readability.contentScore * 0.2); + // Keep potential top candidate's parent node to try to get text direction of it later. + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + + this.log( + 'Looking at sibling node:', + sibling, + sibling.readability ? 'with score ' + sibling.readability.contentScore : '', + ); + this.log( + 'Sibling has score', + sibling.readability ? sibling.readability.contentScore : 'Unknown', + ); + + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + + // Give a bonus if sibling nodes and top candidates have the example same classname + if (sibling.className === topCandidate.className && topCandidate.className !== '') { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + + if ( + sibling.readability && + sibling.readability.contentScore + contentBonus >= siblingScoreThreshold + ) { + append = true; + } else if (sibling.nodeName === 'P') { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if ( + nodeLength < 80 && + nodeLength > 0 && + linkDensity === 0 && + nodeContent.search(/\.( |$)/) !== -1 + ) { + append = true; + } + } + } + + if (append) { + this.log('Appending node:', sibling); + + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + this.log('Altering sibling:', sibling, 'to div.'); + + sibling = this._setNodeTag(sibling, 'DIV'); + } + + articleContent.appendChild(sibling); + // Fetch children again to make it compatible + // with DOM parsers without live collection support. + siblings = parentOfTopCandidate.children; + // siblings is a reference to the children array, and + // sibling is removed from the array when we call appendChild(). + // As a result, we must revisit this index since the nodes + // have been shifted. + s -= 1; + sl -= 1; + } + } + + if (this._debug) { + this.log('Article content pre-prep: ' + articleContent.innerHTML); + } + // So we have all of the content that we need. Now we clean it up for presentation. + this._prepArticle(articleContent); + if (this._debug) { + this.log('Article content post-prep: ' + articleContent.innerHTML); + } + + if (neededToCreateTopCandidate) { + // We already created a fake div thing, and there wouldn't have been any siblings left + // for the previous loop, so there's no point trying to create a new div, and then + // move all the children over. Just assign IDs and class names here. No need to append + // because that already happened anyway. + topCandidate.id = 'readability-page-1'; + topCandidate.className = 'page'; + } else { + var div = doc.createElement('DIV'); + div.id = 'readability-page-1'; + div.className = 'page'; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + + if (this._debug) { + this.log('Article content after paging: ' + articleContent.innerHTML); + } + + var parseSuccessful = true; + + // Now that we've gone through the full algorithm, check to see if + // we got any meaningful content. If we didn't, we may need to re-run + // grabArticle with different flags set. This gives us a higher likelihood of + // finding the content, and the sieve approach gives us a higher likelihood of + // finding the -right- content. + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + // eslint-disable-next-line no-unsanitized/property + page.innerHTML = pageCacheHtml; + + this._attempts.push({ + articleContent, + textLength, + }); + + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + // No luck after removing flags, just return the longest text we found during the different loops + this._attempts.sort(function (a, b) { + return b.textLength - a.textLength; + }); + + // But first check if we actually have something + if (!this._attempts[0].textLength) { + return null; + } + + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + + if (parseSuccessful) { + // Find out text direction from ancestors of final top candidate. + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate), + ); + this._someNode(ancestors, function (ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute('dir'); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities(str) { + if (!str) { + return str; + } + + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str + .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) { + return htmlEscapeMap[tag]; + }) + .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + + // these character references are replaced by a conforming HTML parser + if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) { + num = 0xfffd; + } + + return String.fromCodePoint(num); + }); + }, + + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD(doc) { + var scripts = this._getAllNodesWithTag(doc, ['script']); + + var metadata; + + this._forEachNode(scripts, function (jsonLdElement) { + if (!metadata && jsonLdElement.getAttribute('type') === 'application/ld+json') { + try { + // Strip CDATA markers if present + var content = jsonLdElement.textContent.replace(/^\s*\s*$/g, ''); + var parsed = JSON.parse(content); + + if (Array.isArray(parsed)) { + parsed = parsed.find((it) => { + return it['@type'] && it['@type'].match(this.REGEXPS.jsonLdArticleTypes); + }); + if (!parsed) { + return; + } + } + + var schemaDotOrgRegex = /^https?\:\/\/schema\.org\/?$/; + var matches = + (typeof parsed['@context'] === 'string' && + parsed['@context'].match(schemaDotOrgRegex)) || + (typeof parsed['@context'] === 'object' && + typeof parsed['@context']['@vocab'] == 'string' && + parsed['@context']['@vocab'].match(schemaDotOrgRegex)); + + if (!matches) { + return; + } + + if (!parsed['@type'] && Array.isArray(parsed['@graph'])) { + parsed = parsed['@graph'].find((it) => { + return (it['@type'] || '').match(this.REGEXPS.jsonLdArticleTypes); + }); + } + + if ( + !parsed || + !parsed['@type'] || + !parsed['@type'].match(this.REGEXPS.jsonLdArticleTypes) + ) { + return; + } + + metadata = {}; + + if ( + typeof parsed.name === 'string' && + typeof parsed.headline === 'string' && + parsed.name !== parsed.headline + ) { + // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz + // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either + // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default. + + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75; + + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === 'string') { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === 'string') { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === 'string') { + metadata.byline = parsed.author.name.trim(); + } else if ( + Array.isArray(parsed.author) && + parsed.author[0] && + typeof parsed.author[0].name === 'string' + ) { + metadata.byline = parsed.author + .filter(function (author) { + return author && typeof author.name === 'string'; + }) + .map(function (author) { + return author.name.trim(); + }) + .join(', '); + } + } + if (typeof parsed.description === 'string') { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === 'string') { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === 'string') { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata(jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName('meta'); + + // property is a space-separated list of values + var propertyPattern = + /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi; + + // name is a single value + var namePattern = + /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i; + + // Find description tags. + this._forEachNode(metaElements, function (element) { + var elementName = element.getAttribute('name'); + var elementProperty = element.getAttribute('property'); + var content = element.getAttribute('content'); + if (!content) { + return; + } + var matches = null; + var name = null; + + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[0].toLowerCase().replace(/\s/g, ''); + // multiple authors + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + // Convert to lowercase, remove any whitespace, and convert dots + // to colons so we can match below. + name = name.toLowerCase().replace(/\s/g, '').replace(/\./g, ':'); + values[name] = content.trim(); + } + } + }); + + // get title + metadata.title = + jsonld.title || + values['dc:title'] || + values['dcterm:title'] || + values['og:title'] || + values['weibo:article:title'] || + values['weibo:webpage:title'] || + values.title || + values['twitter:title'] || + values['parsely-title']; + + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + + const articleAuthor = + typeof values['article:author'] === 'string' && !this._isUrl(values['article:author']) + ? values['article:author'] + : undefined; + + // get author + metadata.byline = + jsonld.byline || + values['dc:creator'] || + values['dcterm:creator'] || + values.author || + values['parsely-author'] || + articleAuthor; + + // get description + metadata.excerpt = + jsonld.excerpt || + values['dc:description'] || + values['dcterm:description'] || + values['og:description'] || + values['weibo:article:description'] || + values['weibo:webpage:description'] || + values.description || + values['twitter:description']; + + // get site name + metadata.siteName = jsonld.siteName || values['og:site_name']; + + // get article published time + metadata.publishedTime = + jsonld.datePublished || + values['article:published_time'] || + values['parsely-pub-date'] || + null; + + // in many sites the meta value is escaped with HTML entities, + // so here we need to unescape it + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + + return metadata; + }, + + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage(node) { + while (node) { + if (node.tagName === 'IMG') { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== '') { + return false; + } + node = node.children[0]; + } + return false; + }, + + /** + * Find all