Repository: hangwin/mcp-chrome Branch: master Commit: f48e71751e00 Files: 652 Total size: 37.5 MB Directory structure: gitextract_0pcja33u/ ├── .gitattributes ├── .github/ │ └── workflows/ │ └── build-release.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ └── extensions.json ├── LICENSE ├── README.md ├── README_zh.md ├── app/ │ ├── chrome-extension/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── _locales/ │ │ │ ├── de/ │ │ │ │ └── messages.json │ │ │ ├── en/ │ │ │ │ └── messages.json │ │ │ ├── ja/ │ │ │ │ └── messages.json │ │ │ ├── ko/ │ │ │ │ └── messages.json │ │ │ ├── zh_CN/ │ │ │ │ └── messages.json │ │ │ └── zh_TW/ │ │ │ └── messages.json │ │ ├── common/ │ │ │ ├── agent-models.ts │ │ │ ├── constants.ts │ │ │ ├── element-marker-types.ts │ │ │ ├── message-types.ts │ │ │ ├── node-types.ts │ │ │ ├── rr-v3-keepalive-protocol.ts │ │ │ ├── step-types.ts │ │ │ ├── tool-handler.ts │ │ │ └── web-editor-types.ts │ │ ├── entrypoints/ │ │ │ ├── background/ │ │ │ │ ├── element-marker/ │ │ │ │ │ ├── element-marker-storage.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── keepalive-manager.ts │ │ │ │ ├── native-host.ts │ │ │ │ ├── quick-panel/ │ │ │ │ │ ├── agent-handler.ts │ │ │ │ │ ├── commands.ts │ │ │ │ │ └── tabs-handler.ts │ │ │ │ ├── record-replay/ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ ├── adapter.ts │ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ │ ├── assert.ts │ │ │ │ │ │ │ ├── click.ts │ │ │ │ │ │ │ ├── common.ts │ │ │ │ │ │ │ ├── control-flow.ts │ │ │ │ │ │ │ ├── delay.ts │ │ │ │ │ │ │ ├── dom.ts │ │ │ │ │ │ │ ├── drag.ts │ │ │ │ │ │ │ ├── extract.ts │ │ │ │ │ │ │ ├── fill.ts │ │ │ │ │ │ │ ├── http.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── key.ts │ │ │ │ │ │ │ ├── navigate.ts │ │ │ │ │ │ │ ├── screenshot.ts │ │ │ │ │ │ │ ├── script.ts │ │ │ │ │ │ │ ├── scroll.ts │ │ │ │ │ │ │ ├── tabs.ts │ │ │ │ │ │ │ └── wait.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── registry.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── engine/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── execution-mode.ts │ │ │ │ │ │ ├── logging/ │ │ │ │ │ │ │ └── run-logger.ts │ │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ │ ├── breakpoint.ts │ │ │ │ │ │ │ ├── manager.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── policies/ │ │ │ │ │ │ │ ├── retry.ts │ │ │ │ │ │ │ └── wait.ts │ │ │ │ │ │ ├── runners/ │ │ │ │ │ │ │ ├── after-script-queue.ts │ │ │ │ │ │ │ ├── control-flow-runner.ts │ │ │ │ │ │ │ ├── step-executor.ts │ │ │ │ │ │ │ ├── step-runner.ts │ │ │ │ │ │ │ └── subflow-runner.ts │ │ │ │ │ │ ├── scheduler.ts │ │ │ │ │ │ ├── state-manager.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── expression.ts │ │ │ │ │ ├── flow-runner.ts │ │ │ │ │ ├── flow-store.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── legacy-types.ts │ │ │ │ │ ├── nodes/ │ │ │ │ │ │ ├── assert.ts │ │ │ │ │ │ ├── click.ts │ │ │ │ │ │ ├── conditional.ts │ │ │ │ │ │ ├── download-screenshot-attr-event-frame-loop.ts │ │ │ │ │ │ ├── drag.ts │ │ │ │ │ │ ├── execute-flow.ts │ │ │ │ │ │ ├── extract.ts │ │ │ │ │ │ ├── fill.ts │ │ │ │ │ │ ├── http.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── key.ts │ │ │ │ │ │ ├── loops.ts │ │ │ │ │ │ ├── navigate.ts │ │ │ │ │ │ ├── script.ts │ │ │ │ │ │ ├── scroll.ts │ │ │ │ │ │ ├── tabs.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── wait.ts │ │ │ │ │ ├── recording/ │ │ │ │ │ │ ├── browser-event-listener.ts │ │ │ │ │ │ ├── content-injection.ts │ │ │ │ │ │ ├── content-message-handler.ts │ │ │ │ │ │ ├── flow-builder.ts │ │ │ │ │ │ ├── recorder-manager.ts │ │ │ │ │ │ └── session-manager.ts │ │ │ │ │ ├── rr-utils.ts │ │ │ │ │ ├── selector-engine.ts │ │ │ │ │ ├── storage/ │ │ │ │ │ │ └── indexeddb-manager.ts │ │ │ │ │ ├── trigger-store.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── record-replay-v3/ │ │ │ │ │ ├── bootstrap.ts │ │ │ │ │ ├── domain/ │ │ │ │ │ │ ├── debug.ts │ │ │ │ │ │ ├── errors.ts │ │ │ │ │ │ ├── events.ts │ │ │ │ │ │ ├── flow.ts │ │ │ │ │ │ ├── ids.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── json.ts │ │ │ │ │ │ ├── policy.ts │ │ │ │ │ │ ├── triggers.ts │ │ │ │ │ │ └── variables.ts │ │ │ │ │ ├── engine/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── keepalive/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── offscreen-keepalive.ts │ │ │ │ │ │ ├── kernel/ │ │ │ │ │ │ │ ├── artifacts.ts │ │ │ │ │ │ │ ├── breakpoints.ts │ │ │ │ │ │ │ ├── debug-controller.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── kernel.ts │ │ │ │ │ │ │ ├── recovery-kernel.ts │ │ │ │ │ │ │ ├── runner.ts │ │ │ │ │ │ │ └── traversal.ts │ │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── register-v2-replay-nodes.ts │ │ │ │ │ │ │ ├── registry.ts │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ └── v2-action-adapter.ts │ │ │ │ │ │ ├── queue/ │ │ │ │ │ │ │ ├── enqueue-run.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── leasing.ts │ │ │ │ │ │ │ ├── queue.ts │ │ │ │ │ │ │ └── scheduler.ts │ │ │ │ │ │ ├── recovery/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── recovery-coordinator.ts │ │ │ │ │ │ ├── storage/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── storage-port.ts │ │ │ │ │ │ ├── transport/ │ │ │ │ │ │ │ ├── events-bus.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── rpc-server.ts │ │ │ │ │ │ │ └── rpc.ts │ │ │ │ │ │ └── triggers/ │ │ │ │ │ │ ├── command-trigger.ts │ │ │ │ │ │ ├── context-menu-trigger.ts │ │ │ │ │ │ ├── cron-trigger.ts │ │ │ │ │ │ ├── dom-trigger.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── interval-trigger.ts │ │ │ │ │ │ ├── manual-trigger.ts │ │ │ │ │ │ ├── once-trigger.ts │ │ │ │ │ │ ├── trigger-handler.ts │ │ │ │ │ │ ├── trigger-manager.ts │ │ │ │ │ │ └── url-trigger.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── storage/ │ │ │ │ │ ├── db.ts │ │ │ │ │ ├── events.ts │ │ │ │ │ ├── flows.ts │ │ │ │ │ ├── import/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── v2-reader.ts │ │ │ │ │ │ └── v2-to-v3.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── persistent-vars.ts │ │ │ │ │ ├── queue.ts │ │ │ │ │ ├── runs.ts │ │ │ │ │ └── triggers.ts │ │ │ │ ├── semantic-similarity.ts │ │ │ │ ├── storage-manager.ts │ │ │ │ ├── tools/ │ │ │ │ │ ├── base-browser.ts │ │ │ │ │ ├── browser/ │ │ │ │ │ │ ├── bookmark.ts │ │ │ │ │ │ ├── common.ts │ │ │ │ │ │ ├── computer.ts │ │ │ │ │ │ ├── console-buffer.ts │ │ │ │ │ │ ├── console.ts │ │ │ │ │ │ ├── dialog.ts │ │ │ │ │ │ ├── download.ts │ │ │ │ │ │ ├── element-picker.ts │ │ │ │ │ │ ├── file-upload.ts │ │ │ │ │ │ ├── gif-auto-capture.ts │ │ │ │ │ │ ├── gif-enhanced-renderer.ts │ │ │ │ │ │ ├── gif-recorder.ts │ │ │ │ │ │ ├── history.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── inject-script.ts │ │ │ │ │ │ ├── interaction.ts │ │ │ │ │ │ ├── javascript.ts │ │ │ │ │ │ ├── keyboard.ts │ │ │ │ │ │ ├── network-capture-debugger.ts │ │ │ │ │ │ ├── network-capture-web-request.ts │ │ │ │ │ │ ├── network-capture.ts │ │ │ │ │ │ ├── network-request.ts │ │ │ │ │ │ ├── performance.ts │ │ │ │ │ │ ├── read-page.ts │ │ │ │ │ │ ├── screenshot.ts │ │ │ │ │ │ ├── userscript.ts │ │ │ │ │ │ ├── vector-search.ts │ │ │ │ │ │ ├── web-fetcher.ts │ │ │ │ │ │ └── window.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── record-replay.ts │ │ │ │ ├── utils/ │ │ │ │ │ └── sidepanel.ts │ │ │ │ └── web-editor/ │ │ │ │ └── index.ts │ │ │ ├── builder/ │ │ │ │ ├── App.vue │ │ │ │ ├── index.html │ │ │ │ └── main.ts │ │ │ ├── content.ts │ │ │ ├── element-picker.content.ts │ │ │ ├── offscreen/ │ │ │ │ ├── gif-encoder.ts │ │ │ │ ├── index.html │ │ │ │ ├── main.ts │ │ │ │ └── rr-keepalive.ts │ │ │ ├── options/ │ │ │ │ ├── App.vue │ │ │ │ ├── index.html │ │ │ │ └── main.ts │ │ │ ├── popup/ │ │ │ │ ├── App.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── ConfirmDialog.vue │ │ │ │ │ ├── ElementMarkerManagement.vue │ │ │ │ │ ├── LocalModelPage.vue │ │ │ │ │ ├── ModelCacheManagement.vue │ │ │ │ │ ├── ProgressIndicator.vue │ │ │ │ │ ├── ScheduleDialog.vue │ │ │ │ │ ├── builder/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── Canvas.vue │ │ │ │ │ │ │ ├── EdgePropertyPanel.vue │ │ │ │ │ │ │ ├── KeyValueEditor.vue │ │ │ │ │ │ │ ├── PropertyPanel.vue │ │ │ │ │ │ │ ├── Sidebar.vue │ │ │ │ │ │ │ ├── TriggerPanel.vue │ │ │ │ │ │ │ ├── nodes/ │ │ │ │ │ │ │ │ ├── NodeCard.vue │ │ │ │ │ │ │ │ ├── NodeIf.vue │ │ │ │ │ │ │ │ └── node-util.ts │ │ │ │ │ │ │ └── properties/ │ │ │ │ │ │ │ ├── PropertyAssert.vue │ │ │ │ │ │ │ ├── PropertyClick.vue │ │ │ │ │ │ │ ├── PropertyCloseTab.vue │ │ │ │ │ │ │ ├── PropertyDelay.vue │ │ │ │ │ │ │ ├── PropertyDrag.vue │ │ │ │ │ │ │ ├── PropertyExecuteFlow.vue │ │ │ │ │ │ │ ├── PropertyExtract.vue │ │ │ │ │ │ │ ├── PropertyFill.vue │ │ │ │ │ │ │ ├── PropertyForeach.vue │ │ │ │ │ │ │ ├── PropertyFormRenderer.vue │ │ │ │ │ │ │ ├── PropertyFromSpec.vue │ │ │ │ │ │ │ ├── PropertyHandleDownload.vue │ │ │ │ │ │ │ ├── PropertyHttp.vue │ │ │ │ │ │ │ ├── PropertyIf.vue │ │ │ │ │ │ │ ├── PropertyKey.vue │ │ │ │ │ │ │ ├── PropertyLoopElements.vue │ │ │ │ │ │ │ ├── PropertyNavigate.vue │ │ │ │ │ │ │ ├── PropertyOpenTab.vue │ │ │ │ │ │ │ ├── PropertyScreenshot.vue │ │ │ │ │ │ │ ├── PropertyScript.vue │ │ │ │ │ │ │ ├── PropertyScroll.vue │ │ │ │ │ │ │ ├── PropertySetAttribute.vue │ │ │ │ │ │ │ ├── PropertySwitchFrame.vue │ │ │ │ │ │ │ ├── PropertySwitchTab.vue │ │ │ │ │ │ │ ├── PropertyTrigger.vue │ │ │ │ │ │ │ ├── PropertyTriggerEvent.vue │ │ │ │ │ │ │ ├── PropertyWait.vue │ │ │ │ │ │ │ ├── PropertyWhile.vue │ │ │ │ │ │ │ └── SelectorEditor.vue │ │ │ │ │ │ ├── model/ │ │ │ │ │ │ │ ├── form-widget-registry.ts │ │ │ │ │ │ │ ├── node-spec-registry.ts │ │ │ │ │ │ │ ├── node-spec.ts │ │ │ │ │ │ │ ├── node-specs-builtin.ts │ │ │ │ │ │ │ ├── toast.ts │ │ │ │ │ │ │ ├── transforms.ts │ │ │ │ │ │ │ ├── ui-nodes.ts │ │ │ │ │ │ │ ├── validation.ts │ │ │ │ │ │ │ └── variables.ts │ │ │ │ │ │ ├── store/ │ │ │ │ │ │ │ └── useBuilderStore.ts │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ ├── FieldCode.vue │ │ │ │ │ │ ├── FieldDuration.vue │ │ │ │ │ │ ├── FieldExpression.vue │ │ │ │ │ │ ├── FieldKeySequence.vue │ │ │ │ │ │ ├── FieldSelector.vue │ │ │ │ │ │ ├── FieldTargetLocator.vue │ │ │ │ │ │ └── VarInput.vue │ │ │ │ │ └── icons/ │ │ │ │ │ ├── BoltIcon.vue │ │ │ │ │ ├── CheckIcon.vue │ │ │ │ │ ├── DatabaseIcon.vue │ │ │ │ │ ├── DocumentIcon.vue │ │ │ │ │ ├── EditIcon.vue │ │ │ │ │ ├── MarkerIcon.vue │ │ │ │ │ ├── RecordIcon.vue │ │ │ │ │ ├── RefreshIcon.vue │ │ │ │ │ ├── StopIcon.vue │ │ │ │ │ ├── TabIcon.vue │ │ │ │ │ ├── TrashIcon.vue │ │ │ │ │ ├── VectorIcon.vue │ │ │ │ │ ├── WorkflowIcon.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── index.html │ │ │ │ ├── main.ts │ │ │ │ └── style.css │ │ │ ├── quick-panel.content.ts │ │ │ ├── shared/ │ │ │ │ ├── composables/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useRRV3Rpc.ts │ │ │ │ └── utils/ │ │ │ │ ├── index.ts │ │ │ │ └── rr-flow-convert.ts │ │ │ ├── sidepanel/ │ │ │ │ ├── App.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── AgentChat.vue │ │ │ │ │ ├── SidepanelNavigator.vue │ │ │ │ │ ├── agent/ │ │ │ │ │ │ ├── AttachmentPreview.vue │ │ │ │ │ │ ├── ChatInput.vue │ │ │ │ │ │ ├── CliSettings.vue │ │ │ │ │ │ ├── ConnectionStatus.vue │ │ │ │ │ │ ├── MessageItem.vue │ │ │ │ │ │ ├── MessageList.vue │ │ │ │ │ │ ├── ProjectCreateForm.vue │ │ │ │ │ │ ├── ProjectSelector.vue │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── agent-chat/ │ │ │ │ │ │ ├── AgentChatShell.vue │ │ │ │ │ │ ├── AgentComposer.vue │ │ │ │ │ │ ├── AgentConversation.vue │ │ │ │ │ │ ├── AgentOpenProjectMenu.vue │ │ │ │ │ │ ├── AgentProjectMenu.vue │ │ │ │ │ │ ├── AgentRequestThread.vue │ │ │ │ │ │ ├── AgentSessionListItem.vue │ │ │ │ │ │ ├── AgentSessionMenu.vue │ │ │ │ │ │ ├── AgentSessionSettingsPanel.vue │ │ │ │ │ │ ├── AgentSessionsView.vue │ │ │ │ │ │ ├── AgentSettingsMenu.vue │ │ │ │ │ │ ├── AgentTimeline.vue │ │ │ │ │ │ ├── AgentTimelineItem.vue │ │ │ │ │ │ ├── AgentTopBar.vue │ │ │ │ │ │ ├── ApplyMessageChip.vue │ │ │ │ │ │ ├── AttachmentCachePanel.vue │ │ │ │ │ │ ├── ComposerDrawer.vue │ │ │ │ │ │ ├── ElementChip.vue │ │ │ │ │ │ ├── FakeCaretOverlay.vue │ │ │ │ │ │ ├── SelectionChip.vue │ │ │ │ │ │ ├── WebEditorChanges.vue │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── timeline/ │ │ │ │ │ │ ├── ThinkingNode.vue │ │ │ │ │ │ ├── TimelineNarrativeStep.vue │ │ │ │ │ │ ├── TimelineStatusStep.vue │ │ │ │ │ │ ├── TimelineToolCallStep.vue │ │ │ │ │ │ ├── TimelineToolResultCardStep.vue │ │ │ │ │ │ ├── TimelineUserPromptStep.vue │ │ │ │ │ │ └── markstream-thinking.ts │ │ │ │ │ ├── rr-v3/ │ │ │ │ │ │ └── DebuggerPanel.vue │ │ │ │ │ └── workflows/ │ │ │ │ │ ├── WorkflowListItem.vue │ │ │ │ │ ├── WorkflowsView.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── composables/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useAgentChat.ts │ │ │ │ │ ├── useAgentChatViewRoute.ts │ │ │ │ │ ├── useAgentInputPreferences.ts │ │ │ │ │ ├── useAgentProjects.ts │ │ │ │ │ ├── useAgentServer.ts │ │ │ │ │ ├── useAgentSessions.ts │ │ │ │ │ ├── useAgentTheme.ts │ │ │ │ │ ├── useAgentThreads.ts │ │ │ │ │ ├── useAttachments.ts │ │ │ │ │ ├── useFakeCaret.ts │ │ │ │ │ ├── useFloatingDrag.ts │ │ │ │ │ ├── useOpenProjectPreference.ts │ │ │ │ │ ├── useRRV3Debugger.ts │ │ │ │ │ ├── useRRV3Rpc.ts │ │ │ │ │ ├── useTextareaAutoResize.ts │ │ │ │ │ ├── useWebEditorTxState.ts │ │ │ │ │ └── useWorkflowsV3.ts │ │ │ │ ├── index.html │ │ │ │ ├── main.ts │ │ │ │ ├── styles/ │ │ │ │ │ └── agent-chat.css │ │ │ │ └── utils/ │ │ │ │ └── loading-texts.ts │ │ │ ├── styles/ │ │ │ │ └── tailwind.css │ │ │ ├── web-editor-v2/ │ │ │ │ ├── attr-ui-refactor.md │ │ │ │ ├── constants.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── css-compare.ts │ │ │ │ │ ├── cssom-styles-collector.ts │ │ │ │ │ ├── debug-source.ts │ │ │ │ │ ├── design-tokens/ │ │ │ │ │ │ ├── design-tokens-service.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── token-detector.ts │ │ │ │ │ │ ├── token-resolver.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── editor.ts │ │ │ │ │ ├── element-key.ts │ │ │ │ │ ├── event-controller.ts │ │ │ │ │ ├── execution-tracker.ts │ │ │ │ │ ├── hmr-consistency.ts │ │ │ │ │ ├── locator.ts │ │ │ │ │ ├── message-listener.ts │ │ │ │ │ ├── payload-builder.ts │ │ │ │ │ ├── perf-monitor.ts │ │ │ │ │ ├── position-tracker.ts │ │ │ │ │ ├── props-bridge.ts │ │ │ │ │ ├── snap-engine.ts │ │ │ │ │ ├── transaction-aggregator.ts │ │ │ │ │ └── transaction-manager.ts │ │ │ │ ├── drag/ │ │ │ │ │ └── drag-reorder-controller.ts │ │ │ │ ├── overlay/ │ │ │ │ │ ├── canvas-overlay.ts │ │ │ │ │ └── handles-controller.ts │ │ │ │ ├── selection/ │ │ │ │ │ └── selection-engine.ts │ │ │ │ ├── ui/ │ │ │ │ │ ├── breadcrumbs.ts │ │ │ │ │ ├── floating-drag.ts │ │ │ │ │ ├── icons.ts │ │ │ │ │ ├── property-panel/ │ │ │ │ │ │ ├── class-editor.ts │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── alignment-grid.ts │ │ │ │ │ │ │ ├── icon-button-group.ts │ │ │ │ │ │ │ ├── input-container.ts │ │ │ │ │ │ │ ├── slider-input.ts │ │ │ │ │ │ │ └── token-pill.ts │ │ │ │ │ │ ├── components-tree.ts │ │ │ │ │ │ ├── controls/ │ │ │ │ │ │ │ ├── appearance-control.ts │ │ │ │ │ │ │ ├── background-control.ts │ │ │ │ │ │ │ ├── border-control.ts │ │ │ │ │ │ │ ├── color-field.ts │ │ │ │ │ │ │ ├── css-helpers.ts │ │ │ │ │ │ │ ├── effects-control.ts │ │ │ │ │ │ │ ├── gradient-control.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── layout-control.ts │ │ │ │ │ │ │ ├── number-stepping.ts │ │ │ │ │ │ │ ├── position-control.ts │ │ │ │ │ │ │ ├── size-control.ts │ │ │ │ │ │ │ ├── spacing-control.ts │ │ │ │ │ │ │ ├── token-picker.ts │ │ │ │ │ │ │ └── typography-control.ts │ │ │ │ │ │ ├── css-defaults.ts │ │ │ │ │ │ ├── css-panel.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── property-panel.ts │ │ │ │ │ │ ├── props-panel.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── shadow-host.ts │ │ │ │ │ └── toolbar.ts │ │ │ │ └── utils/ │ │ │ │ └── disposables.ts │ │ │ ├── web-editor-v2.ts │ │ │ └── welcome/ │ │ │ ├── App.vue │ │ │ ├── index.html │ │ │ └── main.ts │ │ ├── env.d.ts │ │ ├── eslint.config.js │ │ ├── inject-scripts/ │ │ │ ├── accessibility-tree-helper.js │ │ │ ├── click-helper.js │ │ │ ├── dom-observer.js │ │ │ ├── element-marker.js │ │ │ ├── element-picker.js │ │ │ ├── fill-helper.js │ │ │ ├── inject-bridge.js │ │ │ ├── interactive-elements-helper.js │ │ │ ├── keyboard-helper.js │ │ │ ├── network-helper.js │ │ │ ├── props-agent.js │ │ │ ├── recorder.js │ │ │ ├── screenshot-helper.js │ │ │ ├── wait-helper.js │ │ │ ├── web-editor.js │ │ │ └── web-fetcher-helper.js │ │ ├── package.json │ │ ├── shared/ │ │ │ ├── element-picker/ │ │ │ │ ├── controller.ts │ │ │ │ └── index.ts │ │ │ ├── quick-panel/ │ │ │ │ ├── core/ │ │ │ │ │ ├── agent-bridge.ts │ │ │ │ │ ├── search-engine.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tabs-provider.ts │ │ │ │ └── ui/ │ │ │ │ ├── ai-chat-panel.ts │ │ │ │ ├── index.ts │ │ │ │ ├── markdown-renderer.ts │ │ │ │ ├── message-renderer.ts │ │ │ │ ├── panel-shell.ts │ │ │ │ ├── quick-entries.ts │ │ │ │ ├── search-input.ts │ │ │ │ ├── shadow-host.ts │ │ │ │ └── styles.ts │ │ │ └── selector/ │ │ │ ├── dom-path.ts │ │ │ ├── fingerprint.ts │ │ │ ├── generator.ts │ │ │ ├── index.ts │ │ │ ├── locator.ts │ │ │ ├── shadow-dom.ts │ │ │ ├── stability.ts │ │ │ ├── strategies/ │ │ │ │ ├── anchor-relpath.ts │ │ │ │ ├── aria.ts │ │ │ │ ├── css-path.ts │ │ │ │ ├── css-unique.ts │ │ │ │ ├── index.ts │ │ │ │ ├── testid.ts │ │ │ │ └── text.ts │ │ │ └── types.ts │ │ ├── tailwind.config.ts │ │ ├── tests/ │ │ │ ├── __mocks__/ │ │ │ │ └── hnswlib-wasm-static.ts │ │ │ ├── record-replay/ │ │ │ │ ├── _test-helpers.ts │ │ │ │ ├── adapter-policy.contract.test.ts │ │ │ │ ├── flow-store-strip-steps.contract.test.ts │ │ │ │ ├── high-risk-actions.integration.test.ts │ │ │ │ ├── hybrid-actions.integration.test.ts │ │ │ │ ├── script-control-flow.integration.test.ts │ │ │ │ ├── session-dag-sync.contract.test.ts │ │ │ │ ├── step-executor.contract.test.ts │ │ │ │ └── tab-cursor.integration.test.ts │ │ │ ├── record-replay-v3/ │ │ │ │ ├── command-trigger.test.ts │ │ │ │ ├── context-menu-trigger.test.ts │ │ │ │ ├── cron-trigger.test.ts │ │ │ │ ├── debugger.contract.test.ts │ │ │ │ ├── dom-trigger.test.ts │ │ │ │ ├── e2e.integration.test.ts │ │ │ │ ├── events.contract.test.ts │ │ │ │ ├── interval-trigger.test.ts │ │ │ │ ├── manual-trigger.test.ts │ │ │ │ ├── once-trigger.test.ts │ │ │ │ ├── queue.contract.test.ts │ │ │ │ ├── recovery.test.ts │ │ │ │ ├── rpc-api.test.ts │ │ │ │ ├── runner.onError.contract.test.ts │ │ │ │ ├── scheduler-integration.test.ts │ │ │ │ ├── scheduler.test.ts │ │ │ │ ├── spec-smoke.test.ts │ │ │ │ ├── trigger-manager.test.ts │ │ │ │ ├── triggers.test.ts │ │ │ │ ├── url-trigger.test.ts │ │ │ │ ├── v2-action-adapter.test.ts │ │ │ │ ├── v2-adapter-integration.test.ts │ │ │ │ ├── v2-to-v3-conversion.test.ts │ │ │ │ └── v3-e2e-harness.ts │ │ │ ├── vitest.setup.ts │ │ │ └── web-editor-v2/ │ │ │ ├── design-tokens.test.ts │ │ │ ├── drag-reorder-controller.test.ts │ │ │ ├── event-controller.test.ts │ │ │ ├── locator.test.ts │ │ │ ├── property-panel-live-sync.test.ts │ │ │ ├── selection-engine.test.ts │ │ │ ├── snap-engine.test.ts │ │ │ └── test-utils/ │ │ │ └── dom.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── gifenc.d.ts │ │ │ └── icons.d.ts │ │ ├── utils/ │ │ │ ├── cdp-session-manager.ts │ │ │ ├── content-indexer.ts │ │ │ ├── i18n.ts │ │ │ ├── image-utils.ts │ │ │ ├── indexeddb-client.ts │ │ │ ├── lru-cache.ts │ │ │ ├── model-cache-manager.ts │ │ │ ├── offscreen-manager.ts │ │ │ ├── output-sanitizer.ts │ │ │ ├── screenshot-context.ts │ │ │ ├── semantic-similarity-engine.ts │ │ │ ├── simd-math-engine.ts │ │ │ ├── text-chunker.ts │ │ │ └── vector-database.ts │ │ ├── vitest.config.ts │ │ ├── workers/ │ │ │ ├── ort-wasm-simd-threaded.jsep.mjs │ │ │ ├── ort-wasm-simd-threaded.jsep.wasm │ │ │ ├── ort-wasm-simd-threaded.mjs │ │ │ ├── ort-wasm-simd-threaded.wasm │ │ │ ├── simd_math.js │ │ │ ├── simd_math_bg.wasm │ │ │ └── similarity.worker.js │ │ └── wxt.config.ts │ └── native-server/ │ ├── .npmignore │ ├── README.md │ ├── debug.sh │ ├── install.md │ ├── jest.config.js │ ├── package.json │ ├── src/ │ │ ├── agent/ │ │ │ ├── attachment-service.ts │ │ │ ├── ccr-detector.ts │ │ │ ├── chat-service.ts │ │ │ ├── db/ │ │ │ │ ├── client.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── directory-picker.ts │ │ │ ├── engines/ │ │ │ │ ├── claude.ts │ │ │ │ ├── codex.ts │ │ │ │ └── types.ts │ │ │ ├── message-service.ts │ │ │ ├── open-project.ts │ │ │ ├── project-service.ts │ │ │ ├── project-types.ts │ │ │ ├── session-service.ts │ │ │ ├── storage.ts │ │ │ ├── stream-manager.ts │ │ │ ├── tool-bridge.ts │ │ │ └── types.ts │ │ ├── cli.ts │ │ ├── constant/ │ │ │ └── index.ts │ │ ├── file-handler.ts │ │ ├── index.ts │ │ ├── mcp/ │ │ │ ├── mcp-server-stdio.ts │ │ │ ├── mcp-server.ts │ │ │ ├── register-tools.ts │ │ │ └── stdio-config.json │ │ ├── native-messaging-host.ts │ │ ├── scripts/ │ │ │ ├── browser-config.ts │ │ │ ├── build.ts │ │ │ ├── constant.ts │ │ │ ├── doctor.ts │ │ │ ├── postinstall.ts │ │ │ ├── register-dev.ts │ │ │ ├── register.ts │ │ │ ├── report.ts │ │ │ ├── run_host.bat │ │ │ ├── run_host.sh │ │ │ └── utils.ts │ │ ├── server/ │ │ │ ├── index.ts │ │ │ ├── routes/ │ │ │ │ ├── agent.ts │ │ │ │ └── index.ts │ │ │ └── server.test.ts │ │ ├── shims/ │ │ │ └── devtools.d.ts │ │ ├── trace-analyzer.ts │ │ ├── types/ │ │ │ └── devtools-frontend.d.ts │ │ └── util/ │ │ └── logger.ts │ └── tsconfig.json ├── commitlint.config.cjs ├── docs/ │ ├── ARCHITECTURE.md │ ├── ARCHITECTURE_zh.md │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── CONTRIBUTING_zh.md │ ├── ISSUE.md │ ├── TOOLS.md │ ├── TOOLS_zh.md │ ├── TROUBLESHOOTING.md │ ├── TROUBLESHOOTING_zh.md │ ├── VisualEditor.md │ ├── VisualEditor_zh.md │ ├── WINDOWS_INSTALL_zh.md │ └── mcp-cli-config.md ├── eslint.config.js ├── package.json ├── packages/ │ ├── shared/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── agent-types.ts │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── labels.ts │ │ │ ├── node-spec-registry.ts │ │ │ ├── node-spec.ts │ │ │ ├── node-specs-builtin.ts │ │ │ ├── rr-graph.ts │ │ │ ├── step-types.ts │ │ │ ├── tools.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ └── wasm-simd/ │ ├── .gitignore │ ├── BUILD.md │ ├── Cargo.toml │ ├── README.md │ ├── package.json │ └── src/ │ └── lib.rs ├── pnpm-workspace.yaml ├── prompt/ │ ├── content-analize.md │ ├── excalidraw-prompt.md │ └── modify-web.md └── releases/ └── README.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ *.onnx filter=lfs diff=lfs merge=lfs -text ================================================ FILE: .github/workflows/build-release.yml ================================================ # 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 ================================================ FILE: .gitignore ================================================ # 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/ ================================================ FILE: .husky/commit-msg ================================================ npx --no -- commitlint --edit "$1" ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .prettierignore ================================================ # 构建输出目录 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 ================================================ FILE: .prettierrc.json ================================================ { "semi": true, "singleQuote": true, "tabWidth": 2, "printWidth": 100, "endOfLine": "auto", "proseWrap": "preserve", "htmlWhitespaceSensitivity": "strict" } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["Vue.volar"] } ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================ # 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 ================================================ FILE: README_zh.md ================================================ # 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) ================================================ FILE: app/chrome-extension/LICENSE ================================================ 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. ================================================ FILE: app/chrome-extension/README.md ================================================ # 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). ================================================ FILE: app/chrome-extension/_locales/de/messages.json ================================================ { "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" } } ================================================ FILE: app/chrome-extension/_locales/en/messages.json ================================================ { "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" } } ================================================ FILE: app/chrome-extension/_locales/ja/messages.json ================================================ { "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": "ページ" } } ================================================ FILE: app/chrome-extension/_locales/ko/messages.json ================================================ { "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": "페이지 수 단위" } } ================================================ FILE: app/chrome-extension/_locales/zh_CN/messages.json ================================================ { "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": "域名筛选占位符" } } ================================================ FILE: app/chrome-extension/_locales/zh_TW/messages.json ================================================ { "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": "頁面計數單位" } } ================================================ FILE: app/chrome-extension/common/agent-models.ts ================================================ /** * 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; } ================================================ FILE: app/chrome-extension/common/constants.ts ================================================ /** * 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', } ================================================ FILE: app/chrome-extension/common/element-marker-types.ts ================================================ // 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 } ================================================ FILE: app/chrome-extension/common/message-types.ts ================================================ /** * 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; } ================================================ FILE: app/chrome-extension/common/node-types.ts ================================================ // 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]; ================================================ FILE: app/chrome-extension/common/rr-v3-keepalive-protocol.ts ================================================ /** * @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; ================================================ FILE: app/chrome-extension/common/step-types.ts ================================================ // 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']]; ================================================ FILE: app/chrome-extension/common/tool-handler.ts ================================================ 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, }; }; ================================================ FILE: app/chrome-extension/common/web-editor-types.ts ================================================ /** * 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; } } ================================================ FILE: app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts ================================================ // 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); } ================================================ FILE: app/chrome-extension/entrypoints/background/element-marker/index.ts ================================================ 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); } }); } } ================================================ FILE: app/chrome-extension/entrypoints/background/index.ts ================================================ 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); }); }); ================================================ FILE: app/chrome-extension/entrypoints/background/keepalive-manager.ts ================================================ /** * @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; } } ================================================ FILE: app/chrome-extension/entrypoints/background/native-host.ts ================================================ 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; } }); }; ================================================ FILE: app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts ================================================ /** * 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`); } ================================================ FILE: app/chrome-extension/entrypoints/background/quick-panel/commands.ts ================================================ /** * 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}`); } ================================================ FILE: app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts ================================================ /** * 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`); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts ================================================ /** * 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]; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts ================================================ /** * 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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts ================================================ /** * 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); }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts ================================================ /** * 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 } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts ================================================ /** * 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)}`, ); } }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts ================================================ /** * 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 }, }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts ================================================ /** * 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)}`, ); } }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts ================================================ /** * 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); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts ================================================ /** * 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 } }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts ================================================ /** * 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 }, }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts ================================================ /** * 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' }; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts ================================================ /** * 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(); }); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts ================================================ /** * 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}`, ); }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/index.ts ================================================ /** * 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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts ================================================ /** * 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(); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/actions/types.ts ================================================ /** * 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', ); ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts ================================================ // 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]; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts ================================================ /** * 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, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/logging/run-logger.ts ================================================ // 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); } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/plugins/breakpoint.ts ================================================ 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; }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/plugins/manager.ts ================================================ 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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/plugins/types.ts ================================================ // 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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/policies/retry.ts ================================================ // 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; } } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts ================================================ // 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 }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts ================================================ // 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 }); } } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/runners/control-flow-runner.ts ================================================ // 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'; } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts ================================================ /** * 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}`); } } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts ================================================ /** * 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 }; } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts ================================================ // 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)}`, }); } } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts ================================================ 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(); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/state-manager.ts ================================================ // 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'); ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts ================================================ // 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; } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/flow-runner.ts ================================================ // thin re-export for backward compatibility export { runFlow } from './engine/scheduler'; export type { RunOptions } from './engine/scheduler'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/flow-store.ts ================================================ 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); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/index.ts ================================================ 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 } }); ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts ================================================ /** * 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; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/click.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/conditional.ts ================================================ 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; } }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/download-screenshot-attr-event-frame-loop.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/drag.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/extract.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/fill.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/http.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/index.ts ================================================ 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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/key.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/navigate.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/script.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/scroll.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/tabs.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts ================================================ 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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts ================================================ 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; }, }; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts ================================================ 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); } }); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts ================================================ 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 } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts ================================================ 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; } }); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts ================================================ 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 } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts ================================================ // 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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts ================================================ 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. ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts ================================================ // 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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts ================================================ 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)}`; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay/types.ts ================================================ /** * 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) } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts ================================================ /** * @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', }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts ================================================ /** * @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 }), }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts ================================================ /** * @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'; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts ================================================ /** * @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); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts ================================================ /** * @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]; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts ================================================ /** * @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; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts ================================================ /** * @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, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts ================================================ /** * @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, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts ================================================ /** * @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, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts ================================================ /** * @fileoverview Keepalive 模块导出入口 */ export * from './offscreen-keepalive'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts ================================================ /** * @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); } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts ================================================ /** * @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 }; }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts ================================================ /** * @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(), }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts ================================================ /** * @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); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts ================================================ /** * @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 }; } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts ================================================ /** * @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); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts ================================================ /** * @fileoverview 插件系统导出入口 */ export * from './types'; export * from './registry'; export * from './v2-action-adapter'; export * from './register-v2-replay-nodes'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts ================================================ /** * @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; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts ================================================ /** * @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 } : {}), }; }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts ================================================ /** * @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 }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts ================================================ /** * @fileoverview Queue 模块导出入口 */ export * from './queue'; export * from './leasing'; export * from './scheduler'; export * from './enqueue-run'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts ================================================ /** * @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)}`; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts ================================================ /** * @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(), }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts ================================================ /** * @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 }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts ================================================ /** * @fileoverview Recovery module exports * @description 崩溃恢复模块导出 */ export * from './recovery-coordinator'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts ================================================ /** * @fileoverview Engine Storage 模块导出入口 */ export * from './storage-port'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts ================================================ /** * @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'), }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts ================================================ /** * @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; } } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/index.ts ================================================ /** * @fileoverview Transport 模块导出入口 */ export * from './rpc'; export * from './rpc-server'; export * from './events-bus'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts ================================================ /** * @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; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc.ts ================================================ /** * @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, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler.ts ================================================ /** * @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; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager.ts ================================================ /** * @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 }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts ================================================ /** * @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()); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/index.ts ================================================ /** * @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; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts ================================================ /** * @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); }); }); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts ================================================ /** * @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); }); }); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts ================================================ /** * @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); }); }); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/index.ts ================================================ /** * @fileoverview Import 模块导出入口 */ export * from './v2-reader'; export * from './v2-to-v3'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-reader.ts ================================================ /** * @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, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts ================================================ /** * @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(); } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/persistent-vars.ts ================================================ /** * @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); }); }); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/queue.ts ================================================ /** * @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); }); }); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts ================================================ /** * @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); }); }); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/record-replay-v3/storage/triggers.ts ================================================ /** * @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); }); }); }, }; } ================================================ FILE: app/chrome-extension/entrypoints/background/semantic-similarity.ts ================================================ 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; } }); }; ================================================ FILE: app/chrome-extension/entrypoints/background/storage-manager.ts ================================================ 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; } }); }; ================================================ FILE: app/chrome-extension/entrypoints/background/tools/base-browser.ts ================================================ 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; } } ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/bookmark.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/common.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/computer.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/console-buffer.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/console.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/dialog.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/download.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts ================================================ /** * 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/gif-auto-capture.ts ================================================ /** * 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); } } } ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/gif-enhanced-renderer.ts ================================================ /** * 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); } ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts ================================================ /** * 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'; ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/history.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/index.ts ================================================ 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'; ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts ================================================ 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); } }); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/interaction.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/javascript.ts ================================================ /** * 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/keyboard.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/network-request.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/performance.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/read-page.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/userscript.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts ================================================ /** * 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/browser/window.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/tools/index.ts ================================================ 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, ); } }; ================================================ FILE: app/chrome-extension/entrypoints/background/tools/record-replay.ts ================================================ 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(); ================================================ FILE: app/chrome-extension/entrypoints/background/utils/sidepanel.ts ================================================ /** * 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 } } ================================================ FILE: app/chrome-extension/entrypoints/background/web-editor/index.ts ================================================ 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; }); } ================================================ FILE: app/chrome-extension/entrypoints/builder/App.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/builder/index.html ================================================ 工作流编辑器
================================================ FILE: app/chrome-extension/entrypoints/builder/main.ts ================================================ import { createApp } from 'vue'; import App from './App.vue'; // Tailwind first, then custom tokens import '../styles/tailwind.css'; createApp(App).mount('#app'); ================================================ FILE: app/chrome-extension/entrypoints/content.ts ================================================ export default defineContentScript({ matches: ['*://*.google.com/*'], main() {}, }); ================================================ FILE: app/chrome-extension/entrypoints/element-picker.content.ts ================================================ /** * 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; }); }, }); ================================================ FILE: app/chrome-extension/entrypoints/offscreen/gif-encoder.ts ================================================ /** * 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'); ================================================ FILE: app/chrome-extension/entrypoints/offscreen/index.html ================================================ ================================================ FILE: app/chrome-extension/entrypoints/offscreen/main.ts ================================================ 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'); ================================================ FILE: app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/options/App.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/Canvas.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/KeyValueEditor.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/PropertyPanel.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/Sidebar.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue ================================================ /** * @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 */ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeCard.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts ================================================ // 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; } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyClick.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFill.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFormRenderer.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyKey.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScript.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWait.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/form-widget-registry.ts ================================================ // 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; } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/node-spec-registry.ts ================================================ export * from 'chrome-mcp-shared'; ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/node-spec.ts ================================================ export * from 'chrome-mcp-shared'; ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/node-specs-builtin.ts ================================================ export * from 'chrome-mcp-shared'; ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/toast.ts ================================================ // 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); } } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts ================================================ 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)); } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/ui-nodes.ts ================================================ // 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 }; } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/validation.ts ================================================ 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 }; } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/model/variables.ts ================================================ // 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 = '{}'; ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/store/useBuilderStore.ts ================================================ 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, }; } ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldCode.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldDuration.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldExpression.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/BoltIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/CheckIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/DatabaseIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/DocumentIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/EditIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/MarkerIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/RecordIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/RefreshIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/StopIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/TabIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/TrashIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/VectorIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/WorkflowIcon.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/popup/components/icons/index.ts ================================================ 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'; ================================================ FILE: app/chrome-extension/entrypoints/popup/index.html ================================================ Default Popup Title
================================================ FILE: app/chrome-extension/entrypoints/popup/main.ts ================================================ 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'); }); ================================================ FILE: app/chrome-extension/entrypoints/popup/style.css ================================================ /* 现代化全局样式 */ :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; } } ================================================ FILE: app/chrome-extension/entrypoints/quick-panel.content.ts ================================================ /** * 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; } }); }, }); ================================================ FILE: app/chrome-extension/entrypoints/shared/composables/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts ================================================ /** * @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, }; } ================================================ FILE: app/chrome-extension/entrypoints/shared/utils/index.ts ================================================ /** * @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'; ================================================ FILE: app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts ================================================ /** * @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 []; } ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/App.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/AgentChat.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/SidepanelNavigator.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/AttachmentPreview.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/ChatInput.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/ConnectionStatus.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/MessageItem.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/MessageList.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectCreateForm.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectSelector.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent/index.ts ================================================ /** * 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'; ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue ================================================ ================================================ FILE: app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentComposer.vue ================================================