This is page 6 of 60. Use http://codebase.md/hangwin/mcp-chrome?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ └── workflows
│ └── build-release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── app
│ ├── chrome-extension
│ │ ├── _locales
│ │ │ ├── de
│ │ │ │ └── messages.json
│ │ │ ├── en
│ │ │ │ └── messages.json
│ │ │ ├── ja
│ │ │ │ └── messages.json
│ │ │ ├── ko
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN
│ │ │ │ └── messages.json
│ │ │ └── zh_TW
│ │ │ └── messages.json
│ │ ├── .env.example
│ │ ├── assets
│ │ │ └── vue.svg
│ │ ├── 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
│ │ │ │ │ ├── builder
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── Canvas.vue
│ │ │ │ │ │ │ ├── EdgePropertyPanel.vue
│ │ │ │ │ │ │ ├── KeyValueEditor.vue
│ │ │ │ │ │ │ ├── nodes
│ │ │ │ │ │ │ │ ├── node-util.ts
│ │ │ │ │ │ │ │ ├── NodeCard.vue
│ │ │ │ │ │ │ │ └── NodeIf.vue
│ │ │ │ │ │ │ ├── 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
│ │ │ │ │ │ │ ├── PropertyPanel.vue
│ │ │ │ │ │ │ ├── Sidebar.vue
│ │ │ │ │ │ │ └── TriggerPanel.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
│ │ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ │ ├── ElementMarkerManagement.vue
│ │ │ │ │ ├── icons
│ │ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ │ ├── EditIcon.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MarkerIcon.vue
│ │ │ │ │ │ ├── RecordIcon.vue
│ │ │ │ │ │ ├── RefreshIcon.vue
│ │ │ │ │ │ ├── StopIcon.vue
│ │ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ │ ├── VectorIcon.vue
│ │ │ │ │ │ └── WorkflowIcon.vue
│ │ │ │ │ ├── LocalModelPage.vue
│ │ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ │ ├── ProgressIndicator.vue
│ │ │ │ │ └── ScheduleDialog.vue
│ │ │ │ ├── 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
│ │ │ │ │ ├── agent
│ │ │ │ │ │ ├── AttachmentPreview.vue
│ │ │ │ │ │ ├── ChatInput.vue
│ │ │ │ │ │ ├── CliSettings.vue
│ │ │ │ │ │ ├── ConnectionStatus.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MessageItem.vue
│ │ │ │ │ │ ├── MessageList.vue
│ │ │ │ │ │ ├── ProjectCreateForm.vue
│ │ │ │ │ │ └── ProjectSelector.vue
│ │ │ │ │ ├── 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
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── SelectionChip.vue
│ │ │ │ │ │ ├── timeline
│ │ │ │ │ │ │ ├── markstream-thinking.ts
│ │ │ │ │ │ │ ├── ThinkingNode.vue
│ │ │ │ │ │ │ ├── TimelineNarrativeStep.vue
│ │ │ │ │ │ │ ├── TimelineStatusStep.vue
│ │ │ │ │ │ │ ├── TimelineToolCallStep.vue
│ │ │ │ │ │ │ ├── TimelineToolResultCardStep.vue
│ │ │ │ │ │ │ └── TimelineUserPromptStep.vue
│ │ │ │ │ │ └── WebEditorChanges.vue
│ │ │ │ │ ├── AgentChat.vue
│ │ │ │ │ ├── rr-v3
│ │ │ │ │ │ └── DebuggerPanel.vue
│ │ │ │ │ ├── SidepanelNavigator.vue
│ │ │ │ │ └── workflows
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── WorkflowListItem.vue
│ │ │ │ │ └── WorkflowsView.vue
│ │ │ │ ├── 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
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── icon
│ │ │ │ ├── 128.png
│ │ │ │ ├── 16.png
│ │ │ │ ├── 32.png
│ │ │ │ ├── 48.png
│ │ │ │ └── 96.png
│ │ │ ├── libs
│ │ │ │ └── ort.min.js
│ │ │ └── wxt.svg
│ │ ├── README.md
│ │ ├── 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_bg.wasm
│ │ │ ├── simd_math.js
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server
│ ├── .npmignore
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── README.md
│ ├── 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_zh.md
│ ├── ARCHITECTURE.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING_zh.md
│ ├── CONTRIBUTING.md
│ ├── ISSUE.md
│ ├── mcp-cli-config.md
│ ├── TOOLS_zh.md
│ ├── TOOLS.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── TROUBLESHOOTING.md
│ ├── VisualEditor_zh.md
│ ├── VisualEditor.md
│ └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── 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
│ ├── package.json
│ ├── README.md
│ └── src
│ └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
├── README_zh.md
├── README.md
└── releases
├── chrome-extension
│ └── latest
│ └── chrome-mcp-server-lastest.zip
└── README.md
```
# Files
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts:
--------------------------------------------------------------------------------
```typescript
1 | // subflow-runner.ts — execute a subflow (nodes/edges) using DAG traversal with branch support
2 |
3 | import { STEP_TYPES } from 'chrome-mcp-shared';
4 | import type { ExecCtx } from '../../nodes';
5 | import { RunLogger } from '../logging/run-logger';
6 | import { PluginManager } from '../plugins/manager';
7 | import { mapDagNodeToStep } from '../../rr-utils';
8 | import type { Edge, NodeBase, Step } from '../../types';
9 | import { StepRunner } from './step-runner';
10 | import { ENGINE_CONSTANTS } from '../constants';
11 |
12 | export interface SubflowEnv {
13 | runId: string;
14 | flow: any;
15 | vars: Record<string, any>;
16 | logger: RunLogger;
17 | pluginManager: PluginManager;
18 | stepRunner: StepRunner;
19 | }
20 |
21 | export class SubflowRunner {
22 | constructor(private env: SubflowEnv) {}
23 |
24 | async runSubflowById(subflowId: string, ctx: ExecCtx, pausedRef: () => boolean): Promise<void> {
25 | const sub = (this.env.flow.subflows || {})[subflowId];
26 | if (!sub || !Array.isArray(sub.nodes) || sub.nodes.length === 0) return;
27 |
28 | try {
29 | await this.env.pluginManager.subflowStart({
30 | runId: this.env.runId,
31 | flow: this.env.flow,
32 | vars: this.env.vars,
33 | subflowId,
34 | });
35 | } catch (e: any) {
36 | this.env.logger.push({
37 | stepId: `subflow:${subflowId}`,
38 | status: 'warning',
39 | message: `plugin.subflowStart error: ${e?.message || String(e)}`,
40 | });
41 | }
42 |
43 | const sNodes: NodeBase[] = sub.nodes;
44 | const sEdges: Edge[] = sub.edges || [];
45 |
46 | // Build lookup maps
47 | const id2node = new Map(sNodes.map((n) => [n.id, n] as const));
48 | const outEdges = new Map<string, Edge[]>();
49 | for (const e of sEdges) {
50 | if (!outEdges.has(e.from)) outEdges.set(e.from, []);
51 | outEdges.get(e.from)!.push(e);
52 | }
53 |
54 | // Calculate in-degrees to find root nodes
55 | const indeg = new Map<string, number>(sNodes.map((n) => [n.id, 0] as const));
56 | for (const e of sEdges) {
57 | indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
58 | }
59 |
60 | // Find start node: prefer non-trigger nodes with indeg=0
61 | const findFirstExecutableRoot = (): string | undefined => {
62 | const executableRoot = sNodes.find(
63 | (n) => (indeg.get(n.id) || 0) === 0 && n.type !== STEP_TYPES.TRIGGER,
64 | );
65 | if (executableRoot) return executableRoot.id;
66 |
67 | // If all roots are triggers, follow default edge to first executable
68 | const triggerRoot = sNodes.find((n) => (indeg.get(n.id) || 0) === 0);
69 | if (triggerRoot) {
70 | const defaultEdge = (outEdges.get(triggerRoot.id) || []).find(
71 | (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,
72 | );
73 | if (defaultEdge) return defaultEdge.to;
74 | }
75 |
76 | return sNodes[0]?.id;
77 | };
78 |
79 | let currentId: string | undefined = findFirstExecutableRoot();
80 | let guard = 0;
81 | const maxIterations = ENGINE_CONSTANTS.MAX_ITERATIONS;
82 |
83 | const ok = (s: Step) => this.env.logger.overlayAppend(`✔ ${s.type} (${s.id})`);
84 | const fail = (s: Step, e: any) =>
85 | this.env.logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${e?.message || String(e)}`);
86 |
87 | while (currentId) {
88 | if (pausedRef()) break;
89 | if (guard++ >= maxIterations) {
90 | this.env.logger.push({
91 | stepId: `subflow:${subflowId}`,
92 | status: 'warning',
93 | message: `Subflow exceeded ${maxIterations} iterations - possible cycle`,
94 | });
95 | break;
96 | }
97 |
98 | const node = id2node.get(currentId);
99 | if (!node) break;
100 |
101 | // Skip trigger nodes
102 | if (node.type === STEP_TYPES.TRIGGER) {
103 | const defaultEdge = (outEdges.get(currentId) || []).find(
104 | (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,
105 | );
106 | if (defaultEdge) {
107 | currentId = defaultEdge.to;
108 | continue;
109 | }
110 | break;
111 | }
112 |
113 | const step: Step = mapDagNodeToStep(node);
114 | const r = await this.env.stepRunner.run(ctx, step, ok, fail);
115 |
116 | if (r.status === 'paused' || pausedRef()) break;
117 |
118 | if (r.status === 'failed') {
119 | // Try to find on_error edge
120 | const errEdge = (outEdges.get(currentId) || []).find(
121 | (e) => e.label === ENGINE_CONSTANTS.EDGE_LABELS.ON_ERROR,
122 | );
123 | if (errEdge) {
124 | currentId = errEdge.to;
125 | continue;
126 | }
127 | break;
128 | }
129 |
130 | // Determine next edge by label
131 | const suggestedLabel = r.nextLabel
132 | ? String(r.nextLabel)
133 | : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT;
134 | const oes = outEdges.get(currentId) || [];
135 | const nextEdge =
136 | oes.find((e) => (e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT) === suggestedLabel) ||
137 | oes.find((e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);
138 |
139 | if (!nextEdge) {
140 | // Log warning if we expected a labeled edge but couldn't find it
141 | if (r.nextLabel && oes.length > 0) {
142 | const availableLabels = oes.map((e) => e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);
143 | this.env.logger.push({
144 | stepId: step.id,
145 | status: 'warning',
146 | message: `No edge for label '${suggestedLabel}'. Available: [${availableLabels.join(', ')}]`,
147 | });
148 | }
149 | break;
150 | }
151 | currentId = nextEdge.to;
152 | }
153 |
154 | try {
155 | await this.env.pluginManager.subflowEnd({
156 | runId: this.env.runId,
157 | flow: this.env.flow,
158 | vars: this.env.vars,
159 | subflowId,
160 | });
161 | } catch (e: any) {
162 | this.env.logger.push({
163 | stepId: `subflow:${subflowId}`,
164 | status: 'warning',
165 | message: `plugin.subflowEnd error: ${e?.message || String(e)}`,
166 | });
167 | }
168 | }
169 | }
170 |
```
--------------------------------------------------------------------------------
/docs/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [v0.0.5]
9 |
10 | ### Improved
11 |
12 | - **Image Compression**: Compress base64 images when using screenshot tool
13 | - **Interactive Elements Detection Optimization**: Enhanced interactive elements detection tool with expanded search scope, now supports finding interactive div elements
14 |
15 | ## [v0.0.4]
16 |
17 | ### Added
18 |
19 | - **STDIO Connection Support**: Added support for connecting to the MCP server via standard input/output (stdio) method
20 | - **Console Output Capture Tool**: New `chrome_console` tool for capturing browser console output
21 |
22 | ## [v0.0.3]
23 |
24 | ### Added
25 |
26 | - **Inject script tool**: For injecting content scripts into web page
27 | - **Send command to inject script tool**: For sending commands to the injected script
28 |
29 | ## [v0.0.2]
30 |
31 | ### Added
32 |
33 | - **Conditional Semantic Engine Initialization**: Smart cache-based initialization that only loads models when cached versions are available
34 | - **Enhanced Model Cache Management**: Comprehensive cache management system with automatic cleanup and size limits
35 | - **Windows Platform Compatibility**: Full support for Windows Chrome Native Messaging with registry-based manifest detection
36 | - **Cache Statistics and Manual Management**: User interface for viewing cache stats and manual cache cleanup
37 | - **Concurrent Initialization Protection**: Prevents duplicate initialization attempts across components
38 |
39 | ### Improved
40 |
41 | - **Startup Performance**: Dramatically reduced startup time when no model cache exists (from ~3s to ~0.5s)
42 | - **Memory Usage**: Optimized memory consumption through on-demand model loading
43 | - **Cache Expiration Logic**: Intelligent cache expiration (14 days) with automatic cleanup
44 | - **Error Handling**: Enhanced error handling for model initialization failures
45 | - **Component Coordination**: Simplified initialization flow between semantic engine and content indexer
46 |
47 | ### Fixed
48 |
49 | - **Windows Native Host Issues**: Resolved Node.js environment conflicts with multiple NVM installations
50 | - **Race Condition Prevention**: Eliminated concurrent initialization attempts that could cause conflicts
51 | - **Cache Size Management**: Automatic cleanup when cache exceeds 500MB limit
52 | - **Model Download Optimization**: Prevents unnecessary model downloads during plugin startup
53 |
54 | ### Technical Improvements
55 |
56 | - **ModelCacheManager**: Added `isModelCached()` and `hasAnyValidCache()` methods for cache detection
57 | - **SemanticSimilarityEngine**: Added cache checking functions and conditional initialization logic
58 | - **Background Script**: Implemented smart initialization based on cache availability
59 | - **VectorSearchTool**: Simplified to passive initialization model
60 | - **ContentIndexer**: Enhanced with semantic engine readiness checks
61 |
62 | ### Documentation
63 |
64 | - Added comprehensive conditional initialization documentation
65 | - Updated cache management system documentation
66 | - Created troubleshooting guides for Windows platform issues
67 |
68 | ## [v0.0.1]
69 |
70 | ### Added
71 |
72 | - **Core Browser Tools**: Complete set of browser automation tools for web interaction
73 |
74 | - **Click Tool**: Intelligent element clicking with coordinate and selector support
75 | - **Fill Tool**: Form filling with text input and selection capabilities
76 | - **Screenshot Tool**: Full page and element-specific screenshot capture
77 | - **Navigation Tools**: URL navigation and page interaction utilities
78 | - **Keyboard Tool**: Keyboard input simulation and hotkey support
79 |
80 | - **Vector Search Engine**: Advanced semantic search capabilities
81 |
82 | - **Content Indexing**: Automatic indexing of browser tab content
83 | - **Semantic Similarity**: AI-powered text similarity matching
84 | - **Vector Database**: Efficient storage and retrieval of embeddings
85 | - **Multi-language Support**: Comprehensive multilingual text processing
86 |
87 | - **Native Host Integration**: Seamless communication with external applications
88 |
89 | - **Chrome Native Messaging**: Bidirectional communication channel
90 | - **Cross-platform Support**: Windows, macOS, and Linux compatibility
91 | - **Message Protocol**: Structured messaging system for tool execution
92 |
93 | - **AI Model Integration**: State-of-the-art language models for semantic processing
94 |
95 | - **Transformer Models**: Support for multiple pre-trained models
96 | - **ONNX Runtime**: Optimized model inference with WebAssembly
97 | - **Model Management**: Dynamic model loading and switching
98 | - **Performance Optimization**: SIMD acceleration and memory pooling
99 |
100 | - **User Interface**: Intuitive popup interface for extension management
101 | - **Model Selection**: Easy switching between different AI models
102 | - **Status Monitoring**: Real-time initialization and download progress
103 | - **Settings Management**: User preferences and configuration options
104 | - **Cache Management**: Visual cache statistics and cleanup controls
105 |
106 | ### Technical Foundation
107 |
108 | - **Extension Architecture**: Robust Chrome extension with background scripts and content injection
109 | - **Worker-based Processing**: Offscreen document for heavy computational tasks
110 | - **Memory Management**: LRU caching and efficient resource utilization
111 | - **Error Handling**: Comprehensive error reporting and recovery mechanisms
112 | - **TypeScript Implementation**: Full type safety and modern JavaScript features
113 |
114 | ### Initial Features
115 |
116 | - Multi-tab content analysis and search
117 | - Real-time semantic similarity computation
118 | - Automated web page interaction
119 | - Cross-platform native messaging
120 | - Extensible tool framework for future enhancements
121 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview EventsBus Interface and Implementation
3 | * @description Event subscription, publishing, and persistence
4 | */
5 |
6 | import type { RunId } from '../../domain/ids';
7 | import type { RunEvent, RunEventInput, Unsubscribe } from '../../domain/events';
8 | import type { EventsStore } from '../storage/storage-port';
9 |
10 | /**
11 | * Event query parameters
12 | */
13 | export interface EventsQuery {
14 | /** Run ID */
15 | runId: RunId;
16 | /** Starting sequence number (inclusive) */
17 | fromSeq?: number;
18 | /** Maximum number of results */
19 | limit?: number;
20 | }
21 |
22 | /**
23 | * Subscription filter
24 | */
25 | export interface EventsFilter {
26 | /** Only receive events for this Run */
27 | runId?: RunId;
28 | }
29 |
30 | /**
31 | * EventsBus Interface
32 | * @description Responsible for event subscription, publishing, and persistence
33 | */
34 | export interface EventsBus {
35 | /**
36 | * Subscribe to events
37 | * @param listener Event listener
38 | * @param filter Optional filter
39 | * @returns Unsubscribe function
40 | */
41 | subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe;
42 |
43 | /**
44 | * Append event
45 | * @description Delegates to EventsStore for atomic seq allocation, then broadcasts
46 | * @param event Event input (without seq)
47 | * @returns Complete event (with seq and ts)
48 | */
49 | append(event: RunEventInput): Promise<RunEvent>;
50 |
51 | /**
52 | * Query historical events
53 | * @param query Query parameters
54 | * @returns Events sorted by seq ascending
55 | */
56 | list(query: EventsQuery): Promise<RunEvent[]>;
57 | }
58 |
59 | /**
60 | * Create NotImplemented EventsBus
61 | * @description Phase 0 placeholder
62 | */
63 | export function createNotImplementedEventsBus(): EventsBus {
64 | const notImplemented = () => {
65 | throw new Error('EventsBus not implemented');
66 | };
67 |
68 | return {
69 | subscribe: () => {
70 | notImplemented();
71 | return () => {};
72 | },
73 | append: async () => notImplemented(),
74 | list: async () => notImplemented(),
75 | };
76 | }
77 |
78 | /**
79 | * Listener entry for subscription management
80 | */
81 | interface ListenerEntry {
82 | listener: (event: RunEvent) => void;
83 | filter?: EventsFilter;
84 | }
85 |
86 | /**
87 | * Storage-backed EventsBus Implementation
88 | * @description
89 | * - seq allocation is done by EventsStore.append() (atomic with RunRecordV3.nextSeq)
90 | * - broadcast happens only after append resolves (i.e. after commit)
91 | */
92 | export class StorageBackedEventsBus implements EventsBus {
93 | private listeners = new Set<ListenerEntry>();
94 |
95 | constructor(private readonly store: EventsStore) {}
96 |
97 | subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
98 | const entry: ListenerEntry = { listener, filter };
99 | this.listeners.add(entry);
100 | return () => {
101 | this.listeners.delete(entry);
102 | };
103 | }
104 |
105 | async append(input: RunEventInput): Promise<RunEvent> {
106 | // Delegate to storage for atomic seq allocation
107 | const event = await this.store.append(input);
108 |
109 | // Broadcast after successful commit
110 | this.broadcast(event);
111 |
112 | return event;
113 | }
114 |
115 | async list(query: EventsQuery): Promise<RunEvent[]> {
116 | return this.store.list(query.runId, {
117 | fromSeq: query.fromSeq,
118 | limit: query.limit,
119 | });
120 | }
121 |
122 | /**
123 | * Broadcast event to all matching listeners
124 | */
125 | private broadcast(event: RunEvent): void {
126 | const { runId } = event;
127 | for (const { listener, filter } of this.listeners) {
128 | if (!filter || !filter.runId || filter.runId === runId) {
129 | try {
130 | listener(event);
131 | } catch (error) {
132 | console.error('[StorageBackedEventsBus] Listener error:', error);
133 | }
134 | }
135 | }
136 | }
137 | }
138 |
139 | /**
140 | * In-memory EventsBus for testing
141 | * @description Uses internal seq counter, NOT suitable for production
142 | * @deprecated Use StorageBackedEventsBus with mock EventsStore for testing
143 | */
144 | export class InMemoryEventsBus implements EventsBus {
145 | private events = new Map<RunId, RunEvent[]>();
146 | private seqCounters = new Map<RunId, number>();
147 | private listeners = new Set<ListenerEntry>();
148 |
149 | subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
150 | const entry: ListenerEntry = { listener, filter };
151 | this.listeners.add(entry);
152 | return () => {
153 | this.listeners.delete(entry);
154 | };
155 | }
156 |
157 | async append(input: RunEventInput): Promise<RunEvent> {
158 | const { runId } = input;
159 |
160 | // Allocate seq (NOT atomic, for testing only)
161 | const currentSeq = this.seqCounters.get(runId) ?? 0;
162 | const seq = currentSeq + 1;
163 | this.seqCounters.set(runId, seq);
164 |
165 | // Create complete event
166 | const event: RunEvent = {
167 | ...input,
168 | seq,
169 | ts: input.ts ?? Date.now(),
170 | } as RunEvent;
171 |
172 | // Store
173 | const runEvents = this.events.get(runId) ?? [];
174 | runEvents.push(event);
175 | this.events.set(runId, runEvents);
176 |
177 | // Broadcast
178 | for (const { listener, filter } of this.listeners) {
179 | if (!filter || !filter.runId || filter.runId === runId) {
180 | try {
181 | listener(event);
182 | } catch (error) {
183 | console.error('[InMemoryEventsBus] Listener error:', error);
184 | }
185 | }
186 | }
187 |
188 | return event;
189 | }
190 |
191 | async list(query: EventsQuery): Promise<RunEvent[]> {
192 | const runEvents = this.events.get(query.runId) ?? [];
193 |
194 | let result = runEvents;
195 |
196 | if (query.fromSeq !== undefined) {
197 | result = result.filter((e) => e.seq >= query.fromSeq!);
198 | }
199 |
200 | if (query.limit !== undefined) {
201 | result = result.slice(0, query.limit);
202 | }
203 |
204 | return result;
205 | }
206 |
207 | /**
208 | * Clear all data (for testing)
209 | */
210 | clear(): void {
211 | this.events.clear();
212 | this.seqCounters.clear();
213 | this.listeners.clear();
214 | }
215 |
216 | /**
217 | * Get current seq for a run (for testing)
218 | */
219 | getSeq(runId: RunId): number {
220 | return this.seqCounters.get(runId) ?? 0;
221 | }
222 | }
223 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/base-browser.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ToolExecutor } from '@/common/tool-handler';
2 | import type { ToolResult } from '@/common/tool-handler';
3 | import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
4 |
5 | const PING_TIMEOUT_MS = 300;
6 |
7 | /**
8 | * Base class for browser tool executors
9 | */
10 | export abstract class BaseBrowserToolExecutor implements ToolExecutor {
11 | abstract name: string;
12 | abstract execute(args: any): Promise<ToolResult>;
13 |
14 | /**
15 | * Inject content script into tab
16 | */
17 | protected async injectContentScript(
18 | tabId: number,
19 | files: string[],
20 | injectImmediately = false,
21 | world: 'MAIN' | 'ISOLATED' = 'ISOLATED',
22 | allFrames: boolean = false,
23 | frameIds?: number[],
24 | ): Promise<void> {
25 | console.log(`Injecting ${files.join(', ')} into tab ${tabId}`);
26 |
27 | // check if script is already injected
28 | try {
29 | const pingFrameId = frameIds?.[0];
30 | const response = await Promise.race([
31 | typeof pingFrameId === 'number'
32 | ? chrome.tabs.sendMessage(
33 | tabId,
34 | { action: `${this.name}_ping` },
35 | { frameId: pingFrameId },
36 | )
37 | : chrome.tabs.sendMessage(tabId, { action: `${this.name}_ping` }),
38 | new Promise((_, reject) =>
39 | setTimeout(
40 | () => reject(new Error(`${this.name} Ping action to tab ${tabId} timed out`)),
41 | PING_TIMEOUT_MS,
42 | ),
43 | ),
44 | ]);
45 |
46 | if (response && response.status === 'pong') {
47 | console.log(
48 | `pong received for action '${this.name}' in tab ${tabId}. Assuming script is active.`,
49 | );
50 | return;
51 | } else {
52 | console.warn(`Unexpected ping response in tab ${tabId}:`, response);
53 | }
54 | } catch (error) {
55 | console.error(
56 | `ping content script failed: ${error instanceof Error ? error.message : String(error)}`,
57 | );
58 | }
59 |
60 | try {
61 | const target: { tabId: number; allFrames?: boolean; frameIds?: number[] } = { tabId };
62 | if (frameIds && frameIds.length > 0) {
63 | target.frameIds = frameIds;
64 | } else if (allFrames) {
65 | target.allFrames = true;
66 | }
67 | await chrome.scripting.executeScript({
68 | target,
69 | files,
70 | injectImmediately,
71 | world,
72 | } as any);
73 | console.log(`'${files.join(', ')}' injection successful for tab ${tabId}`);
74 | } catch (injectionError) {
75 | const errorMessage =
76 | injectionError instanceof Error ? injectionError.message : String(injectionError);
77 | console.error(
78 | `Content script '${files.join(', ')}' injection failed for tab ${tabId}: ${errorMessage}`,
79 | );
80 | throw new Error(
81 | `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to inject content script in tab ${tabId}: ${errorMessage}`,
82 | );
83 | }
84 | }
85 |
86 | /**
87 | * Send message to tab
88 | */
89 | protected async sendMessageToTab(tabId: number, message: any, frameId?: number): Promise<any> {
90 | try {
91 | const response =
92 | typeof frameId === 'number'
93 | ? await chrome.tabs.sendMessage(tabId, message, { frameId })
94 | : await chrome.tabs.sendMessage(tabId, message);
95 |
96 | if (response && response.error) {
97 | throw new Error(String(response.error));
98 | }
99 |
100 | return response;
101 | } catch (error) {
102 | const errorMessage = error instanceof Error ? error.message : String(error);
103 | console.error(
104 | `Error sending message to tab ${tabId} for action ${message?.action || 'unknown'}: ${errorMessage}`,
105 | );
106 |
107 | if (error instanceof Error) {
108 | throw error;
109 | }
110 | throw new Error(errorMessage);
111 | }
112 | }
113 |
114 | /**
115 | * Try to get an existing tab by id. Returns null when not found.
116 | */
117 | protected async tryGetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {
118 | if (typeof tabId !== 'number') return null;
119 | try {
120 | return await chrome.tabs.get(tabId);
121 | } catch {
122 | return null;
123 | }
124 | }
125 |
126 | /**
127 | * Get the active tab in the current window. Throws when not found.
128 | */
129 | protected async getActiveTabOrThrow(): Promise<chrome.tabs.Tab> {
130 | const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
131 | if (!active || !active.id) throw new Error('Active tab not found');
132 | return active;
133 | }
134 |
135 | /**
136 | * Optionally focus window and/or activate tab. Defaults preserve current behavior
137 | * when caller sets activate/focus flags explicitly.
138 | */
139 | protected async ensureFocus(
140 | tab: chrome.tabs.Tab,
141 | options: { activate?: boolean; focusWindow?: boolean } = {},
142 | ): Promise<void> {
143 | const activate = options.activate === true;
144 | const focusWindow = options.focusWindow === true;
145 | if (focusWindow && typeof tab.windowId === 'number') {
146 | await chrome.windows.update(tab.windowId, { focused: true });
147 | }
148 | if (activate && typeof tab.id === 'number') {
149 | await chrome.tabs.update(tab.id, { active: true });
150 | }
151 | }
152 |
153 | /**
154 | * Get the active tab. When windowId provided, search within that window; otherwise currentWindow.
155 | */
156 | protected async getActiveTabInWindow(windowId?: number): Promise<chrome.tabs.Tab | null> {
157 | if (typeof windowId === 'number') {
158 | const tabs = await chrome.tabs.query({ active: true, windowId });
159 | return tabs && tabs[0] ? tabs[0] : null;
160 | }
161 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
162 | return tabs && tabs[0] ? tabs[0] : null;
163 | }
164 |
165 | /**
166 | * Same as getActiveTabInWindow, but throws if not found.
167 | */
168 | protected async getActiveTabOrThrowInWindow(windowId?: number): Promise<chrome.tabs.Tab> {
169 | const tab = await this.getActiveTabInWindow(windowId);
170 | if (!tab || !tab.id) throw new Error('Active tab not found');
171 | return tab;
172 | }
173 | }
174 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | // indexeddb-manager.ts
2 | // IndexedDB storage manager for Record & Replay data.
3 | // Stores: flows, runs, published, schedules, triggers.
4 |
5 | import type { Flow, RunRecord } from '../types';
6 | import type { FlowSchedule } from '../flow-store';
7 | import type { PublishedFlowInfo } from '../flow-store';
8 | import type { FlowTrigger } from '../trigger-store';
9 | import { IndexedDbClient } from '@/utils/indexeddb-client';
10 |
11 | type StoreName = 'flows' | 'runs' | 'published' | 'schedules' | 'triggers';
12 |
13 | const DB_NAME = 'rr_storage';
14 | // Version history:
15 | // v1: Initial schema with flows, runs, published, schedules, triggers stores
16 | // v2: (Previous iteration - no schema change, version was bumped during development)
17 | // v3: Current - ensure all stores exist, support upgrade from any previous version
18 | const DB_VERSION = 3;
19 |
20 | const REQUIRED_STORES = ['flows', 'runs', 'published', 'schedules', 'triggers'] as const;
21 |
22 | const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
23 | // Idempotent upgrade: ensure all required stores exist regardless of oldVersion
24 | // This handles both fresh installs (oldVersion=0) and upgrades from any version
25 | for (const storeName of REQUIRED_STORES) {
26 | if (!db.objectStoreNames.contains(storeName)) {
27 | db.createObjectStore(storeName, { keyPath: 'id' });
28 | }
29 | }
30 | });
31 |
32 | const tx = <T>(
33 | store: StoreName,
34 | mode: IDBTransactionMode,
35 | op: (s: IDBObjectStore, t: IDBTransaction) => T | Promise<T>,
36 | ) => idb.tx<T>(store, mode, op);
37 |
38 | async function getAll<T>(store: StoreName): Promise<T[]> {
39 | return idb.getAll<T>(store);
40 | }
41 |
42 | async function getOne<T>(store: StoreName, key: string): Promise<T | undefined> {
43 | return idb.get<T>(store, key);
44 | }
45 |
46 | async function putOne<T>(store: StoreName, value: T): Promise<void> {
47 | return idb.put(store, value);
48 | }
49 |
50 | async function deleteOne(store: StoreName, key: string): Promise<void> {
51 | return idb.delete(store, key);
52 | }
53 |
54 | async function clearStore(store: StoreName): Promise<void> {
55 | return idb.clear(store);
56 | }
57 |
58 | async function putMany<T>(storeName: StoreName, values: T[]): Promise<void> {
59 | return idb.putMany(storeName, values);
60 | }
61 |
62 | export const IndexedDbStorage = {
63 | flows: {
64 | async list(): Promise<Flow[]> {
65 | return getAll<Flow>('flows');
66 | },
67 | async get(id: string): Promise<Flow | undefined> {
68 | return getOne<Flow>('flows', id);
69 | },
70 | async save(flow: Flow): Promise<void> {
71 | return putOne<Flow>('flows', flow);
72 | },
73 | async delete(id: string): Promise<void> {
74 | return deleteOne('flows', id);
75 | },
76 | },
77 | runs: {
78 | async list(): Promise<RunRecord[]> {
79 | return getAll<RunRecord>('runs');
80 | },
81 | async save(record: RunRecord): Promise<void> {
82 | return putOne<RunRecord>('runs', record);
83 | },
84 | async replaceAll(records: RunRecord[]): Promise<void> {
85 | return tx<void>('runs', 'readwrite', async (st) => {
86 | st.clear();
87 | for (const r of records) st.put(r);
88 | return;
89 | });
90 | },
91 | },
92 | published: {
93 | async list(): Promise<PublishedFlowInfo[]> {
94 | return getAll<PublishedFlowInfo>('published');
95 | },
96 | async save(info: PublishedFlowInfo): Promise<void> {
97 | return putOne<PublishedFlowInfo>('published', info);
98 | },
99 | async delete(id: string): Promise<void> {
100 | return deleteOne('published', id);
101 | },
102 | },
103 | schedules: {
104 | async list(): Promise<FlowSchedule[]> {
105 | return getAll<FlowSchedule>('schedules');
106 | },
107 | async save(s: FlowSchedule): Promise<void> {
108 | return putOne<FlowSchedule>('schedules', s);
109 | },
110 | async delete(id: string): Promise<void> {
111 | return deleteOne('schedules', id);
112 | },
113 | },
114 | triggers: {
115 | async list(): Promise<FlowTrigger[]> {
116 | return getAll<FlowTrigger>('triggers');
117 | },
118 | async save(t: FlowTrigger): Promise<void> {
119 | return putOne<FlowTrigger>('triggers', t);
120 | },
121 | async delete(id: string): Promise<void> {
122 | return deleteOne('triggers', id);
123 | },
124 | },
125 | };
126 |
127 | // One-time migration from chrome.storage.local to IndexedDB
128 | let migrationPromise: Promise<void> | null = null;
129 | let migrationFailed = false;
130 |
131 | export async function ensureMigratedFromLocal(): Promise<void> {
132 | // If previous migration failed, allow retry
133 | if (migrationFailed) {
134 | migrationPromise = null;
135 | migrationFailed = false;
136 | }
137 | if (migrationPromise) return migrationPromise;
138 |
139 | migrationPromise = (async () => {
140 | try {
141 | const flag = await chrome.storage.local.get(['rr_idb_migrated']);
142 | if (flag && flag['rr_idb_migrated']) return;
143 |
144 | // Read existing data from chrome.storage.local
145 | const res = await chrome.storage.local.get([
146 | 'rr_flows',
147 | 'rr_runs',
148 | 'rr_published_flows',
149 | 'rr_schedules',
150 | 'rr_triggers',
151 | ]);
152 | const flows = (res['rr_flows'] as Flow[]) || [];
153 | const runs = (res['rr_runs'] as RunRecord[]) || [];
154 | const published = (res['rr_published_flows'] as PublishedFlowInfo[]) || [];
155 | const schedules = (res['rr_schedules'] as FlowSchedule[]) || [];
156 | const triggers = (res['rr_triggers'] as FlowTrigger[]) || [];
157 |
158 | // Write into IDB
159 | if (flows.length) await putMany('flows', flows);
160 | if (runs.length) await putMany('runs', runs);
161 | if (published.length) await putMany('published', published);
162 | if (schedules.length) await putMany('schedules', schedules);
163 | if (triggers.length) await putMany('triggers', triggers);
164 |
165 | await chrome.storage.local.set({ rr_idb_migrated: true });
166 | } catch (e) {
167 | migrationFailed = true;
168 | console.error('IndexedDbStorage migration failed:', e);
169 | // Re-throw to let callers know migration failed
170 | throw e;
171 | }
172 | })();
173 | return migrationPromise;
174 | }
175 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/wxt.config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { defineConfig } from 'wxt';
2 | import tailwindcss from '@tailwindcss/vite';
3 | import { viteStaticCopy } from 'vite-plugin-static-copy';
4 | import { config } from 'dotenv';
5 | import { resolve } from 'path';
6 | import Icons from 'unplugin-icons/vite';
7 | import Components from 'unplugin-vue-components/vite';
8 | import IconsResolver from 'unplugin-icons/resolver';
9 |
10 | config({ path: resolve(process.cwd(), '.env') });
11 | config({ path: resolve(process.cwd(), '.env.local') });
12 |
13 | const CHROME_EXTENSION_KEY = process.env.CHROME_EXTENSION_KEY;
14 | // Detect dev mode early for manifest-level switches
15 | const IS_DEV = process.env.NODE_ENV !== 'production' && process.env.MODE !== 'production';
16 |
17 | // See https://wxt.dev/api/config.html
18 | export default defineConfig({
19 | modules: ['@wxt-dev/module-vue'],
20 | runner: {
21 | // 方案1: 禁用自动启动(推荐)
22 | disabled: true,
23 |
24 | // 方案2: 如果要启用自动启动并使用现有配置,取消注释下面的配置
25 | // chromiumArgs: [
26 | // '--user-data-dir=' + homedir() + (process.platform === 'darwin'
27 | // ? '/Library/Application Support/Google/Chrome'
28 | // : process.platform === 'win32'
29 | // ? '/AppData/Local/Google/Chrome/User Data'
30 | // : '/.config/google-chrome'),
31 | // '--remote-debugging-port=9222',
32 | // ],
33 | },
34 | manifest: {
35 | // Use environment variable for the key, fallback to undefined if not set
36 | key: CHROME_EXTENSION_KEY,
37 | default_locale: 'zh_CN',
38 | name: '__MSG_extensionName__',
39 | description: '__MSG_extensionDescription__',
40 | permissions: [
41 | 'nativeMessaging',
42 | 'tabs',
43 | 'activeTab',
44 | 'scripting',
45 | 'contextMenus',
46 | 'downloads',
47 | 'webRequest',
48 | 'webNavigation',
49 | 'debugger',
50 | 'history',
51 | 'bookmarks',
52 | 'offscreen',
53 | 'storage',
54 | 'declarativeNetRequest',
55 | 'alarms',
56 | // Allow programmatic control of Chrome Side Panel
57 | 'sidePanel',
58 | ],
59 | host_permissions: ['<all_urls>'],
60 | options_ui: {
61 | page: 'options.html',
62 | open_in_tab: true,
63 | },
64 | action: {
65 | default_popup: 'popup.html',
66 | default_title: 'Chrome MCP Server',
67 | },
68 | // Chrome Side Panel entry for workflow management
69 | // Ref: https://developer.chrome.com/docs/extensions/reference/api/sidePanel
70 | side_panel: {
71 | default_path: 'sidepanel.html',
72 | },
73 | // Keyboard shortcuts for quick triggers
74 | commands: {
75 | // run_quick_trigger_1: {
76 | // suggested_key: { default: 'Ctrl+Shift+1' },
77 | // description: 'Run quick trigger 1',
78 | // },
79 | // run_quick_trigger_2: {
80 | // suggested_key: { default: 'Ctrl+Shift+2' },
81 | // description: 'Run quick trigger 2',
82 | // },
83 | // run_quick_trigger_3: {
84 | // suggested_key: { default: 'Ctrl+Shift+3' },
85 | // description: 'Run quick trigger 3',
86 | // },
87 | // open_workflow_sidepanel: {
88 | // suggested_key: { default: 'Ctrl+Shift+O' },
89 | // description: 'Open workflow sidepanel',
90 | // },
91 | toggle_web_editor: {
92 | suggested_key: { default: 'Ctrl+Shift+O', mac: 'Command+Shift+O' },
93 | description: 'Toggle Web Editor mode',
94 | },
95 | toggle_quick_panel: {
96 | suggested_key: { default: 'Ctrl+Shift+U', mac: 'Command+Shift+U' },
97 | description: 'Toggle Quick Panel AI Chat',
98 | },
99 | },
100 | web_accessible_resources: [
101 | {
102 | resources: [
103 | '/models/*', // 允许访问 public/models/ 下的所有文件
104 | '/workers/*', // 允许访问 workers 文件
105 | '/inject-scripts/*', // 允许内容脚本注入的助手文件
106 | ],
107 | matches: ['<all_urls>'],
108 | },
109 | ],
110 | // 注意:以下安全策略在开发环境会阻断 dev server 的资源加载,
111 | // 只在生产环境启用,开发环境交由 WXT 默认策略处理。
112 | ...(IS_DEV
113 | ? {}
114 | : {
115 | cross_origin_embedder_policy: { value: 'require-corp' as const },
116 | cross_origin_opener_policy: { value: 'same-origin' as const },
117 | content_security_policy: {
118 | // Allow inline styles injected by Vite (compiled CSS) and data images used in UI thumbnails
119 | extension_pages:
120 | "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;",
121 | },
122 | }),
123 | },
124 | vite: (env) => ({
125 | plugins: [
126 | // TailwindCSS v4 Vite plugin – no PostCSS config required
127 | tailwindcss(),
128 | // Auto-register SVG icons as Vue components; all icons are bundled locally
129 | Components({
130 | dts: false,
131 | resolvers: [IconsResolver({ prefix: 'i', enabledCollections: ['lucide', 'mdi', 'ri'] })],
132 | }) as any,
133 | Icons({ compiler: 'vue3', autoInstall: false }) as any,
134 | // Ensure static assets are available as early as possible to avoid race conditions in dev
135 | // Copy workers/_locales/inject-scripts into the build output before other steps
136 | viteStaticCopy({
137 | targets: [
138 | {
139 | src: 'inject-scripts/*.js',
140 | dest: 'inject-scripts',
141 | },
142 | {
143 | src: ['workers/*'],
144 | dest: 'workers',
145 | },
146 | {
147 | src: '_locales/**/*',
148 | dest: '_locales',
149 | },
150 | ],
151 | // Use writeBundle so outDir exists for dev and prod
152 | hook: 'writeBundle',
153 | // Enable watch so changes to these files are reflected during dev
154 | watch: {
155 | // Use default patterns inferred from targets; explicit true enables watching
156 | // Vite plugin will watch src patterns and re-copy on change
157 | } as any,
158 | }) as any,
159 | ],
160 | build: {
161 | // 我们的构建产物需要兼容到es6
162 | target: 'es2015',
163 | // 非生产环境下生成sourcemap
164 | sourcemap: env.mode !== 'production',
165 | // 禁用gzip 压缩大小报告,因为压缩大型文件可能会很慢
166 | reportCompressedSize: false,
167 | // chunk大小超过1500kb是触发警告
168 | chunkSizeWarningLimit: 1500,
169 | minify: false,
170 | },
171 | }),
172 | });
173 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Click and Double-Click Action Handlers
3 | *
4 | * Handles click interactions:
5 | * - Single click
6 | * - Double click
7 | * - Post-click navigation/network wait
8 | * - Selector fallback with logging
9 | */
10 |
11 | import { handleCallTool } from '@/entrypoints/background/tools';
12 | import { TOOL_NAMES } from 'chrome-mcp-shared';
13 | import { ENGINE_CONSTANTS } from '../../engine/constants';
14 | import {
15 | maybeQuickWaitForNav,
16 | waitForNavigationDone,
17 | waitForNetworkIdle,
18 | } from '../../engine/policies/wait';
19 | import { failed, invalid, ok } from '../registry';
20 | import type {
21 | Action,
22 | ActionExecutionContext,
23 | ActionExecutionResult,
24 | ActionHandler,
25 | } from '../types';
26 | import {
27 | clampInt,
28 | ensureElementVisible,
29 | logSelectorFallback,
30 | readTabUrl,
31 | selectorLocator,
32 | toSelectorTarget,
33 | } from './common';
34 |
35 | /**
36 | * Shared click execution logic for both click and dblclick
37 | */
38 | async function executeClick<T extends 'click' | 'dblclick'>(
39 | ctx: ActionExecutionContext,
40 | action: Action<T>,
41 | ): Promise<ActionExecutionResult<T>> {
42 | const vars = ctx.vars;
43 | const tabId = ctx.tabId;
44 | // Check if StepRunner owns nav-wait (skip internal nav-wait logic)
45 | const skipNavWait = ctx.execution?.skipNavWait === true;
46 |
47 | if (typeof tabId !== 'number') {
48 | return failed('TAB_NOT_FOUND', 'No active tab found');
49 | }
50 |
51 | // Ensure page is read before locating element
52 | await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
53 |
54 | // Only read beforeUrl if we need to do nav-wait
55 | const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
56 | const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
57 | action.params.target,
58 | vars,
59 | );
60 |
61 | // Locate element using shared selector locator
62 | const located = await selectorLocator.locate(tabId, selectorTarget, {
63 | frameId: ctx.frameId,
64 | preferRef: false,
65 | });
66 |
67 | const frameId = located?.frameId ?? ctx.frameId;
68 | const refToUse = located?.ref ?? selectorTarget.ref;
69 | const selectorToUse = !located?.ref ? firstCssOrAttr : undefined;
70 |
71 | if (!refToUse && !selectorToUse) {
72 | return failed('TARGET_NOT_FOUND', 'Could not locate target element');
73 | }
74 |
75 | // Verify element visibility if we have a ref
76 | if (located?.ref) {
77 | const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
78 | if (!isVisible) {
79 | return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
80 | }
81 | }
82 |
83 | // Execute click with tool timeout
84 | const toolTimeout = clampInt(action.policy?.timeout?.ms ?? 10000, 1000, 30000);
85 |
86 | const clickResult = await handleCallTool({
87 | name: TOOL_NAMES.BROWSER.CLICK,
88 | args: {
89 | ref: refToUse,
90 | selector: selectorToUse,
91 | waitForNavigation: false,
92 | timeout: toolTimeout,
93 | frameId,
94 | tabId,
95 | double: action.type === 'dblclick',
96 | },
97 | });
98 |
99 | if ((clickResult as { isError?: boolean })?.isError) {
100 | const errorContent = (clickResult as { content?: Array<{ text?: string }> })?.content;
101 | const errorMsg = errorContent?.[0]?.text || `${action.type} action failed`;
102 | return failed('UNKNOWN', errorMsg);
103 | }
104 |
105 | // Log selector fallback if used
106 | const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
107 | const fallbackUsed =
108 | resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
109 |
110 | if (fallbackUsed) {
111 | logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
112 | }
113 |
114 | // Skip post-click wait if StepRunner handles it
115 | if (skipNavWait) {
116 | return { status: 'success' };
117 | }
118 |
119 | // Post-click wait handling (only when handler owns nav-wait)
120 | const waitMs = clampInt(
121 | action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
122 | 0,
123 | ENGINE_CONSTANTS.MAX_WAIT_MS,
124 | );
125 | const after = action.params.after ?? {};
126 |
127 | if (after.waitForNavigation) {
128 | await waitForNavigationDone(beforeUrl, waitMs);
129 | } else if (after.waitForNetworkIdle) {
130 | const totalMs = clampInt(waitMs, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);
131 | const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
132 | await waitForNetworkIdle(totalMs, idleMs);
133 | } else {
134 | // Quick sniff for navigation that might have been triggered
135 | await maybeQuickWaitForNav(beforeUrl, waitMs);
136 | }
137 |
138 | return { status: 'success' };
139 | }
140 |
141 | /**
142 | * Validate click target configuration
143 | */
144 | function validateClickTarget(target: {
145 | ref?: string;
146 | candidates?: unknown[];
147 | }): { ok: true } | { ok: false; errors: [string, ...string[]] } {
148 | const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
149 | const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
150 |
151 | if (hasRef || hasCandidates) {
152 | return ok();
153 | }
154 | return invalid('Missing target selector or ref');
155 | }
156 |
157 | export const clickHandler: ActionHandler<'click'> = {
158 | type: 'click',
159 |
160 | validate: (action) =>
161 | validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),
162 |
163 | describe: (action) => {
164 | const target = action.params.target;
165 | if (typeof (target as { ref?: string }).ref === 'string') {
166 | return `Click element ${(target as { ref: string }).ref}`;
167 | }
168 | return 'Click element';
169 | },
170 |
171 | run: async (ctx, action) => {
172 | return await executeClick(ctx, action);
173 | },
174 | };
175 |
176 | export const dblclickHandler: ActionHandler<'dblclick'> = {
177 | type: 'dblclick',
178 |
179 | validate: (action) =>
180 | validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),
181 |
182 | describe: (action) => {
183 | const target = action.params.target;
184 | if (typeof (target as { ref?: string }).ref === 'string') {
185 | return `Double-click element ${(target as { ref: string }).ref}`;
186 | }
187 | return 'Double-click element';
188 | },
189 |
190 | run: async (ctx, action) => {
191 | return await executeClick(ctx, action);
192 | },
193 | };
194 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview V3 IndexedDB 数据库定义
3 | * @description 定义 rr_v3 数据库的 schema 和初始化逻辑
4 | */
5 |
6 | /** 数据库名称 */
7 | export const RR_V3_DB_NAME = 'rr_v3';
8 |
9 | /** 数据库版本 */
10 | export const RR_V3_DB_VERSION = 1;
11 |
12 | /**
13 | * Store 名称常量
14 | */
15 | export const RR_V3_STORES = {
16 | FLOWS: 'flows',
17 | RUNS: 'runs',
18 | EVENTS: 'events',
19 | QUEUE: 'queue',
20 | PERSISTENT_VARS: 'persistent_vars',
21 | TRIGGERS: 'triggers',
22 | } as const;
23 |
24 | /**
25 | * Store 配置
26 | */
27 | export interface StoreConfig {
28 | keyPath: string | string[];
29 | autoIncrement?: boolean;
30 | indexes?: Array<{
31 | name: string;
32 | keyPath: string | string[];
33 | options?: IDBIndexParameters;
34 | }>;
35 | }
36 |
37 | /**
38 | * V3 Store Schema 定义
39 | * @description 包含 Phase 1-3 所需的所有索引,避免后续升级
40 | */
41 | export const RR_V3_STORE_SCHEMAS: Record<string, StoreConfig> = {
42 | [RR_V3_STORES.FLOWS]: {
43 | keyPath: 'id',
44 | indexes: [
45 | { name: 'name', keyPath: 'name' },
46 | { name: 'updatedAt', keyPath: 'updatedAt' },
47 | ],
48 | },
49 | [RR_V3_STORES.RUNS]: {
50 | keyPath: 'id',
51 | indexes: [
52 | { name: 'status', keyPath: 'status' },
53 | { name: 'flowId', keyPath: 'flowId' },
54 | { name: 'createdAt', keyPath: 'createdAt' },
55 | { name: 'updatedAt', keyPath: 'updatedAt' },
56 | // Compound index for listing runs by flow and status
57 | { name: 'flowId_status', keyPath: ['flowId', 'status'] },
58 | ],
59 | },
60 | [RR_V3_STORES.EVENTS]: {
61 | keyPath: ['runId', 'seq'],
62 | indexes: [
63 | { name: 'runId', keyPath: 'runId' },
64 | { name: 'type', keyPath: 'type' },
65 | // Compound index for filtering events by run and type
66 | { name: 'runId_type', keyPath: ['runId', 'type'] },
67 | ],
68 | },
69 | [RR_V3_STORES.QUEUE]: {
70 | keyPath: 'id',
71 | indexes: [
72 | { name: 'status', keyPath: 'status' },
73 | { name: 'priority', keyPath: 'priority' },
74 | { name: 'createdAt', keyPath: 'createdAt' },
75 | { name: 'flowId', keyPath: 'flowId' },
76 | // Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC.
77 | { name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] },
78 | // Phase 3: Lease expiration tracking
79 | { name: 'lease_expiresAt', keyPath: 'lease.expiresAt' },
80 | ],
81 | },
82 | [RR_V3_STORES.PERSISTENT_VARS]: {
83 | keyPath: 'key',
84 | indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }],
85 | },
86 | [RR_V3_STORES.TRIGGERS]: {
87 | keyPath: 'id',
88 | indexes: [
89 | { name: 'kind', keyPath: 'kind' },
90 | { name: 'flowId', keyPath: 'flowId' },
91 | { name: 'enabled', keyPath: 'enabled' },
92 | // Compound index for listing enabled triggers by kind
93 | { name: 'kind_enabled', keyPath: ['kind', 'enabled'] },
94 | ],
95 | },
96 | };
97 |
98 | /**
99 | * 数据库升级处理器
100 | */
101 | export function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void {
102 | // Version 0 -> 1: 创建所有 stores
103 | if (oldVersion < 1) {
104 | for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) {
105 | const store = db.createObjectStore(storeName, {
106 | keyPath: config.keyPath,
107 | autoIncrement: config.autoIncrement,
108 | });
109 |
110 | // 创建索引
111 | if (config.indexes) {
112 | for (const index of config.indexes) {
113 | store.createIndex(index.name, index.keyPath, index.options);
114 | }
115 | }
116 | }
117 | }
118 | }
119 |
120 | /** 全局数据库实例 */
121 | let dbInstance: IDBDatabase | null = null;
122 | let dbPromise: Promise<IDBDatabase> | null = null;
123 |
124 | /**
125 | * 打开 V3 数据库
126 | * @description 单例模式,确保只有一个数据库连接
127 | */
128 | export async function openRrV3Db(): Promise<IDBDatabase> {
129 | if (dbInstance) {
130 | return dbInstance;
131 | }
132 |
133 | if (dbPromise) {
134 | return dbPromise;
135 | }
136 |
137 | dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
138 | const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION);
139 |
140 | request.onerror = () => {
141 | dbPromise = null;
142 | reject(new Error(`Failed to open database: ${request.error?.message}`));
143 | };
144 |
145 | request.onsuccess = () => {
146 | dbInstance = request.result;
147 |
148 | // 处理版本变更(其他 tab 升级了数据库)
149 | dbInstance.onversionchange = () => {
150 | dbInstance?.close();
151 | dbInstance = null;
152 | dbPromise = null;
153 | };
154 |
155 | resolve(dbInstance);
156 | };
157 |
158 | request.onupgradeneeded = (event) => {
159 | const db = request.result;
160 | const oldVersion = event.oldVersion;
161 | const newVersion = event.newVersion ?? RR_V3_DB_VERSION;
162 | handleUpgrade(db, oldVersion, newVersion);
163 | };
164 | });
165 |
166 | return dbPromise;
167 | }
168 |
169 | /**
170 | * 关闭数据库连接
171 | * @description 主要用于测试
172 | */
173 | export function closeRrV3Db(): void {
174 | if (dbInstance) {
175 | dbInstance.close();
176 | dbInstance = null;
177 | dbPromise = null;
178 | }
179 | }
180 |
181 | /**
182 | * 删除数据库
183 | * @description 主要用于测试
184 | */
185 | export async function deleteRrV3Db(): Promise<void> {
186 | closeRrV3Db();
187 |
188 | return new Promise((resolve, reject) => {
189 | const request = indexedDB.deleteDatabase(RR_V3_DB_NAME);
190 | request.onsuccess = () => resolve();
191 | request.onerror = () => reject(request.error);
192 | });
193 | }
194 |
195 | /**
196 | * 执行事务
197 | * @param storeNames Store 名称(单个或多个)
198 | * @param mode 事务模式
199 | * @param callback 事务回调
200 | */
201 | export async function withTransaction<T>(
202 | storeNames: string | string[],
203 | mode: IDBTransactionMode,
204 | callback: (stores: Record<string, IDBObjectStore>) => Promise<T> | T,
205 | ): Promise<T> {
206 | const db = await openRrV3Db();
207 | const names = Array.isArray(storeNames) ? storeNames : [storeNames];
208 | const tx = db.transaction(names, mode);
209 |
210 | const stores: Record<string, IDBObjectStore> = {};
211 | for (const name of names) {
212 | stores[name] = tx.objectStore(name);
213 | }
214 |
215 | return new Promise<T>((resolve, reject) => {
216 | let result: T;
217 |
218 | tx.oncomplete = () => resolve(result);
219 | tx.onerror = () => reject(tx.error);
220 | tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
221 |
222 | Promise.resolve(callback(stores))
223 | .then((r) => {
224 | result = r;
225 | })
226 | .catch((err) => {
227 | tx.abort();
228 | reject(err);
229 | });
230 | });
231 | }
232 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/stability.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Selector Stability - 选择器稳定性评估
3 | */
4 |
5 | import type {
6 | SelectorCandidate,
7 | SelectorStability,
8 | SelectorStabilitySignals,
9 | SelectorType,
10 | } from './types';
11 | import { splitCompositeSelector } from './types';
12 |
13 | const TESTID_ATTR_NAMES = [
14 | 'data-testid',
15 | 'data-test-id',
16 | 'data-test',
17 | 'data-qa',
18 | 'data-cy',
19 | ] as const;
20 |
21 | function clamp01(n: number): number {
22 | if (!Number.isFinite(n)) return 0;
23 | return Math.min(1, Math.max(0, n));
24 | }
25 |
26 | function mergeSignals(
27 | a: SelectorStabilitySignals,
28 | b: SelectorStabilitySignals,
29 | ): SelectorStabilitySignals {
30 | return {
31 | usesId: a.usesId || b.usesId || undefined,
32 | usesTestId: a.usesTestId || b.usesTestId || undefined,
33 | usesAria: a.usesAria || b.usesAria || undefined,
34 | usesText: a.usesText || b.usesText || undefined,
35 | usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined,
36 | usesAttributes: a.usesAttributes || b.usesAttributes || undefined,
37 | usesClass: a.usesClass || b.usesClass || undefined,
38 | };
39 | }
40 |
41 | function analyzeCssLike(selector: string): SelectorStabilitySignals {
42 | const s = String(selector || '');
43 | const usesNthOfType = /:nth-of-type\(/i.test(s);
44 | const usesAttributes = /\[[^\]]+\]/.test(s);
45 | const usesAria = /\[\s*aria-[^=]+\s*=|\[\s*role\s*=|\brole\s*=\s*/i.test(s);
46 |
47 | // Avoid counting `#` inside attribute values (e.g. href="#...") by requiring a token-ish pattern.
48 | const usesId = /(^|[\s>+~])#[^\s>+~.:#[]+/.test(s);
49 | const usesClass = /(^|[\s>+~])\.[^\s>+~.:#[]+/.test(s);
50 |
51 | const lower = s.toLowerCase();
52 | const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`));
53 |
54 | return {
55 | usesId: usesId || undefined,
56 | usesTestId: usesTestId || undefined,
57 | usesAria: usesAria || undefined,
58 | usesNthOfType: usesNthOfType || undefined,
59 | usesAttributes: usesAttributes || undefined,
60 | usesClass: usesClass || undefined,
61 | };
62 | }
63 |
64 | function baseScoreForCssSignals(signals: SelectorStabilitySignals): number {
65 | if (signals.usesTestId) return 0.95;
66 | if (signals.usesId) return 0.9;
67 | if (signals.usesAria) return 0.8;
68 | if (signals.usesAttributes) return 0.75;
69 | if (signals.usesClass) return 0.65;
70 | return 0.5;
71 | }
72 |
73 | function lengthPenalty(value: string): number {
74 | const len = value.length;
75 | if (len <= 60) return 0;
76 | if (len <= 120) return 0.05;
77 | if (len <= 200) return 0.1;
78 | return 0.18;
79 | }
80 |
81 | /**
82 | * 计算选择器稳定性评分
83 | */
84 | export function computeSelectorStability(candidate: SelectorCandidate): SelectorStability {
85 | if (candidate.type === 'css' || candidate.type === 'attr') {
86 | const composite = splitCompositeSelector(candidate.value);
87 | if (composite) {
88 | const a = analyzeCssLike(composite.frameSelector);
89 | const b = analyzeCssLike(composite.innerSelector);
90 | const merged = mergeSignals(a, b);
91 |
92 | let score = baseScoreForCssSignals(merged);
93 | score -= 0.05; // iframe coupling penalty
94 | if (merged.usesNthOfType) score -= 0.2;
95 | score -= lengthPenalty(candidate.value);
96 |
97 | return { score: clamp01(score), signals: merged, note: 'composite' };
98 | }
99 |
100 | const signals = analyzeCssLike(candidate.value);
101 | let score = baseScoreForCssSignals(signals);
102 | if (signals.usesNthOfType) score -= 0.2;
103 | score -= lengthPenalty(candidate.value);
104 |
105 | return { score: clamp01(score), signals };
106 | }
107 |
108 | if (candidate.type === 'xpath') {
109 | const s = String(candidate.value || '');
110 | const signals: SelectorStabilitySignals = {
111 | usesAttributes: /@[\w-]+\s*=/.test(s) || undefined,
112 | usesId: /@id\s*=/.test(s) || undefined,
113 | usesTestId: /@data-testid\s*=/.test(s) || undefined,
114 | };
115 |
116 | let score = 0.42;
117 | if (signals.usesTestId) score = 0.85;
118 | else if (signals.usesId) score = 0.75;
119 | else if (signals.usesAttributes) score = 0.55;
120 |
121 | score -= lengthPenalty(s);
122 | return { score: clamp01(score), signals };
123 | }
124 |
125 | if (candidate.type === 'aria') {
126 | const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;
127 | const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0;
128 |
129 | const signals: SelectorStabilitySignals = { usesAria: true };
130 | let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6;
131 | score -= lengthPenalty(candidate.value);
132 |
133 | return { score: clamp01(score), signals };
134 | }
135 |
136 | // text
137 | const text = String(candidate.value || '').trim();
138 | const signals: SelectorStabilitySignals = { usesText: true };
139 | let score = 0.35;
140 |
141 | // Very short texts tend to be ambiguous; very long texts are unstable.
142 | if (text.length >= 6 && text.length <= 48) score = 0.45;
143 | if (text.length > 80) score = 0.3;
144 |
145 | return { score: clamp01(score), signals };
146 | }
147 |
148 | /**
149 | * 为选择器候选添加稳定性评分
150 | */
151 | export function withStability(candidate: SelectorCandidate): SelectorCandidate {
152 | if (candidate.stability) return candidate;
153 | return { ...candidate, stability: computeSelectorStability(candidate) };
154 | }
155 |
156 | function typePriority(type: SelectorType): number {
157 | switch (type) {
158 | case 'attr':
159 | return 5;
160 | case 'css':
161 | return 4;
162 | case 'aria':
163 | return 3;
164 | case 'xpath':
165 | return 2;
166 | case 'text':
167 | return 1;
168 | default:
169 | return 0;
170 | }
171 | }
172 |
173 | /**
174 | * 比较两个选择器候选的优先级
175 | * 返回负数表示 a 优先,正数表示 b 优先
176 | */
177 | export function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number {
178 | // 1. 用户指定的权重优先
179 | const aw = a.weight ?? 0;
180 | const bw = b.weight ?? 0;
181 | if (aw !== bw) return bw - aw;
182 |
183 | // 2. 稳定性评分
184 | const as = a.stability?.score ?? computeSelectorStability(a).score;
185 | const bs = b.stability?.score ?? computeSelectorStability(b).score;
186 | if (as !== bs) return bs - as;
187 |
188 | // 3. 类型优先级
189 | const ap = typePriority(a.type);
190 | const bp = typePriority(b.type);
191 | if (ap !== bp) return bp - ap;
192 |
193 | // 4. 长度(越短越好)
194 | const alen = String(a.value || '').length;
195 | const blen = String(b.value || '').length;
196 | return alen - blen;
197 | }
198 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/common/constants.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Chrome Extension Constants
3 | * Centralized configuration values and magic constants
4 | */
5 |
6 | // Native Host Configuration
7 | export const NATIVE_HOST = {
8 | NAME: 'com.chromemcp.nativehost',
9 | DEFAULT_PORT: 12306,
10 | } as const;
11 |
12 | // Chrome Extension Icons
13 | export const ICONS = {
14 | NOTIFICATION: 'icon/48.png',
15 | } as const;
16 |
17 | // Timeouts and Delays (in milliseconds)
18 | export const TIMEOUTS = {
19 | DEFAULT_WAIT: 1000,
20 | NETWORK_CAPTURE_MAX: 30000,
21 | NETWORK_CAPTURE_IDLE: 3000,
22 | SCREENSHOT_DELAY: 100,
23 | KEYBOARD_DELAY: 50,
24 | CLICK_DELAY: 100,
25 | } as const;
26 |
27 | // Limits and Thresholds
28 | export const LIMITS = {
29 | MAX_NETWORK_REQUESTS: 100,
30 | MAX_SEARCH_RESULTS: 50,
31 | MAX_BOOKMARK_RESULTS: 100,
32 | MAX_HISTORY_RESULTS: 100,
33 | SIMILARITY_THRESHOLD: 0.1,
34 | VECTOR_DIMENSIONS: 384,
35 | } as const;
36 |
37 | // Error Messages
38 | export const ERROR_MESSAGES = {
39 | NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',
40 | NATIVE_DISCONNECTED: 'Native connection disconnected',
41 | SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',
42 | SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',
43 | TOOL_EXECUTION_FAILED: 'Tool execution failed',
44 | INVALID_PARAMETERS: 'Invalid parameters provided',
45 | PERMISSION_DENIED: 'Permission denied',
46 | TAB_NOT_FOUND: 'Tab not found',
47 | ELEMENT_NOT_FOUND: 'Element not found',
48 | NETWORK_ERROR: 'Network error occurred',
49 | } as const;
50 |
51 | // Success Messages
52 | export const SUCCESS_MESSAGES = {
53 | TOOL_EXECUTED: 'Tool executed successfully',
54 | CONNECTION_ESTABLISHED: 'Connection established',
55 | SERVER_STARTED: 'Server started successfully',
56 | SERVER_STOPPED: 'Server stopped successfully',
57 | } as const;
58 |
59 | // External Links
60 | export const LINKS = {
61 | TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
62 | } as const;
63 |
64 | // File Extensions and MIME Types
65 | export const FILE_TYPES = {
66 | STATIC_EXTENSIONS: [
67 | '.css',
68 | '.js',
69 | '.png',
70 | '.jpg',
71 | '.jpeg',
72 | '.gif',
73 | '.svg',
74 | '.ico',
75 | '.woff',
76 | '.woff2',
77 | '.ttf',
78 | ],
79 | FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],
80 | IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,
81 | } as const;
82 |
83 | // Network Filtering
84 | export const NETWORK_FILTERS = {
85 | // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
86 | EXCLUDED_DOMAINS: [
87 | // Google
88 | 'google-analytics.com',
89 | 'googletagmanager.com',
90 | 'analytics.google.com',
91 | 'doubleclick.net',
92 | 'googlesyndication.com',
93 | 'googleads.g.doubleclick.net',
94 | 'stats.g.doubleclick.net',
95 | 'adservice.google.com',
96 | 'pagead2.googlesyndication.com',
97 | // Amazon
98 | 'amazon-adsystem.com',
99 | // Microsoft
100 | 'bat.bing.com',
101 | 'clarity.ms',
102 | // Facebook
103 | 'connect.facebook.net',
104 | 'facebook.com/tr',
105 | // Twitter
106 | 'analytics.twitter.com',
107 | 'ads-twitter.com',
108 | // Other ad networks
109 | 'ads.yahoo.com',
110 | 'adroll.com',
111 | 'adnxs.com',
112 | 'criteo.com',
113 | 'quantserve.com',
114 | 'scorecardresearch.com',
115 | // Analytics & session recording
116 | 'segment.io',
117 | 'amplitude.com',
118 | 'mixpanel.com',
119 | 'optimizely.com',
120 | 'static.hotjar.com',
121 | 'script.hotjar.com',
122 | 'crazyegg.com',
123 | 'clicktale.net',
124 | 'mouseflow.com',
125 | 'fullstory.com',
126 | // LinkedIn (tracking pixels)
127 | 'linkedin.com/px',
128 | ],
129 | // Static resource extensions (used when includeStatic=false)
130 | STATIC_RESOURCE_EXTENSIONS: [
131 | '.jpg',
132 | '.jpeg',
133 | '.png',
134 | '.gif',
135 | '.svg',
136 | '.webp',
137 | '.ico',
138 | '.bmp',
139 | '.cur',
140 | '.css',
141 | '.scss',
142 | '.less',
143 | '.js',
144 | '.jsx',
145 | '.ts',
146 | '.tsx',
147 | '.map',
148 | '.woff',
149 | '.woff2',
150 | '.ttf',
151 | '.eot',
152 | '.otf',
153 | '.mp3',
154 | '.mp4',
155 | '.avi',
156 | '.mov',
157 | '.wmv',
158 | '.flv',
159 | '.webm',
160 | '.ogg',
161 | '.wav',
162 | '.pdf',
163 | '.zip',
164 | '.rar',
165 | '.7z',
166 | '.iso',
167 | '.dmg',
168 | '.doc',
169 | '.docx',
170 | '.xls',
171 | '.xlsx',
172 | '.ppt',
173 | '.pptx',
174 | ],
175 | // MIME types treated as static/binary (filtered when includeStatic=false)
176 | STATIC_MIME_TYPES_TO_FILTER: [
177 | 'image/',
178 | 'font/',
179 | 'audio/',
180 | 'video/',
181 | 'text/css',
182 | 'text/javascript',
183 | 'application/javascript',
184 | 'application/x-javascript',
185 | 'application/pdf',
186 | 'application/zip',
187 | 'application/octet-stream',
188 | ],
189 | // API-like MIME types (never filtered by MIME)
190 | API_MIME_TYPES: [
191 | 'application/json',
192 | 'application/xml',
193 | 'text/xml',
194 | 'text/plain',
195 | 'text/event-stream',
196 | 'application/x-www-form-urlencoded',
197 | 'application/graphql',
198 | 'application/grpc',
199 | 'application/protobuf',
200 | 'application/x-protobuf',
201 | 'application/x-json',
202 | 'application/ld+json',
203 | 'application/problem+json',
204 | 'application/problem+xml',
205 | 'application/soap+xml',
206 | 'application/vnd.api+json',
207 | ],
208 | STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
209 | } as const;
210 |
211 | // Semantic Similarity Configuration
212 | export const SEMANTIC_CONFIG = {
213 | DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
214 | CHUNK_SIZE: 512,
215 | CHUNK_OVERLAP: 50,
216 | BATCH_SIZE: 32,
217 | CACHE_SIZE: 1000,
218 | } as const;
219 |
220 | // Storage Keys
221 | export const STORAGE_KEYS = {
222 | SERVER_STATUS: 'serverStatus',
223 | NATIVE_SERVER_PORT: 'nativeServerPort',
224 | NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
225 | SEMANTIC_MODEL: 'selectedModel',
226 | USER_PREFERENCES: 'userPreferences',
227 | VECTOR_INDEX: 'vectorIndex',
228 | USERSCRIPTS: 'userscripts',
229 | USERSCRIPTS_DISABLED: 'userscripts_disabled',
230 | // Record & Replay storage keys
231 | RR_FLOWS: 'rr_flows',
232 | RR_RUNS: 'rr_runs',
233 | RR_PUBLISHED: 'rr_published_flows',
234 | RR_SCHEDULES: 'rr_schedules',
235 | RR_TRIGGERS: 'rr_triggers',
236 | // Persistent recording state (guards resume across navigations/service worker restarts)
237 | RR_RECORDING_STATE: 'rr_recording_state',
238 | } as const;
239 |
240 | // Notification Configuration
241 | export const NOTIFICATIONS = {
242 | PRIORITY: 2,
243 | TYPE: 'basic' as const,
244 | } as const;
245 |
246 | export enum ExecutionWorld {
247 | ISOLATED = 'ISOLATED',
248 | MAIN = 'MAIN',
249 | }
250 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ElementMarkerManagement.vue:
--------------------------------------------------------------------------------
```vue
1 | <template>
2 | <div class="section">
3 | <h2 class="section-title">元素标注管理</h2>
4 | <div class="config-card">
5 | <div class="status-section" style="gap: 8px">
6 | <div class="status-header">
7 | <p class="status-label">当前页面</p>
8 | <span class="status-text" style="opacity: 0.85">{{ currentUrl }}</span>
9 | </div>
10 | <div class="status-header">
11 | <p class="status-label">已标注元素</p>
12 | <span class="status-text">{{ markers.length }}</span>
13 | </div>
14 | </div>
15 |
16 | <form class="mcp-config-section" @submit.prevent="onAdd">
17 | <div class="mcp-config-header">
18 | <p class="mcp-config-label">新增标注</p>
19 | </div>
20 | <div style="display: flex; gap: 8px; margin-bottom: 8px">
21 | <input v-model="form.name" placeholder="名称,如 登录按钮" class="port-input" />
22 | <select v-model="form.selectorType" class="port-input" style="max-width: 120px">
23 | <option value="css">CSS</option>
24 | <option value="xpath">XPath</option>
25 | </select>
26 | <select v-model="form.matchType" class="port-input" style="max-width: 120px">
27 | <option value="prefix">路径前缀</option>
28 | <option value="exact">精确匹配</option>
29 | <option value="host">域名</option>
30 | </select>
31 | </div>
32 | <input v-model="form.selector" placeholder="CSS 选择器" class="port-input" />
33 | <div style="display: flex; gap: 8px; margin-top: 8px">
34 | <button class="semantic-engine-button" :disabled="!form.selector" type="submit">
35 | 保存
36 | </button>
37 | <button class="danger-button" type="button" @click="resetForm">清空</button>
38 | </div>
39 | </form>
40 |
41 | <div v-if="markers.length" class="model-list" style="margin-top: 8px">
42 | <div
43 | v-for="m in markers"
44 | :key="m.id"
45 | class="model-card"
46 | style="display: flex; align-items: center; justify-content: space-between; gap: 8px"
47 | >
48 | <div style="display: flex; flex-direction: column; gap: 4px">
49 | <strong class="model-name">{{ m.name }}</strong>
50 | <code style="font-size: 12px; opacity: 0.85">{{ m.selector }}</code>
51 | <div style="display: flex; gap: 6px; margin-top: 2px">
52 | <span class="model-tag dimension">{{ m.selectorType || 'css' }}</span>
53 | <span class="model-tag dimension">{{ m.matchType }}</span>
54 | </div>
55 | </div>
56 | <div style="display: flex; gap: 6px">
57 | <button class="semantic-engine-button" @click="validate(m)">验证</button>
58 | <button class="secondary-button" @click="prefill(m)">编辑</button>
59 | <button class="danger-button" @click="remove(m)">删除</button>
60 | </div>
61 | </div>
62 | </div>
63 | </div>
64 | </div>
65 | </template>
66 |
67 | <script setup lang="ts">
68 | import { ref, onMounted } from 'vue';
69 | import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
70 | import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
71 |
72 | const currentUrl = ref('');
73 | const markers = ref<ElementMarker[]>([]);
74 |
75 | const form = ref<UpsertMarkerRequest>({
76 | url: '',
77 | name: '',
78 | selector: '',
79 | matchType: 'prefix',
80 | });
81 |
82 | function resetForm() {
83 | form.value = { url: currentUrl.value, name: '', selector: '', matchType: 'prefix' };
84 | }
85 |
86 | async function load() {
87 | try {
88 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
89 | const t = tabs[0];
90 | currentUrl.value = String(t?.url || '');
91 | form.value.url = currentUrl.value;
92 | const res: any = await chrome.runtime.sendMessage({
93 | type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL,
94 | url: currentUrl.value,
95 | });
96 | if (res?.success) markers.value = res.markers || [];
97 | } catch (e) {
98 | /* ignore */
99 | }
100 | }
101 |
102 | function prefill(m: ElementMarker) {
103 | form.value = {
104 | url: m.url,
105 | name: m.name,
106 | selector: m.selector,
107 | selectorType: m.selectorType,
108 | listMode: m.listMode,
109 | matchType: m.matchType,
110 | action: m.action,
111 | id: m.id,
112 | };
113 | }
114 |
115 | async function onAdd() {
116 | try {
117 | if (!form.value.selector) return;
118 | form.value.url = currentUrl.value;
119 | const res: any = await chrome.runtime.sendMessage({
120 | type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,
121 | marker: form.value,
122 | });
123 | if (res?.success) {
124 | resetForm();
125 | await load();
126 | }
127 | } catch (e) {
128 | /* ignore */
129 | }
130 | }
131 |
132 | async function remove(m: ElementMarker) {
133 | try {
134 | const res: any = await chrome.runtime.sendMessage({
135 | type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,
136 | id: m.id,
137 | });
138 | if (res?.success) await load();
139 | } catch (e) {
140 | /* ignore */
141 | }
142 | }
143 |
144 | async function validate(m: ElementMarker) {
145 | try {
146 | const res: any = await chrome.runtime.sendMessage({
147 | type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,
148 | selector: m.selector,
149 | selectorType: m.selectorType || 'css',
150 | action: 'hover',
151 | listMode: !!m.listMode,
152 | } as any);
153 |
154 | // Trigger highlight in the page only if tool validation succeeded
155 | if (res?.tool?.ok !== false) {
156 | await highlightInTab(m);
157 | }
158 | } catch (e) {
159 | /* ignore */
160 | }
161 | }
162 |
163 | async function highlightInTab(m: ElementMarker) {
164 | try {
165 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
166 | const tabId = tabs[0]?.id;
167 | if (!tabId) return;
168 |
169 | // Ensure element-marker.js is injected
170 | try {
171 | await chrome.scripting.executeScript({
172 | target: { tabId, allFrames: true },
173 | files: ['inject-scripts/element-marker.js'],
174 | world: 'ISOLATED',
175 | });
176 | } catch {
177 | // Already injected, ignore
178 | }
179 |
180 | // Send highlight message to content script
181 | await chrome.tabs.sendMessage(tabId, {
182 | action: 'element_marker_highlight',
183 | selector: m.selector,
184 | selectorType: m.selectorType || 'css',
185 | listMode: !!m.listMode,
186 | });
187 | } catch (e) {
188 | // Ignore errors (tab might not support content scripts)
189 | }
190 | }
191 |
192 | onMounted(load);
193 | </script>
194 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/input-container.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Input Container Component
3 | *
4 | * A reusable wrapper for inputs aligned with the attr-ui.html design spec.
5 | * Provides container-level hover/focus-within styling with optional prefix/suffix.
6 | *
7 | * Design spec pattern:
8 | * ```html
9 | * <div class="input-bg rounded h-[28px] flex items-center px-2">
10 | * <span class="text-gray-400 mr-2">X</span> <!-- prefix -->
11 | * <input type="text" class="bg-transparent w-full outline-none">
12 | * <span class="text-gray-400 text-[10px]">px</span> <!-- suffix -->
13 | * </div>
14 | * ```
15 | *
16 | * CSS classes (defined in shadow-host.ts):
17 | * - `.we-input-container` - wrapper with hover/focus-within styles
18 | * - `.we-input-container__input` - transparent input
19 | * - `.we-input-container__prefix` - prefix element
20 | * - `.we-input-container__suffix` - suffix element (typically unit)
21 | */
22 |
23 | // =============================================================================
24 | // Types
25 | // =============================================================================
26 |
27 | /** Content for prefix/suffix: text string or DOM node (e.g., SVG icon) */
28 | export type InputAffix = string | Node;
29 |
30 | export interface InputContainerOptions {
31 | /** Accessible label for the input element */
32 | ariaLabel: string;
33 | /** Input type (default: "text") */
34 | type?: string;
35 | /** Input mode for virtual keyboard (e.g., "decimal", "numeric") */
36 | inputMode?: string;
37 | /** Optional prefix content (text label or icon) */
38 | prefix?: InputAffix | null;
39 | /** Optional suffix content (unit text or icon) */
40 | suffix?: InputAffix | null;
41 | /** Additional class name(s) for root container */
42 | rootClassName?: string;
43 | /** Additional class name(s) for input element */
44 | inputClassName?: string;
45 | /** Input autocomplete attribute (default: "off") */
46 | autocomplete?: string;
47 | /** Input spellcheck attribute (default: false) */
48 | spellcheck?: boolean;
49 | /** Initial placeholder text */
50 | placeholder?: string;
51 | }
52 |
53 | export interface InputContainer {
54 | /** Root container element */
55 | root: HTMLDivElement;
56 | /** Input element for wiring events */
57 | input: HTMLInputElement;
58 | /** Update prefix content */
59 | setPrefix(content: InputAffix | null): void;
60 | /** Update suffix content */
61 | setSuffix(content: InputAffix | null): void;
62 | /** Get current suffix text (null if no suffix or if suffix is a Node) */
63 | getSuffixText(): string | null;
64 | }
65 |
66 | // =============================================================================
67 | // Helpers
68 | // =============================================================================
69 |
70 | function isNonEmptyString(value: unknown): value is string {
71 | return typeof value === 'string' && value.trim().length > 0;
72 | }
73 |
74 | function hasAffix(value: InputAffix | null | undefined): value is InputAffix {
75 | if (value === null || value === undefined) return false;
76 | return typeof value === 'string' ? value.trim().length > 0 : true;
77 | }
78 |
79 | function joinClassNames(...parts: Array<string | null | undefined | false>): string {
80 | return parts.filter(isNonEmptyString).join(' ');
81 | }
82 |
83 | // =============================================================================
84 | // Factory
85 | // =============================================================================
86 |
87 | /**
88 | * Create an input container with optional prefix/suffix
89 | */
90 | export function createInputContainer(options: InputContainerOptions): InputContainer {
91 | const {
92 | ariaLabel,
93 | type = 'text',
94 | inputMode,
95 | prefix,
96 | suffix,
97 | rootClassName,
98 | inputClassName,
99 | autocomplete = 'off',
100 | spellcheck = false,
101 | placeholder,
102 | } = options;
103 |
104 | // Root container
105 | const root = document.createElement('div');
106 | root.className = joinClassNames('we-input-container', rootClassName);
107 |
108 | // Prefix element (created lazily)
109 | let prefixEl: HTMLSpanElement | null = null;
110 |
111 | // Input element
112 | const input = document.createElement('input');
113 | input.type = type;
114 | input.className = joinClassNames('we-input-container__input', inputClassName);
115 | input.setAttribute('autocomplete', autocomplete);
116 | input.spellcheck = spellcheck;
117 | input.setAttribute('aria-label', ariaLabel);
118 | if (inputMode) {
119 | input.inputMode = inputMode;
120 | }
121 | if (placeholder !== undefined) {
122 | input.placeholder = placeholder;
123 | }
124 |
125 | // Suffix element (created lazily)
126 | let suffixEl: HTMLSpanElement | null = null;
127 |
128 | // Helper: create/update affix element
129 | function updateAffix(
130 | kind: 'prefix' | 'suffix',
131 | content: InputAffix | null,
132 | existingEl: HTMLSpanElement | null,
133 | ): HTMLSpanElement | null {
134 | if (!hasAffix(content)) {
135 | // Remove existing element if present
136 | if (existingEl) {
137 | existingEl.remove();
138 | }
139 | return null;
140 | }
141 |
142 | // Create element if needed
143 | const el = existingEl ?? document.createElement('span');
144 | el.className = `we-input-container__${kind}`;
145 |
146 | // Clear and set content
147 | el.textContent = '';
148 | if (typeof content === 'string') {
149 | el.textContent = content;
150 | } else {
151 | el.append(content);
152 | }
153 |
154 | return el;
155 | }
156 |
157 | // Initial prefix
158 | if (hasAffix(prefix)) {
159 | prefixEl = updateAffix('prefix', prefix, null);
160 | if (prefixEl) root.append(prefixEl);
161 | }
162 |
163 | // Append input
164 | root.append(input);
165 |
166 | // Initial suffix
167 | if (hasAffix(suffix)) {
168 | suffixEl = updateAffix('suffix', suffix, null);
169 | if (suffixEl) root.append(suffixEl);
170 | }
171 |
172 | // Public interface
173 | return {
174 | root,
175 | input,
176 |
177 | setPrefix(content: InputAffix | null): void {
178 | const newEl = updateAffix('prefix', content, prefixEl);
179 | if (newEl && !prefixEl) {
180 | // Insert before input
181 | root.insertBefore(newEl, input);
182 | }
183 | prefixEl = newEl;
184 | },
185 |
186 | setSuffix(content: InputAffix | null): void {
187 | const newEl = updateAffix('suffix', content, suffixEl);
188 | if (newEl && !suffixEl) {
189 | // Append after input
190 | root.append(newEl);
191 | }
192 | suffixEl = newEl;
193 | },
194 |
195 | getSuffixText(): string | null {
196 | if (!suffixEl) return null;
197 | // Only return text content, not Node content
198 | const text = suffixEl.textContent?.trim();
199 | return text || null;
200 | },
201 | };
202 | }
203 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts:
--------------------------------------------------------------------------------
```typescript
1 | // expression.ts — minimal safe boolean expression evaluator (no access to global scope)
2 | // Supported:
3 | // - Literals: numbers (123, 1.23), strings ('x' or "x"), booleans (true/false)
4 | // - Variables: vars.x, vars.a.b (only reads from provided vars object)
5 | // - Operators: !, &&, ||, ==, !=, >, >=, <, <=, +, -, *, /
6 | // - Parentheses: ( ... )
7 |
8 | type Token = { type: string; value?: any };
9 |
10 | function tokenize(input: string): Token[] {
11 | const s = input.trim();
12 | const out: Token[] = [];
13 | let i = 0;
14 | const isAlpha = (c: string) => /[a-zA-Z_]/.test(c);
15 | const isNum = (c: string) => /[0-9]/.test(c);
16 | const isIdChar = (c: string) => /[a-zA-Z0-9_]/.test(c);
17 | while (i < s.length) {
18 | const c = s[i];
19 | if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
20 | i++;
21 | continue;
22 | }
23 | // operators
24 | if (
25 | s.startsWith('&&', i) ||
26 | s.startsWith('||', i) ||
27 | s.startsWith('==', i) ||
28 | s.startsWith('!=', i) ||
29 | s.startsWith('>=', i) ||
30 | s.startsWith('<=', i)
31 | ) {
32 | out.push({ type: 'op', value: s.slice(i, i + 2) });
33 | i += 2;
34 | continue;
35 | }
36 | if ('!+-*/()<>'.includes(c)) {
37 | out.push({ type: 'op', value: c });
38 | i++;
39 | continue;
40 | }
41 | // number
42 | if (isNum(c) || (c === '.' && isNum(s[i + 1] || ''))) {
43 | let j = i + 1;
44 | while (j < s.length && (isNum(s[j]) || s[j] === '.')) j++;
45 | out.push({ type: 'num', value: parseFloat(s.slice(i, j)) });
46 | i = j;
47 | continue;
48 | }
49 | // string
50 | if (c === '"' || c === "'") {
51 | const quote = c;
52 | let j = i + 1;
53 | let str = '';
54 | while (j < s.length) {
55 | if (s[j] === '\\' && j + 1 < s.length) {
56 | str += s[j + 1];
57 | j += 2;
58 | } else if (s[j] === quote) {
59 | j++;
60 | break;
61 | } else {
62 | str += s[j++];
63 | }
64 | }
65 | out.push({ type: 'str', value: str });
66 | i = j;
67 | continue;
68 | }
69 | // identifier (vars or true/false)
70 | if (isAlpha(c)) {
71 | let j = i + 1;
72 | while (j < s.length && isIdChar(s[j])) j++;
73 | let id = s.slice(i, j);
74 | // dotted path
75 | while (s[j] === '.' && isAlpha(s[j + 1] || '')) {
76 | let k = j + 1;
77 | while (k < s.length && isIdChar(s[k])) k++;
78 | id += s.slice(j, k);
79 | j = k;
80 | }
81 | out.push({ type: 'id', value: id });
82 | i = j;
83 | continue;
84 | }
85 | // unknown token, skip to avoid crash
86 | i++;
87 | }
88 | return out;
89 | }
90 |
91 | // Recursive descent parser
92 | export function evalExpression(expr: string, scope: { vars: Record<string, any> }): any {
93 | const tokens = tokenize(expr);
94 | let i = 0;
95 | const peek = () => tokens[i];
96 | const consume = () => tokens[i++];
97 |
98 | function parsePrimary(): any {
99 | const t = peek();
100 | if (!t) return undefined;
101 | if (t.type === 'num') {
102 | consume();
103 | return t.value;
104 | }
105 | if (t.type === 'str') {
106 | consume();
107 | return t.value;
108 | }
109 | if (t.type === 'id') {
110 | consume();
111 | const id = String(t.value);
112 | if (id === 'true') return true;
113 | if (id === 'false') return false;
114 | // Only allow vars.* lookups
115 | if (!id.startsWith('vars')) return undefined;
116 | try {
117 | const parts = id.split('.').slice(1);
118 | let cur: any = scope.vars;
119 | for (const p of parts) {
120 | if (cur == null) return undefined;
121 | cur = cur[p];
122 | }
123 | return cur;
124 | } catch {
125 | return undefined;
126 | }
127 | }
128 | if (t.type === 'op' && t.value === '(') {
129 | consume();
130 | const v = parseOr();
131 | if (peek()?.type === 'op' && peek()?.value === ')') consume();
132 | return v;
133 | }
134 | return undefined;
135 | }
136 |
137 | function parseUnary(): any {
138 | const t = peek();
139 | if (t && t.type === 'op' && (t.value === '!' || t.value === '-')) {
140 | consume();
141 | const v = parseUnary();
142 | return t.value === '!' ? !truthy(v) : -Number(v || 0);
143 | }
144 | return parsePrimary();
145 | }
146 |
147 | function parseMulDiv(): any {
148 | let v = parseUnary();
149 | while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
150 | const op = consume().value;
151 | const r = parseUnary();
152 | v = op === '*' ? Number(v || 0) * Number(r || 0) : Number(v || 0) / Number(r || 0);
153 | }
154 | return v;
155 | }
156 |
157 | function parseAddSub(): any {
158 | let v = parseMulDiv();
159 | while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
160 | const op = consume().value;
161 | const r = parseMulDiv();
162 | v = op === '+' ? Number(v || 0) + Number(r || 0) : Number(v || 0) - Number(r || 0);
163 | }
164 | return v;
165 | }
166 |
167 | function parseRel(): any {
168 | let v = parseAddSub();
169 | while (peek() && peek().type === 'op' && ['>', '>=', '<', '<='].includes(peek().value)) {
170 | const op = consume().value as string;
171 | const r = parseAddSub();
172 | const a = toComparable(v);
173 | const b = toComparable(r);
174 | if (op === '>') v = (a as any) > (b as any);
175 | else if (op === '>=') v = (a as any) >= (b as any);
176 | else if (op === '<') v = (a as any) < (b as any);
177 | else v = (a as any) <= (b as any);
178 | }
179 | return v;
180 | }
181 |
182 | function parseEq(): any {
183 | let v = parseRel();
184 | while (peek() && peek().type === 'op' && (peek().value === '==' || peek().value === '!=')) {
185 | const op = consume().value as string;
186 | const r = parseRel();
187 | const a = toComparable(v);
188 | const b = toComparable(r);
189 | v = op === '==' ? a === b : a !== b;
190 | }
191 | return v;
192 | }
193 |
194 | function parseAnd(): any {
195 | let v = parseEq();
196 | while (peek() && peek().type === 'op' && peek().value === '&&') {
197 | consume();
198 | const r = parseEq();
199 | v = truthy(v) && truthy(r);
200 | }
201 | return v;
202 | }
203 |
204 | function parseOr(): any {
205 | let v = parseAnd();
206 | while (peek() && peek().type === 'op' && peek().value === '||') {
207 | consume();
208 | const r = parseAnd();
209 | v = truthy(v) || truthy(r);
210 | }
211 | return v;
212 | }
213 |
214 | function truthy(v: any) {
215 | return !!v;
216 | }
217 | function toComparable(v: any) {
218 | return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v);
219 | }
220 |
221 | try {
222 | const res = parseOr();
223 | return res;
224 | } catch {
225 | return false;
226 | }
227 | }
228 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Fill Action Handler
3 | *
4 | * Handles form input actions:
5 | * - Text input
6 | * - File upload
7 | * - Auto-scroll and focus
8 | * - Selector fallback with logging
9 | */
10 |
11 | import { handleCallTool } from '@/entrypoints/background/tools';
12 | import { TOOL_NAMES } from 'chrome-mcp-shared';
13 | import { failed, invalid, ok } from '../registry';
14 | import type { ActionHandler } from '../types';
15 | import {
16 | ensureElementVisible,
17 | logSelectorFallback,
18 | resolveString,
19 | selectorLocator,
20 | sendMessageToTab,
21 | toSelectorTarget,
22 | } from './common';
23 |
24 | export const fillHandler: ActionHandler<'fill'> = {
25 | type: 'fill',
26 |
27 | validate: (action) => {
28 | const target = action.params.target as { ref?: string; candidates?: unknown[] };
29 | const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
30 | const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
31 | const hasValue = action.params.value !== undefined;
32 |
33 | if (!hasValue) {
34 | return invalid('Missing value parameter');
35 | }
36 | if (!hasRef && !hasCandidates) {
37 | return invalid('Missing target selector or ref');
38 | }
39 | return ok();
40 | },
41 |
42 | describe: (action) => {
43 | const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)';
44 | const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value;
45 | return `Fill "${displayValue}"`;
46 | },
47 |
48 | run: async (ctx, action) => {
49 | const vars = ctx.vars;
50 | const tabId = ctx.tabId;
51 |
52 | if (typeof tabId !== 'number') {
53 | return failed('TAB_NOT_FOUND', 'No active tab found');
54 | }
55 |
56 | // Ensure page is read before locating element
57 | await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
58 |
59 | // Resolve fill value
60 | const valueResolved = resolveString(action.params.value, vars);
61 | if (!valueResolved.ok) {
62 | return failed('VALIDATION_ERROR', valueResolved.error);
63 | }
64 | const value = valueResolved.value;
65 |
66 | // Locate target element
67 | const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
68 | action.params.target,
69 | vars,
70 | );
71 |
72 | const located = await selectorLocator.locate(tabId, selectorTarget, {
73 | frameId: ctx.frameId,
74 | preferRef: false,
75 | });
76 |
77 | const frameId = located?.frameId ?? ctx.frameId;
78 | const refToUse = located?.ref ?? selectorTarget.ref;
79 | const cssSelector = !located?.ref ? firstCssOrAttr : undefined;
80 |
81 | if (!refToUse && !cssSelector) {
82 | return failed('TARGET_NOT_FOUND', 'Could not locate target element');
83 | }
84 |
85 | // Verify element visibility if we have a ref
86 | if (located?.ref) {
87 | const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
88 | if (!isVisible) {
89 | return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
90 | }
91 | }
92 |
93 | // Check for file input and handle file upload
94 | // Use firstCssOrAttr to check input type even when ref is available
95 | const selectorForTypeCheck = firstCssOrAttr || cssSelector;
96 | if (selectorForTypeCheck) {
97 | const attrResult = await sendMessageToTab<{ value?: string }>(
98 | tabId,
99 | { action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' },
100 | frameId,
101 | );
102 | const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase();
103 |
104 | if (inputType === 'file') {
105 | const uploadResult = await handleCallTool({
106 | name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
107 | args: { selector: selectorForTypeCheck, filePath: value, tabId },
108 | });
109 |
110 | if ((uploadResult as { isError?: boolean })?.isError) {
111 | const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content;
112 | const errorMsg = errorContent?.[0]?.text || 'File upload failed';
113 | return failed('UNKNOWN', errorMsg);
114 | }
115 |
116 | // Log fallback if used
117 | const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
118 | const fallbackUsed =
119 | resolvedBy &&
120 | firstCandidateType &&
121 | resolvedBy !== 'ref' &&
122 | resolvedBy !== firstCandidateType;
123 | if (fallbackUsed) {
124 | logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
125 | }
126 |
127 | return { status: 'success' };
128 | }
129 | }
130 |
131 | // Scroll element into view (best-effort)
132 | if (cssSelector) {
133 | try {
134 | await handleCallTool({
135 | name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
136 | args: {
137 | type: 'MAIN',
138 | jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`,
139 | tabId,
140 | },
141 | });
142 | } catch {
143 | // Ignore scroll errors
144 | }
145 | }
146 |
147 | // Focus element (best-effort, ignore errors)
148 | if (located?.ref) {
149 | await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId);
150 | } else if (cssSelector) {
151 | await handleCallTool({
152 | name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
153 | args: {
154 | type: 'MAIN',
155 | jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
156 | tabId,
157 | },
158 | });
159 | }
160 |
161 | // Execute fill
162 | const fillResult = await handleCallTool({
163 | name: TOOL_NAMES.BROWSER.FILL,
164 | args: {
165 | ref: refToUse,
166 | selector: cssSelector,
167 | value,
168 | frameId,
169 | tabId,
170 | },
171 | });
172 |
173 | if ((fillResult as { isError?: boolean })?.isError) {
174 | const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content;
175 | const errorMsg = errorContent?.[0]?.text || 'Fill action failed';
176 | return failed('UNKNOWN', errorMsg);
177 | }
178 |
179 | // Log fallback if used
180 | const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
181 | const fallbackUsed =
182 | resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
183 |
184 | if (fallbackUsed) {
185 | logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
186 | }
187 |
188 | return { status: 'success' };
189 | },
190 | };
191 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTopBar.vue:
--------------------------------------------------------------------------------
```vue
1 | <template>
2 | <div class="flex items-center justify-between w-full">
3 | <!-- Brand / Context -->
4 | <div class="flex items-center gap-2 overflow-hidden -ml-1">
5 | <!-- Back Button (when in chat view) -->
6 | <button
7 | v-if="showBackButton"
8 | class="flex items-center justify-center w-8 h-8 flex-shrink-0 ac-btn"
9 | :style="{
10 | color: 'var(--ac-text-muted)',
11 | borderRadius: 'var(--ac-radius-button)',
12 | }"
13 | title="Back to sessions"
14 | @click="$emit('back')"
15 | >
16 | <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
17 | <path
18 | stroke-linecap="round"
19 | stroke-linejoin="round"
20 | stroke-width="2"
21 | d="M15 19l-7-7 7-7"
22 | />
23 | </svg>
24 | </button>
25 |
26 | <!-- Brand -->
27 | <h1
28 | class="text-lg font-medium tracking-tight flex-shrink-0"
29 | :style="{
30 | fontFamily: 'var(--ac-font-heading)',
31 | color: 'var(--ac-text)',
32 | }"
33 | >
34 | {{ brandLabel || 'Agent' }}
35 | </h1>
36 |
37 | <!-- Divider -->
38 | <div
39 | class="h-4 w-[1px] flex-shrink-0"
40 | :style="{ backgroundColor: 'var(--ac-border-strong)' }"
41 | />
42 |
43 | <!-- Project Breadcrumb -->
44 | <button
45 | class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
46 | :style="{
47 | fontFamily: 'var(--ac-font-mono)',
48 | color: 'var(--ac-text-muted)',
49 | borderRadius: 'var(--ac-radius-button)',
50 | }"
51 | @click="$emit('toggle:projectMenu')"
52 | >
53 | <span class="truncate">{{ projectLabel }}</span>
54 | <svg
55 | class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
56 | fill="none"
57 | viewBox="0 0 24 24"
58 | stroke="currentColor"
59 | >
60 | <path
61 | stroke-linecap="round"
62 | stroke-linejoin="round"
63 | stroke-width="2"
64 | d="M19 9l-7 7-7-7"
65 | />
66 | </svg>
67 | </button>
68 |
69 | <!-- Session Breadcrumb -->
70 | <div class="h-3 w-[1px] flex-shrink-0" :style="{ backgroundColor: 'var(--ac-border)' }" />
71 | <button
72 | class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
73 | :style="{
74 | fontFamily: 'var(--ac-font-mono)',
75 | color: 'var(--ac-text-subtle)',
76 | borderRadius: 'var(--ac-radius-button)',
77 | }"
78 | @click="$emit('toggle:sessionMenu')"
79 | >
80 | <span class="truncate">{{ sessionLabel }}</span>
81 | <svg
82 | class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
83 | fill="none"
84 | viewBox="0 0 24 24"
85 | stroke="currentColor"
86 | >
87 | <path
88 | stroke-linecap="round"
89 | stroke-linejoin="round"
90 | stroke-width="2"
91 | d="M19 9l-7 7-7-7"
92 | />
93 | </svg>
94 | </button>
95 | </div>
96 |
97 | <!-- Connection / Status / Settings -->
98 | <div class="flex items-center gap-3">
99 | <!-- Connection Indicator -->
100 | <div class="flex items-center gap-1.5" :title="connectionText">
101 | <span
102 | class="w-2 h-2 rounded-full"
103 | :style="{
104 | backgroundColor: connectionColor,
105 | boxShadow: connectionState === 'ready' ? `0 0 8px ${connectionColor}` : 'none',
106 | }"
107 | />
108 | </div>
109 |
110 | <!-- Open Project Button -->
111 | <button
112 | class="p-1 ac-btn ac-hover-text"
113 | :style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
114 | title="Open project in VS Code or Terminal"
115 | @click="$emit('toggle:openProjectMenu')"
116 | >
117 | <svg
118 | class="w-5 h-5"
119 | viewBox="0 0 24 24"
120 | fill="none"
121 | stroke="currentColor"
122 | stroke-width="2"
123 | stroke-linecap="round"
124 | stroke-linejoin="round"
125 | >
126 | <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
127 | <line x1="12" y1="11" x2="12" y2="17" />
128 | <line x1="9" y1="14" x2="15" y2="14" />
129 | </svg>
130 | </button>
131 |
132 | <!-- Theme & Settings Icon (Color Palette) -->
133 | <button
134 | class="p-1 ac-btn ac-hover-text"
135 | :style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
136 | @click="$emit('toggle:settingsMenu')"
137 | >
138 | <svg
139 | class="w-5 h-5"
140 | viewBox="0 0 24 24"
141 | fill="none"
142 | stroke="currentColor"
143 | stroke-width="2"
144 | stroke-linecap="round"
145 | stroke-linejoin="round"
146 | >
147 | <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
148 | <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
149 | <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
150 | <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
151 | <path
152 | d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
153 | />
154 | </svg>
155 | </button>
156 | </div>
157 | </div>
158 | </template>
159 |
160 | <script lang="ts" setup>
161 | import { computed } from 'vue';
162 |
163 | export type ConnectionState = 'ready' | 'connecting' | 'disconnected';
164 |
165 | const props = defineProps<{
166 | projectLabel: string;
167 | sessionLabel: string;
168 | connectionState: ConnectionState;
169 | /** Whether to show back button (for returning to sessions list) */
170 | showBackButton?: boolean;
171 | /** Brand label to display (e.g., "Claude Code", "Codex") */
172 | brandLabel?: string;
173 | }>();
174 |
175 | defineEmits<{
176 | 'toggle:projectMenu': [];
177 | 'toggle:sessionMenu': [];
178 | 'toggle:settingsMenu': [];
179 | 'toggle:openProjectMenu': [];
180 | /** Emitted when back button is clicked */
181 | back: [];
182 | }>();
183 |
184 | const connectionColor = computed(() => {
185 | switch (props.connectionState) {
186 | case 'ready':
187 | return 'var(--ac-success)';
188 | case 'connecting':
189 | return 'var(--ac-warning)';
190 | default:
191 | return 'var(--ac-text-subtle)';
192 | }
193 | });
194 |
195 | const connectionText = computed(() => {
196 | switch (props.connectionState) {
197 | case 'ready':
198 | return 'Connected';
199 | case 'connecting':
200 | return 'Connecting...';
201 | default:
202 | return 'Disconnected';
203 | }
204 | });
205 | </script>
206 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * @fileoverview Interval Trigger Handler (M3.1)
3 | * @description
4 | * 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。
5 | *
6 | * 策略:
7 | * - 每个触发器对应一个重复 alarm
8 | * - 使用 delayInMinutes 使首次触发在配置的间隔后
9 | */
10 |
11 | import type { TriggerId } from '../../domain/ids';
12 | import type { TriggerSpecByKind } from '../../domain/triggers';
13 | import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
14 |
15 | // ==================== Types ====================
16 |
17 | type IntervalTriggerSpec = TriggerSpecByKind<'interval'>;
18 |
19 | export interface IntervalTriggerHandlerDeps {
20 | logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
21 | }
22 |
23 | interface InstalledIntervalTrigger {
24 | spec: IntervalTriggerSpec;
25 | periodMinutes: number;
26 | version: number;
27 | }
28 |
29 | // ==================== Constants ====================
30 |
31 | const ALARM_PREFIX = 'rr_v3_interval_';
32 |
33 | // ==================== Utilities ====================
34 |
35 | /**
36 | * 校验并规范化 periodMinutes
37 | */
38 | function normalizePeriodMinutes(value: unknown): number {
39 | if (typeof value !== 'number' || !Number.isFinite(value)) {
40 | throw new Error('periodMinutes must be a finite number');
41 | }
42 | if (value < 1) {
43 | throw new Error('periodMinutes must be >= 1');
44 | }
45 | return value;
46 | }
47 |
48 | /**
49 | * 生成 alarm 名称
50 | */
51 | function alarmNameForTrigger(triggerId: TriggerId): string {
52 | return `${ALARM_PREFIX}${triggerId}`;
53 | }
54 |
55 | /**
56 | * 从 alarm 名称解析 triggerId
57 | */
58 | function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
59 | if (!name.startsWith(ALARM_PREFIX)) return null;
60 | const id = name.slice(ALARM_PREFIX.length);
61 | return id ? (id as TriggerId) : null;
62 | }
63 |
64 | // ==================== Handler Implementation ====================
65 |
66 | /**
67 | * 创建 interval 触发器处理器工厂
68 | */
69 | export function createIntervalTriggerHandlerFactory(
70 | deps?: IntervalTriggerHandlerDeps,
71 | ): TriggerHandlerFactory<'interval'> {
72 | return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);
73 | }
74 |
75 | /**
76 | * 创建 interval 触发器处理器
77 | */
78 | export function createIntervalTriggerHandler(
79 | fireCallback: TriggerFireCallback,
80 | deps?: IntervalTriggerHandlerDeps,
81 | ): TriggerHandler<'interval'> {
82 | const logger = deps?.logger ?? console;
83 |
84 | const installed = new Map<TriggerId, InstalledIntervalTrigger>();
85 | const versions = new Map<TriggerId, number>();
86 | let listening = false;
87 |
88 | /**
89 | * 递增版本号以使挂起的操作失效
90 | */
91 | function bumpVersion(triggerId: TriggerId): number {
92 | const next = (versions.get(triggerId) ?? 0) + 1;
93 | versions.set(triggerId, next);
94 | return next;
95 | }
96 |
97 | /**
98 | * 清除指定 alarm
99 | */
100 | async function clearAlarmByName(name: string): Promise<void> {
101 | if (!chrome.alarms?.clear) return;
102 | try {
103 | await Promise.resolve(chrome.alarms.clear(name));
104 | } catch (e) {
105 | logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);
106 | }
107 | }
108 |
109 | /**
110 | * 清除所有 interval alarms
111 | */
112 | async function clearAllIntervalAlarms(): Promise<void> {
113 | if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
114 | try {
115 | const alarms = await Promise.resolve(chrome.alarms.getAll());
116 | const list = Array.isArray(alarms) ? alarms : [];
117 | await Promise.all(
118 | list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
119 | );
120 | } catch (e) {
121 | logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);
122 | }
123 | }
124 |
125 | /**
126 | * 调度 alarm
127 | */
128 | async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
129 | if (!chrome.alarms?.create) {
130 | logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');
131 | return;
132 | }
133 |
134 | const entry = installed.get(triggerId);
135 | if (!entry || entry.version !== expectedVersion) return;
136 |
137 | const name = alarmNameForTrigger(triggerId);
138 | const periodInMinutes = entry.periodMinutes;
139 |
140 | try {
141 | // 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm
142 | // 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发
143 | await Promise.resolve(
144 | chrome.alarms.create(name, {
145 | delayInMinutes: periodInMinutes,
146 | periodInMinutes,
147 | }),
148 | );
149 | } catch (e) {
150 | logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
151 | }
152 | }
153 |
154 | /**
155 | * Alarm 事件处理
156 | */
157 | const onAlarm = (alarm: chrome.alarms.Alarm): void => {
158 | const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
159 | if (!triggerId) return;
160 |
161 | const entry = installed.get(triggerId);
162 | if (!entry) return;
163 |
164 | // 触发回调
165 | Promise.resolve(
166 | fireCallback.onFire(triggerId, {
167 | sourceTabId: undefined,
168 | sourceUrl: undefined,
169 | }),
170 | ).catch((e) => {
171 | logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
172 | });
173 | };
174 |
175 | /**
176 | * 确保正在监听 alarm 事件
177 | */
178 | function ensureListening(): void {
179 | if (listening) return;
180 | if (!chrome.alarms?.onAlarm?.addListener) {
181 | logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');
182 | return;
183 | }
184 | chrome.alarms.onAlarm.addListener(onAlarm);
185 | listening = true;
186 | }
187 |
188 | /**
189 | * 停止监听 alarm 事件
190 | */
191 | function stopListening(): void {
192 | if (!listening) return;
193 | try {
194 | chrome.alarms.onAlarm.removeListener(onAlarm);
195 | } catch (e) {
196 | logger.debug('[IntervalTriggerHandler] removeListener failed:', e);
197 | } finally {
198 | listening = false;
199 | }
200 | }
201 |
202 | return {
203 | kind: 'interval',
204 |
205 | async install(trigger: IntervalTriggerSpec): Promise<void> {
206 | const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);
207 |
208 | const version = bumpVersion(trigger.id);
209 | installed.set(trigger.id, {
210 | spec: { ...trigger, periodMinutes },
211 | periodMinutes,
212 | version,
213 | });
214 |
215 | ensureListening();
216 | await schedule(trigger.id, version);
217 | },
218 |
219 | async uninstall(triggerId: string): Promise<void> {
220 | const id = triggerId as TriggerId;
221 | bumpVersion(id);
222 | installed.delete(id);
223 | await clearAlarmByName(alarmNameForTrigger(id));
224 |
225 | if (installed.size === 0) {
226 | stopListening();
227 | }
228 | },
229 |
230 | async uninstallAll(): Promise<void> {
231 | for (const id of installed.keys()) {
232 | bumpVersion(id);
233 | }
234 | installed.clear();
235 | await clearAllIntervalAlarms();
236 | stopListening();
237 | },
238 |
239 | getInstalledIds(): string[] {
240 | return Array.from(installed.keys());
241 | },
242 | };
243 | }
244 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/image-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Image processing utility functions
3 | */
4 |
5 | /**
6 | * Create ImageBitmap from data URL (for OffscreenCanvas)
7 | * @param dataUrl Image data URL
8 | * @returns Created ImageBitmap object
9 | */
10 | export async function createImageBitmapFromUrl(dataUrl: string): Promise<ImageBitmap> {
11 | const response = await fetch(dataUrl);
12 | const blob = await response.blob();
13 | return await createImageBitmap(blob);
14 | }
15 |
16 | /**
17 | * Stitch multiple image parts (dataURL) onto a single canvas
18 | * @param parts Array of image parts, each containing dataUrl and y coordinate
19 | * @param totalWidthPx Total width (pixels)
20 | * @param totalHeightPx Total height (pixels)
21 | * @returns Stitched canvas
22 | */
23 | export async function stitchImages(
24 | parts: { dataUrl: string; y: number }[],
25 | totalWidthPx: number,
26 | totalHeightPx: number,
27 | ): Promise<OffscreenCanvas> {
28 | const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx);
29 | const ctx = canvas.getContext('2d');
30 |
31 | if (!ctx) {
32 | throw new Error('Unable to get canvas context');
33 | }
34 |
35 | ctx.fillStyle = '#FFFFFF';
36 | ctx.fillRect(0, 0, canvas.width, canvas.height);
37 |
38 | for (const part of parts) {
39 | try {
40 | const img = await createImageBitmapFromUrl(part.dataUrl);
41 | const sx = 0;
42 | const sy = 0;
43 | const sWidth = img.width;
44 | let sHeight = img.height;
45 | const dy = part.y;
46 |
47 | if (dy + sHeight > totalHeightPx) {
48 | sHeight = totalHeightPx - dy;
49 | }
50 |
51 | if (sHeight <= 0) continue;
52 |
53 | ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight);
54 | } catch (error) {
55 | console.error('Error stitching image part:', error, part);
56 | }
57 | }
58 | return canvas;
59 | }
60 |
61 | /**
62 | * Crop image (from dataURL) to specified rectangle and resize
63 | * @param originalDataUrl Original image data URL
64 | * @param cropRectPx Crop rectangle (physical pixels)
65 | * @param dpr Device pixel ratio
66 | * @param targetWidthOpt Optional target output width (CSS pixels)
67 | * @param targetHeightOpt Optional target output height (CSS pixels)
68 | * @returns Cropped canvas
69 | */
70 | export async function cropAndResizeImage(
71 | originalDataUrl: string,
72 | cropRectPx: { x: number; y: number; width: number; height: number },
73 | dpr: number = 1,
74 | targetWidthOpt?: number,
75 | targetHeightOpt?: number,
76 | ): Promise<OffscreenCanvas> {
77 | const img = await createImageBitmapFromUrl(originalDataUrl);
78 |
79 | let sx = cropRectPx.x;
80 | let sy = cropRectPx.y;
81 | let sWidth = cropRectPx.width;
82 | let sHeight = cropRectPx.height;
83 |
84 | // Ensure crop area is within image boundaries
85 | if (sx < 0) {
86 | sWidth += sx;
87 | sx = 0;
88 | }
89 | if (sy < 0) {
90 | sHeight += sy;
91 | sy = 0;
92 | }
93 | if (sx + sWidth > img.width) {
94 | sWidth = img.width - sx;
95 | }
96 | if (sy + sHeight > img.height) {
97 | sHeight = img.height - sy;
98 | }
99 |
100 | if (sWidth <= 0 || sHeight <= 0) {
101 | throw new Error(
102 | 'Invalid calculated crop size (<=0). Element may not be visible or fully captured.',
103 | );
104 | }
105 |
106 | const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth;
107 | const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight;
108 |
109 | const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx);
110 | const ctx = canvas.getContext('2d');
111 |
112 | if (!ctx) {
113 | throw new Error('Unable to get canvas context');
114 | }
115 |
116 | ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx);
117 |
118 | return canvas;
119 | }
120 |
121 | /**
122 | * Convert canvas to data URL
123 | * @param canvas Canvas
124 | * @param format Image format
125 | * @param quality JPEG quality (0-1)
126 | * @returns Data URL
127 | */
128 | export async function canvasToDataURL(
129 | canvas: OffscreenCanvas,
130 | format: string = 'image/png',
131 | quality?: number,
132 | ): Promise<string> {
133 | const blob = await canvas.convertToBlob({
134 | type: format,
135 | quality: format === 'image/jpeg' ? quality : undefined,
136 | });
137 |
138 | return new Promise((resolve, reject) => {
139 | const reader = new FileReader();
140 | reader.onloadend = () => resolve(reader.result as string);
141 | reader.onerror = reject;
142 | reader.readAsDataURL(blob);
143 | });
144 | }
145 |
146 | /**
147 | * Compresses an image by scaling it and converting it to a target format with a specific quality.
148 | * This is the most effective way to reduce image data size for transport or storage.
149 | *
150 | * @param {string} imageDataUrl - The original image data URL (e.g., from captureVisibleTab).
151 | * @param {object} options - Compression options.
152 | * @param {number} [options.scale=1.0] - The scaling factor for dimensions (e.g., 0.7 for 70%).
153 | * @param {number} [options.quality=0.8] - The quality for lossy formats like JPEG (0.0 to 1.0).
154 | * @param {string} [options.format='image/jpeg'] - The target image format.
155 | * @returns {Promise<{dataUrl: string, mimeType: string}>} A promise that resolves to the compressed image data URL and its MIME type.
156 | */
157 | export async function compressImage(
158 | imageDataUrl: string,
159 | options: { scale?: number; quality?: number; format?: 'image/jpeg' | 'image/webp' },
160 | ): Promise<{ dataUrl: string; mimeType: string }> {
161 | const { scale = 1.0, quality = 0.8, format = 'image/jpeg' } = options;
162 |
163 | // 1. Create an ImageBitmap from the original data URL for efficient drawing.
164 | const imageBitmap = await createImageBitmapFromUrl(imageDataUrl);
165 |
166 | // 2. Calculate the new dimensions based on the scale factor.
167 | const newWidth = Math.round(imageBitmap.width * scale);
168 | const newHeight = Math.round(imageBitmap.height * scale);
169 |
170 | // 3. Use OffscreenCanvas for performance, as it doesn't need to be in the DOM.
171 | const canvas = new OffscreenCanvas(newWidth, newHeight);
172 | const ctx = canvas.getContext('2d');
173 |
174 | if (!ctx) {
175 | throw new Error('Failed to get 2D context from OffscreenCanvas');
176 | }
177 |
178 | // 4. Draw the original image onto the smaller canvas, effectively resizing it.
179 | ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);
180 |
181 | // 5. Export the canvas content to the target format with the specified quality.
182 | // This is the step that performs the data compression.
183 | const compressedDataUrl = await canvas.convertToBlob({ type: format, quality: quality });
184 |
185 | // A helper to convert blob to data URL since OffscreenCanvas.toDataURL is not standard yet
186 | // on all execution contexts (like service workers).
187 | const dataUrl = await new Promise<string>((resolve) => {
188 | const reader = new FileReader();
189 | reader.onloadend = () => resolve(reader.result as string);
190 | reader.readAsDataURL(compressedDataUrl);
191 | });
192 |
193 | return { dataUrl, mimeType: format };
194 | }
195 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Key Action Handler
3 | *
4 | * Handles keyboard input:
5 | * - Resolves key sequences via variables/templates
6 | * - Optionally focuses a target element before sending keys
7 | * - Dispatches keyboard events via the keyboard tool
8 | */
9 |
10 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
11 | import { handleCallTool } from '@/entrypoints/background/tools';
12 | import { TOOL_NAMES } from 'chrome-mcp-shared';
13 | import { failed, invalid, ok } from '../registry';
14 | import type { ActionHandler, ElementTarget } from '../types';
15 | import {
16 | ensureElementVisible,
17 | logSelectorFallback,
18 | resolveString,
19 | selectorLocator,
20 | sendMessageToTab,
21 | toSelectorTarget,
22 | } from './common';
23 |
24 | /** Extract error text from tool result */
25 | function extractToolError(result: unknown, fallback: string): string {
26 | const content = (result as { content?: Array<{ text?: string }> })?.content;
27 | return content?.find((c) => typeof c?.text === 'string')?.text || fallback;
28 | }
29 |
30 | /** Check if target has valid selector specification */
31 | function hasTargetSpec(target: unknown): boolean {
32 | if (!target || typeof target !== 'object') return false;
33 | const t = target as { ref?: unknown; candidates?: unknown };
34 | const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
35 | const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
36 | return hasRef || hasCandidates;
37 | }
38 |
39 | /** Strip frame prefix from composite selector */
40 | function stripCompositeSelector(selector: string): string {
41 | const raw = String(selector || '').trim();
42 | if (!raw || !raw.includes('|>')) return raw;
43 | const parts = raw
44 | .split('|>')
45 | .map((p) => p.trim())
46 | .filter(Boolean);
47 | return parts.length > 0 ? parts[parts.length - 1] : raw;
48 | }
49 |
50 | export const keyHandler: ActionHandler<'key'> = {
51 | type: 'key',
52 |
53 | validate: (action) => {
54 | if (action.params.keys === undefined) {
55 | return invalid('Missing keys parameter');
56 | }
57 |
58 | if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) {
59 | return invalid('Target must include a non-empty ref or selector candidates');
60 | }
61 |
62 | return ok();
63 | },
64 |
65 | describe: (action) => {
66 | const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)';
67 | const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys;
68 | return `Keys "${display}"`;
69 | },
70 |
71 | run: async (ctx, action) => {
72 | const vars = ctx.vars;
73 | const tabId = ctx.tabId;
74 |
75 | if (typeof tabId !== 'number') {
76 | return failed('TAB_NOT_FOUND', 'No active tab found for key action');
77 | }
78 |
79 | // Resolve keys string
80 | const keysResolved = resolveString(action.params.keys, vars);
81 | if (!keysResolved.ok) {
82 | return failed('VALIDATION_ERROR', keysResolved.error);
83 | }
84 |
85 | const keys = keysResolved.value.trim();
86 | if (!keys) {
87 | return failed('VALIDATION_ERROR', 'Keys string is empty');
88 | }
89 |
90 | let frameId = ctx.frameId;
91 | let selectorForTool: string | undefined;
92 | let firstCandidateType: string | undefined;
93 | let resolvedBy: string | undefined;
94 |
95 | // Handle optional target focusing
96 | const target = action.params.target as ElementTarget | undefined;
97 | if (target) {
98 | await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
99 |
100 | const {
101 | selectorTarget,
102 | firstCandidateType: firstType,
103 | firstCssOrAttr,
104 | } = toSelectorTarget(target, vars);
105 | firstCandidateType = firstType;
106 |
107 | const located = await selectorLocator.locate(tabId, selectorTarget, {
108 | frameId: ctx.frameId,
109 | preferRef: false,
110 | });
111 |
112 | frameId = located?.frameId ?? ctx.frameId;
113 | const refToUse = located?.ref ?? selectorTarget.ref;
114 |
115 | if (!refToUse && !firstCssOrAttr) {
116 | return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action');
117 | }
118 |
119 | resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
120 |
121 | // Only verify visibility for freshly located refs (not stale refs from payload)
122 | if (located?.ref) {
123 | const visible = await ensureElementVisible(tabId, located.ref, frameId);
124 | if (!visible) {
125 | return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
126 | }
127 |
128 | const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>(
129 | tabId,
130 | { action: 'focusByRef', ref: located.ref },
131 | frameId,
132 | );
133 |
134 | if (!focusResult.ok || focusResult.value?.success !== true) {
135 | const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error;
136 |
137 | if (!firstCssOrAttr) {
138 | return failed(
139 | 'TARGET_NOT_FOUND',
140 | `Failed to focus target element: ${focusErr || 'ref may be stale'}`,
141 | );
142 | }
143 |
144 | ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn');
145 | }
146 |
147 | // Try to resolve ref to CSS selector for tool
148 | const resolved = await sendMessageToTab<{
149 | success?: boolean;
150 | selector?: string;
151 | error?: string;
152 | }>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId);
153 |
154 | if (
155 | resolved.ok &&
156 | resolved.value?.success !== false &&
157 | typeof resolved.value?.selector === 'string'
158 | ) {
159 | const sel = resolved.value.selector.trim();
160 | if (sel) selectorForTool = sel;
161 | }
162 | }
163 |
164 | // Fallback to CSS/attr selector
165 | if (!selectorForTool && firstCssOrAttr) {
166 | const stripped = stripCompositeSelector(firstCssOrAttr);
167 | if (stripped) selectorForTool = stripped;
168 | }
169 | }
170 |
171 | // Execute keyboard input
172 | const keyboardResult = await handleCallTool({
173 | name: TOOL_NAMES.BROWSER.KEYBOARD,
174 | args: {
175 | keys,
176 | selector: selectorForTool,
177 | selectorType: selectorForTool ? 'css' : undefined,
178 | tabId,
179 | frameId,
180 | },
181 | });
182 |
183 | if ((keyboardResult as { isError?: boolean })?.isError) {
184 | return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed'));
185 | }
186 |
187 | // Log fallback after successful execution
188 | const fallbackUsed =
189 | resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
190 | if (fallbackUsed) {
191 | logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
192 | }
193 |
194 | return { status: 'success' };
195 | },
196 | };
197 |
```