This is page 46 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/utils/vector-database.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Vector database manager
3 | * Uses hnswlib-wasm for high-performance vector similarity search
4 | * Implements singleton pattern to avoid duplicate WASM module initialization
5 | */
6 |
7 | import { loadHnswlib } from 'hnswlib-wasm-static';
8 | import type { TextChunk } from './text-chunker';
9 |
10 | export interface VectorDocument {
11 | id: string;
12 | tabId: number;
13 | url: string;
14 | title: string;
15 | chunk: TextChunk;
16 | embedding: Float32Array;
17 | timestamp: number;
18 | }
19 |
20 | export interface SearchResult {
21 | document: VectorDocument;
22 | similarity: number;
23 | distance: number;
24 | }
25 |
26 | export interface VectorDatabaseConfig {
27 | dimension: number;
28 | maxElements: number;
29 | efConstruction: number;
30 | M: number;
31 | efSearch: number;
32 | indexFileName: string;
33 | enableAutoCleanup?: boolean;
34 | maxRetentionDays?: number;
35 | }
36 |
37 | let globalHnswlib: any = null;
38 | let globalHnswlibInitPromise: Promise<any> | null = null;
39 | let globalHnswlibInitialized = false;
40 |
41 | let syncInProgress = false;
42 | let pendingSyncPromise: Promise<void> | null = null;
43 |
44 | const DB_NAME = 'VectorDatabaseStorage';
45 | const DB_VERSION = 1;
46 | const STORE_NAME = 'documentMappings';
47 |
48 | /**
49 | * IndexedDB helper functions
50 | */
51 | class IndexedDBHelper {
52 | private static dbPromise: Promise<IDBDatabase> | null = null;
53 |
54 | static async getDB(): Promise<IDBDatabase> {
55 | if (!this.dbPromise) {
56 | this.dbPromise = new Promise((resolve, reject) => {
57 | const request = indexedDB.open(DB_NAME, DB_VERSION);
58 |
59 | request.onerror = () => reject(request.error);
60 | request.onsuccess = () => resolve(request.result);
61 |
62 | request.onupgradeneeded = (event) => {
63 | const db = (event.target as IDBOpenDBRequest).result;
64 |
65 | if (!db.objectStoreNames.contains(STORE_NAME)) {
66 | const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
67 | store.createIndex('indexFileName', 'indexFileName', { unique: false });
68 | }
69 | };
70 | });
71 | }
72 | return this.dbPromise;
73 | }
74 |
75 | static async saveData(indexFileName: string, data: any): Promise<void> {
76 | const db = await this.getDB();
77 | const transaction = db.transaction([STORE_NAME], 'readwrite');
78 | const store = transaction.objectStore(STORE_NAME);
79 |
80 | await new Promise<void>((resolve, reject) => {
81 | const request = store.put({
82 | id: indexFileName,
83 | indexFileName,
84 | data,
85 | timestamp: Date.now(),
86 | });
87 |
88 | request.onsuccess = () => resolve();
89 | request.onerror = () => reject(request.error);
90 | });
91 | }
92 |
93 | static async loadData(indexFileName: string): Promise<any | null> {
94 | const db = await this.getDB();
95 | const transaction = db.transaction([STORE_NAME], 'readonly');
96 | const store = transaction.objectStore(STORE_NAME);
97 |
98 | return new Promise<any | null>((resolve, reject) => {
99 | const request = store.get(indexFileName);
100 |
101 | request.onsuccess = () => {
102 | const result = request.result;
103 | resolve(result ? result.data : null);
104 | };
105 | request.onerror = () => reject(request.error);
106 | });
107 | }
108 |
109 | static async deleteData(indexFileName: string): Promise<void> {
110 | const db = await this.getDB();
111 | const transaction = db.transaction([STORE_NAME], 'readwrite');
112 | const store = transaction.objectStore(STORE_NAME);
113 |
114 | await new Promise<void>((resolve, reject) => {
115 | const request = store.delete(indexFileName);
116 | request.onsuccess = () => resolve();
117 | request.onerror = () => reject(request.error);
118 | });
119 | }
120 |
121 | /**
122 | * Clear all IndexedDB data (for complete cleanup during model switching)
123 | */
124 | static async clearAllData(): Promise<void> {
125 | try {
126 | const db = await this.getDB();
127 | const transaction = db.transaction([STORE_NAME], 'readwrite');
128 | const store = transaction.objectStore(STORE_NAME);
129 |
130 | await new Promise<void>((resolve, reject) => {
131 | const request = store.clear();
132 | request.onsuccess = () => {
133 | console.log('IndexedDBHelper: All data cleared from IndexedDB');
134 | resolve();
135 | };
136 | request.onerror = () => reject(request.error);
137 | });
138 | } catch (error) {
139 | console.error('IndexedDBHelper: Failed to clear all data:', error);
140 | throw error;
141 | }
142 | }
143 |
144 | /**
145 | * Get all stored keys (for debugging)
146 | */
147 | static async getAllKeys(): Promise<string[]> {
148 | try {
149 | const db = await this.getDB();
150 | const transaction = db.transaction([STORE_NAME], 'readonly');
151 | const store = transaction.objectStore(STORE_NAME);
152 |
153 | return new Promise<string[]>((resolve, reject) => {
154 | const request = store.getAllKeys();
155 | request.onsuccess = () => resolve(request.result as string[]);
156 | request.onerror = () => reject(request.error);
157 | });
158 | } catch (error) {
159 | console.error('IndexedDBHelper: Failed to get all keys:', error);
160 | return [];
161 | }
162 | }
163 | }
164 |
165 | /**
166 | * Global hnswlib-wasm initialization function
167 | * Ensures initialization only once across the entire application
168 | */
169 | async function initializeGlobalHnswlib(): Promise<any> {
170 | if (globalHnswlibInitialized && globalHnswlib) {
171 | return globalHnswlib;
172 | }
173 |
174 | if (globalHnswlibInitPromise) {
175 | return globalHnswlibInitPromise;
176 | }
177 |
178 | globalHnswlibInitPromise = (async () => {
179 | try {
180 | console.log('VectorDatabase: Initializing global hnswlib-wasm instance...');
181 | globalHnswlib = await loadHnswlib();
182 | globalHnswlibInitialized = true;
183 | console.log('VectorDatabase: Global hnswlib-wasm instance initialized successfully');
184 | return globalHnswlib;
185 | } catch (error) {
186 | console.error('VectorDatabase: Failed to initialize global hnswlib-wasm:', error);
187 | globalHnswlibInitPromise = null;
188 | throw error;
189 | }
190 | })();
191 |
192 | return globalHnswlibInitPromise;
193 | }
194 |
195 | export class VectorDatabase {
196 | private index: any = null;
197 | private isInitialized = false;
198 | private isInitializing = false;
199 | private initPromise: Promise<void> | null = null;
200 |
201 | private documents = new Map<number, VectorDocument>();
202 | private tabDocuments = new Map<number, Set<number>>();
203 | private nextLabel = 0;
204 |
205 | private readonly config: VectorDatabaseConfig;
206 |
207 | constructor(config?: Partial<VectorDatabaseConfig>) {
208 | this.config = {
209 | dimension: 384,
210 | maxElements: 100000,
211 | efConstruction: 200,
212 | M: 48,
213 | efSearch: 50,
214 | indexFileName: 'tab_content_index.dat',
215 | enableAutoCleanup: true,
216 | maxRetentionDays: 30,
217 | ...config,
218 | };
219 |
220 | console.log('VectorDatabase: Initialized with config:', {
221 | dimension: this.config.dimension,
222 | efSearch: this.config.efSearch,
223 | M: this.config.M,
224 | efConstruction: this.config.efConstruction,
225 | enableAutoCleanup: this.config.enableAutoCleanup,
226 | maxRetentionDays: this.config.maxRetentionDays,
227 | });
228 | }
229 |
230 | /**
231 | * Initialize vector database
232 | */
233 | public async initialize(): Promise<void> {
234 | if (this.isInitialized) return;
235 | if (this.isInitializing && this.initPromise) return this.initPromise;
236 |
237 | this.isInitializing = true;
238 | this.initPromise = this._doInitialize().finally(() => {
239 | this.isInitializing = false;
240 | });
241 |
242 | return this.initPromise;
243 | }
244 |
245 | private async _doInitialize(): Promise<void> {
246 | try {
247 | console.log('VectorDatabase: Initializing...');
248 |
249 | const hnswlib = await initializeGlobalHnswlib();
250 |
251 | hnswlib.EmscriptenFileSystemManager.setDebugLogs(true);
252 |
253 | this.index = new hnswlib.HierarchicalNSW(
254 | 'cosine',
255 | this.config.dimension,
256 | this.config.indexFileName,
257 | );
258 |
259 | await this.syncFileSystem('read');
260 |
261 | const indexExists = hnswlib.EmscriptenFileSystemManager.checkFileExists(
262 | this.config.indexFileName,
263 | );
264 |
265 | if (indexExists) {
266 | console.log('VectorDatabase: Loading existing index...');
267 | try {
268 | await this.index.readIndex(this.config.indexFileName, this.config.maxElements);
269 | this.index.setEfSearch(this.config.efSearch);
270 |
271 | await this.loadDocumentMappings();
272 |
273 | if (this.documents.size > 0) {
274 | const maxLabel = Math.max(...Array.from(this.documents.keys()));
275 | this.nextLabel = maxLabel + 1;
276 | console.log(
277 | `VectorDatabase: Loaded existing index with ${this.documents.size} documents, next label: ${this.nextLabel}`,
278 | );
279 | } else {
280 | const indexCount = this.index.getCurrentCount();
281 | if (indexCount > 0) {
282 | console.warn(
283 | `VectorDatabase: Index has ${indexCount} vectors but no document mappings found. This may cause label mismatch.`,
284 | );
285 | this.nextLabel = indexCount;
286 | } else {
287 | this.nextLabel = 0;
288 | }
289 | console.log(
290 | `VectorDatabase: No document mappings found, starting with next label: ${this.nextLabel}`,
291 | );
292 | }
293 | } catch (loadError) {
294 | console.warn(
295 | 'VectorDatabase: Failed to load existing index, creating new one:',
296 | loadError,
297 | );
298 |
299 | this.index.initIndex(
300 | this.config.maxElements,
301 | this.config.M,
302 | this.config.efConstruction,
303 | 200,
304 | );
305 | this.index.setEfSearch(this.config.efSearch);
306 | this.nextLabel = 0;
307 | }
308 | } else {
309 | console.log('VectorDatabase: Creating new index...');
310 | this.index.initIndex(
311 | this.config.maxElements,
312 | this.config.M,
313 | this.config.efConstruction,
314 | 200,
315 | );
316 | this.index.setEfSearch(this.config.efSearch);
317 | this.nextLabel = 0;
318 | }
319 |
320 | this.isInitialized = true;
321 | console.log('VectorDatabase: Initialization completed successfully');
322 | } catch (error) {
323 | console.error('VectorDatabase: Initialization failed:', error);
324 | this.isInitialized = false;
325 | throw error;
326 | }
327 | }
328 |
329 | /**
330 | * Add document to vector database
331 | */
332 | public async addDocument(
333 | tabId: number,
334 | url: string,
335 | title: string,
336 | chunk: TextChunk,
337 | embedding: Float32Array,
338 | ): Promise<number> {
339 | if (!this.isInitialized) {
340 | await this.initialize();
341 | }
342 |
343 | const documentId = this.generateDocumentId(tabId, chunk.index);
344 | const document: VectorDocument = {
345 | id: documentId,
346 | tabId,
347 | url,
348 | title,
349 | chunk,
350 | embedding,
351 | timestamp: Date.now(),
352 | };
353 |
354 | try {
355 | // Validate vector data
356 | if (!embedding || embedding.length !== this.config.dimension) {
357 | const errorMsg = `Invalid embedding dimension: expected ${this.config.dimension}, got ${embedding?.length || 0}`;
358 | console.error('VectorDatabase: Dimension mismatch detected!', {
359 | expectedDimension: this.config.dimension,
360 | actualDimension: embedding?.length || 0,
361 | documentId,
362 | tabId,
363 | url,
364 | title: title.substring(0, 50) + '...',
365 | });
366 |
367 | // This might be caused by model switching, suggest reinitialization
368 | console.warn(
369 | 'VectorDatabase: This might be caused by model switching. Consider reinitializing the vector database with the correct dimension.',
370 | );
371 |
372 | throw new Error(errorMsg);
373 | }
374 |
375 | // Check if vector data contains invalid values
376 | for (let i = 0; i < embedding.length; i++) {
377 | if (!isFinite(embedding[i])) {
378 | throw new Error(`Invalid embedding value at index ${i}: ${embedding[i]}`);
379 | }
380 | }
381 |
382 | // Ensure we have a clean Float32Array
383 | let cleanEmbedding: Float32Array;
384 | if (embedding instanceof Float32Array) {
385 | cleanEmbedding = embedding;
386 | } else {
387 | cleanEmbedding = new Float32Array(embedding);
388 | }
389 |
390 | // Use current nextLabel as label
391 | const label = this.nextLabel++;
392 |
393 | console.log(
394 | `VectorDatabase: Adding document with label ${label}, embedding dimension: ${embedding.length}`,
395 | );
396 |
397 | // Add vector to index
398 | // According to hnswlib-wasm-static emscripten binding requirements, need to create VectorFloat type
399 | console.log(`VectorDatabase: 🔧 DEBUGGING - About to call addPoint with:`, {
400 | embeddingType: typeof cleanEmbedding,
401 | isFloat32Array: cleanEmbedding instanceof Float32Array,
402 | length: cleanEmbedding.length,
403 | firstFewValues: Array.from(cleanEmbedding.slice(0, 3)),
404 | label: label,
405 | replaceDeleted: false,
406 | });
407 |
408 | // Method 1: Try using VectorFloat constructor (if available)
409 | let vectorToAdd;
410 | try {
411 | // Check if VectorFloat constructor exists
412 | if (globalHnswlib && globalHnswlib.VectorFloat) {
413 | console.log('VectorDatabase: Using VectorFloat constructor');
414 | vectorToAdd = new globalHnswlib.VectorFloat();
415 | // Add elements to VectorFloat one by one
416 | for (let i = 0; i < cleanEmbedding.length; i++) {
417 | vectorToAdd.push_back(cleanEmbedding[i]);
418 | }
419 | } else {
420 | // Method 2: Use plain JS array (fallback)
421 | console.log('VectorDatabase: Using plain JS array as fallback');
422 | vectorToAdd = Array.from(cleanEmbedding);
423 | }
424 |
425 | // Call addPoint with constructed vector
426 | this.index.addPoint(vectorToAdd, label, false);
427 |
428 | // Clean up VectorFloat object (if manually created)
429 | if (vectorToAdd && typeof vectorToAdd.delete === 'function') {
430 | vectorToAdd.delete();
431 | }
432 | } catch (vectorError) {
433 | console.error(
434 | 'VectorDatabase: VectorFloat approach failed, trying alternatives:',
435 | vectorError,
436 | );
437 |
438 | // Method 3: Try passing Float32Array directly
439 | try {
440 | console.log('VectorDatabase: Trying Float32Array directly');
441 | this.index.addPoint(cleanEmbedding, label, false);
442 | } catch (float32Error) {
443 | console.error('VectorDatabase: Float32Array approach failed:', float32Error);
444 |
445 | // Method 4: Last resort - use spread operator
446 | console.log('VectorDatabase: Trying spread operator as last resort');
447 | this.index.addPoint([...cleanEmbedding], label, false);
448 | }
449 | }
450 | console.log(`VectorDatabase: ✅ Successfully added document with label ${label}`);
451 |
452 | // Store document mapping
453 | this.documents.set(label, document);
454 |
455 | // Update tab document mapping
456 | if (!this.tabDocuments.has(tabId)) {
457 | this.tabDocuments.set(tabId, new Set());
458 | }
459 | this.tabDocuments.get(tabId)!.add(label);
460 |
461 | // Save index and mappings
462 | await this.saveIndex();
463 | await this.saveDocumentMappings();
464 |
465 | // Check if auto cleanup is needed
466 | if (this.config.enableAutoCleanup) {
467 | await this.checkAndPerformAutoCleanup();
468 | }
469 |
470 | console.log(`VectorDatabase: Successfully added document ${documentId} with label ${label}`);
471 | return label;
472 | } catch (error) {
473 | console.error('VectorDatabase: Failed to add document:', error);
474 | console.error('VectorDatabase: Embedding info:', {
475 | type: typeof embedding,
476 | constructor: embedding?.constructor?.name,
477 | length: embedding?.length,
478 | isFloat32Array: embedding instanceof Float32Array,
479 | firstFewValues: embedding ? Array.from(embedding.slice(0, 5)) : null,
480 | });
481 | throw error;
482 | }
483 | }
484 |
485 | /**
486 | * Search similar documents
487 | */
488 | public async search(queryEmbedding: Float32Array, topK: number = 10): Promise<SearchResult[]> {
489 | if (!this.isInitialized) {
490 | await this.initialize();
491 | }
492 |
493 | try {
494 | // Validate query vector
495 | if (!queryEmbedding || queryEmbedding.length !== this.config.dimension) {
496 | throw new Error(
497 | `Invalid query embedding dimension: expected ${this.config.dimension}, got ${queryEmbedding?.length || 0}`,
498 | );
499 | }
500 |
501 | // Check if query vector contains invalid values
502 | for (let i = 0; i < queryEmbedding.length; i++) {
503 | if (!isFinite(queryEmbedding[i])) {
504 | throw new Error(`Invalid query embedding value at index ${i}: ${queryEmbedding[i]}`);
505 | }
506 | }
507 |
508 | console.log(
509 | `VectorDatabase: Searching with query embedding dimension: ${queryEmbedding.length}, topK: ${topK}`,
510 | );
511 |
512 | // Check if index is empty
513 | const currentCount = this.index.getCurrentCount();
514 | if (currentCount === 0) {
515 | console.log('VectorDatabase: Index is empty, returning no results');
516 | return [];
517 | }
518 |
519 | console.log(`VectorDatabase: Index contains ${currentCount} vectors`);
520 |
521 | // Check if document mapping and index are synchronized
522 | const mappingCount = this.documents.size;
523 | if (mappingCount === 0 && currentCount > 0) {
524 | console.warn(
525 | `VectorDatabase: Index has ${currentCount} vectors but document mapping is empty. Attempting to reload mappings...`,
526 | );
527 | await this.loadDocumentMappings();
528 |
529 | if (this.documents.size === 0) {
530 | console.error(
531 | 'VectorDatabase: Failed to load document mappings. Index and mappings are out of sync.',
532 | );
533 | return [];
534 | }
535 | console.log(
536 | `VectorDatabase: Successfully reloaded ${this.documents.size} document mappings`,
537 | );
538 | }
539 |
540 | // Process query vector according to hnswlib-wasm-static emscripten binding requirements
541 | let queryVector;
542 | let searchResult;
543 |
544 | try {
545 | // Method 1: Try using VectorFloat constructor (if available)
546 | if (globalHnswlib && globalHnswlib.VectorFloat) {
547 | console.log('VectorDatabase: Using VectorFloat for search query');
548 | queryVector = new globalHnswlib.VectorFloat();
549 | // Add elements to VectorFloat one by one
550 | for (let i = 0; i < queryEmbedding.length; i++) {
551 | queryVector.push_back(queryEmbedding[i]);
552 | }
553 | searchResult = this.index.searchKnn(queryVector, topK, undefined);
554 |
555 | // Clean up VectorFloat object
556 | if (queryVector && typeof queryVector.delete === 'function') {
557 | queryVector.delete();
558 | }
559 | } else {
560 | // Method 2: Use plain JS array (fallback)
561 | console.log('VectorDatabase: Using plain JS array for search query');
562 | const queryArray = Array.from(queryEmbedding);
563 | searchResult = this.index.searchKnn(queryArray, topK, undefined);
564 | }
565 | } catch (vectorError) {
566 | console.error(
567 | 'VectorDatabase: VectorFloat search failed, trying alternatives:',
568 | vectorError,
569 | );
570 |
571 | // Method 3: Try passing Float32Array directly
572 | try {
573 | console.log('VectorDatabase: Trying Float32Array directly for search');
574 | searchResult = this.index.searchKnn(queryEmbedding, topK, undefined);
575 | } catch (float32Error) {
576 | console.error('VectorDatabase: Float32Array search failed:', float32Error);
577 |
578 | // Method 4: Last resort - use spread operator
579 | console.log('VectorDatabase: Trying spread operator for search as last resort');
580 | searchResult = this.index.searchKnn([...queryEmbedding], topK, undefined);
581 | }
582 | }
583 |
584 | const results: SearchResult[] = [];
585 |
586 | console.log(`VectorDatabase: Processing ${searchResult.neighbors.length} search neighbors`);
587 | console.log(`VectorDatabase: Available documents in mapping: ${this.documents.size}`);
588 | console.log(`VectorDatabase: Index current count: ${this.index.getCurrentCount()}`);
589 |
590 | for (let i = 0; i < searchResult.neighbors.length; i++) {
591 | const label = searchResult.neighbors[i];
592 | const distance = searchResult.distances[i];
593 | const similarity = 1 - distance; // Convert cosine distance to similarity
594 |
595 | console.log(
596 | `VectorDatabase: Processing neighbor ${i}: label=${label}, distance=${distance}, similarity=${similarity}`,
597 | );
598 |
599 | // Find corresponding document by label
600 | const document = this.findDocumentByLabel(label);
601 | if (document) {
602 | console.log(`VectorDatabase: Found document for label ${label}: ${document.id}`);
603 | results.push({
604 | document,
605 | similarity,
606 | distance,
607 | });
608 | } else {
609 | console.warn(`VectorDatabase: No document found for label ${label}`);
610 |
611 | // Detailed debug information
612 | if (i < 5) {
613 | // Only show detailed info for first 5 neighbors to avoid log spam
614 | console.warn(
615 | `VectorDatabase: Available labels (first 20): ${Array.from(this.documents.keys()).slice(0, 20).join(', ')}`,
616 | );
617 | console.warn(`VectorDatabase: Total available labels: ${this.documents.size}`);
618 | console.warn(
619 | `VectorDatabase: Label type: ${typeof label}, Available label types: ${Array.from(
620 | this.documents.keys(),
621 | )
622 | .slice(0, 3)
623 | .map((k) => typeof k)
624 | .join(', ')}`,
625 | );
626 | }
627 | }
628 | }
629 |
630 | console.log(
631 | `VectorDatabase: Found ${results.length} search results out of ${searchResult.neighbors.length} neighbors`,
632 | );
633 |
634 | // If no results found but index has data, indicates label mismatch
635 | if (results.length === 0 && searchResult.neighbors.length > 0) {
636 | console.error(
637 | 'VectorDatabase: Label mismatch detected! Index has vectors but no matching documents found.',
638 | );
639 | console.error(
640 | 'VectorDatabase: This usually indicates the index and document mappings are out of sync.',
641 | );
642 | console.error('VectorDatabase: Consider rebuilding the index to fix this issue.');
643 |
644 | // Provide some diagnostic information
645 | const sampleLabels = searchResult.neighbors.slice(0, 5);
646 | const availableLabels = Array.from(this.documents.keys()).slice(0, 5);
647 | console.error('VectorDatabase: Sample search labels:', sampleLabels);
648 | console.error('VectorDatabase: Sample available labels:', availableLabels);
649 | }
650 |
651 | return results.sort((a, b) => b.similarity - a.similarity);
652 | } catch (error) {
653 | console.error('VectorDatabase: Search failed:', error);
654 | console.error('VectorDatabase: Query embedding info:', {
655 | type: typeof queryEmbedding,
656 | constructor: queryEmbedding?.constructor?.name,
657 | length: queryEmbedding?.length,
658 | isFloat32Array: queryEmbedding instanceof Float32Array,
659 | firstFewValues: queryEmbedding ? Array.from(queryEmbedding.slice(0, 5)) : null,
660 | });
661 | throw error;
662 | }
663 | }
664 |
665 | /**
666 | * Remove all documents for a tab
667 | */
668 | public async removeTabDocuments(tabId: number): Promise<void> {
669 | if (!this.isInitialized) {
670 | await this.initialize();
671 | }
672 |
673 | const documentLabels = this.tabDocuments.get(tabId);
674 | if (!documentLabels) {
675 | return;
676 | }
677 |
678 | try {
679 | // Remove documents from mapping (hnswlib-wasm doesn't support direct deletion, only mark as deleted)
680 | for (const label of documentLabels) {
681 | this.documents.delete(label);
682 | }
683 |
684 | // Clean up tab mapping
685 | this.tabDocuments.delete(tabId);
686 |
687 | // Save changes
688 | await this.saveDocumentMappings();
689 |
690 | console.log(`VectorDatabase: Removed ${documentLabels.size} documents for tab ${tabId}`);
691 | } catch (error) {
692 | console.error('VectorDatabase: Failed to remove tab documents:', error);
693 | throw error;
694 | }
695 | }
696 |
697 | /**
698 | * Get database statistics
699 | */
700 | public getStats(): {
701 | totalDocuments: number;
702 | totalTabs: number;
703 | indexSize: number;
704 | isInitialized: boolean;
705 | } {
706 | return {
707 | totalDocuments: this.documents.size,
708 | totalTabs: this.tabDocuments.size,
709 | indexSize: this.calculateStorageSize(),
710 | isInitialized: this.isInitialized,
711 | };
712 | }
713 |
714 | /**
715 | * Calculate actual storage size (bytes)
716 | */
717 | private calculateStorageSize(): number {
718 | let totalSize = 0;
719 |
720 | try {
721 | // 1. 计算文档映射的大小
722 | const documentsSize = this.calculateDocumentMappingsSize();
723 | totalSize += documentsSize;
724 |
725 | // 2. 计算向量数据的大小
726 | const vectorsSize = this.calculateVectorsSize();
727 | totalSize += vectorsSize;
728 |
729 | // 3. 估算索引结构的大小
730 | const indexStructureSize = this.calculateIndexStructureSize();
731 | totalSize += indexStructureSize;
732 |
733 | console.log(
734 | `VectorDatabase: Storage size breakdown - Documents: ${documentsSize}, Vectors: ${vectorsSize}, Index: ${indexStructureSize}, Total: ${totalSize} bytes`,
735 | );
736 | } catch (error) {
737 | console.warn('VectorDatabase: Failed to calculate storage size:', error);
738 | // 返回一个基于文档数量的估算值
739 | totalSize = this.documents.size * 1024; // 每个文档估算1KB
740 | }
741 |
742 | return totalSize;
743 | }
744 |
745 | /**
746 | * Calculate document mappings size
747 | */
748 | private calculateDocumentMappingsSize(): number {
749 | let size = 0;
750 |
751 | // Calculate documents Map size
752 | for (const [label, document] of this.documents.entries()) {
753 | // label (number): 8 bytes
754 | size += 8;
755 |
756 | // document object
757 | size += this.calculateObjectSize(document);
758 | }
759 |
760 | // Calculate tabDocuments Map size
761 | for (const [tabId, labels] of this.tabDocuments.entries()) {
762 | // tabId (number): 8 bytes
763 | size += 8;
764 |
765 | // Set of labels: 8 bytes per label + Set overhead
766 | size += labels.size * 8 + 32; // 32 bytes Set overhead
767 | }
768 |
769 | return size;
770 | }
771 |
772 | /**
773 | * Calculate vectors data size
774 | */
775 | private calculateVectorsSize(): number {
776 | const documentCount = this.documents.size;
777 | const dimension = this.config.dimension;
778 |
779 | // Each vector: dimension * 4 bytes (Float32)
780 | const vectorSize = dimension * 4;
781 |
782 | return documentCount * vectorSize;
783 | }
784 |
785 | /**
786 | * Estimate index structure size
787 | */
788 | private calculateIndexStructureSize(): number {
789 | const documentCount = this.documents.size;
790 |
791 | if (documentCount === 0) return 0;
792 |
793 | // HNSW index size estimation
794 | // Based on papers and actual testing, HNSW index size is about 20-40% of vector data
795 | const vectorsSize = this.calculateVectorsSize();
796 | const indexOverhead = Math.floor(vectorsSize * 0.3); // 30% overhead
797 |
798 | // Additional graph structure overhead
799 | const graphOverhead = documentCount * 64; // About 64 bytes graph structure overhead per node
800 |
801 | return indexOverhead + graphOverhead;
802 | }
803 |
804 | /**
805 | * Calculate object size (rough estimation)
806 | */
807 | private calculateObjectSize(obj: any): number {
808 | let size = 0;
809 |
810 | try {
811 | const jsonString = JSON.stringify(obj);
812 | // UTF-8 encoding, most characters 1 byte, Chinese etc 3 bytes, average 2 bytes
813 | size = jsonString.length * 2;
814 | } catch (error) {
815 | // If JSON serialization fails, use default estimation
816 | size = 512; // Default 512 bytes
817 | }
818 |
819 | return size;
820 | }
821 |
822 | /**
823 | * Clear entire database
824 | */
825 | public async clear(): Promise<void> {
826 | console.log('VectorDatabase: Starting complete database clear...');
827 |
828 | try {
829 | // Clear in-memory data structures
830 | this.documents.clear();
831 | this.tabDocuments.clear();
832 | this.nextLabel = 0;
833 |
834 | // Clear HNSW index file (in hnswlib-index database)
835 | if (this.isInitialized && this.index) {
836 | try {
837 | console.log('VectorDatabase: Clearing HNSW index file from IndexedDB...');
838 |
839 | // 1. First try to physically delete index file (using EmscriptenFileSystemManager)
840 | try {
841 | if (
842 | globalHnswlib &&
843 | globalHnswlib.EmscriptenFileSystemManager.checkFileExists(this.config.indexFileName)
844 | ) {
845 | console.log(
846 | `VectorDatabase: Deleting physical index file: ${this.config.indexFileName}`,
847 | );
848 | globalHnswlib.EmscriptenFileSystemManager.deleteFile(this.config.indexFileName);
849 | await this.syncFileSystem('write'); // Ensure deletion is synced to persistent storage
850 | console.log(
851 | `VectorDatabase: Physical index file ${this.config.indexFileName} deleted successfully`,
852 | );
853 | } else {
854 | console.log(
855 | `VectorDatabase: Physical index file ${this.config.indexFileName} does not exist or already deleted`,
856 | );
857 | }
858 | } catch (fileError) {
859 | console.warn(
860 | `VectorDatabase: Failed to delete physical index file ${this.config.indexFileName}:`,
861 | fileError,
862 | );
863 | // Continue with other cleanup operations, don't block the process
864 | }
865 |
866 | // 2. Delete index file from IndexedDB
867 | await this.index.deleteIndex(this.config.indexFileName);
868 | console.log('VectorDatabase: HNSW index file cleared from IndexedDB');
869 |
870 | // 3. Reinitialize empty index
871 | console.log('VectorDatabase: Reinitializing empty HNSW index...');
872 | this.index.initIndex(
873 | this.config.maxElements,
874 | this.config.M,
875 | this.config.efConstruction,
876 | 200,
877 | );
878 | this.index.setEfSearch(this.config.efSearch);
879 |
880 | // 4. Force save empty index
881 | await this.forceSaveIndex();
882 | } catch (indexError) {
883 | console.warn('VectorDatabase: Failed to clear HNSW index file:', indexError);
884 | // Continue with other cleanup operations
885 | }
886 | }
887 |
888 | // Clear document mappings from IndexedDB (in VectorDatabaseStorage database)
889 | try {
890 | console.log('VectorDatabase: Clearing document mappings from IndexedDB...');
891 | await IndexedDBHelper.deleteData(this.config.indexFileName);
892 | console.log('VectorDatabase: Document mappings cleared from IndexedDB');
893 | } catch (idbError) {
894 | console.warn(
895 | 'VectorDatabase: Failed to clear document mappings from IndexedDB, trying chrome.storage fallback:',
896 | idbError,
897 | );
898 |
899 | // Clear backup data from chrome.storage
900 | try {
901 | const storageKey = `hnswlib_document_mappings_${this.config.indexFileName}`;
902 | await chrome.storage.local.remove([storageKey]);
903 | console.log('VectorDatabase: Chrome storage fallback cleared');
904 | } catch (storageError) {
905 | console.warn('VectorDatabase: Failed to clear chrome.storage fallback:', storageError);
906 | }
907 | }
908 |
909 | // Save empty document mappings to ensure consistency
910 | await this.saveDocumentMappings();
911 |
912 | console.log('VectorDatabase: Complete database clear finished successfully');
913 | } catch (error) {
914 | console.error('VectorDatabase: Failed to clear database:', error);
915 | throw error;
916 | }
917 | }
918 |
919 | /**
920 | * Force save index and sync filesystem
921 | */
922 | private async forceSaveIndex(): Promise<void> {
923 | try {
924 | await this.index.writeIndex(this.config.indexFileName);
925 | await this.syncFileSystem('write'); // Force sync
926 | } catch (error) {
927 | console.error('VectorDatabase: Failed to force save index:', error);
928 | }
929 | }
930 |
931 | /**
932 | * Check and perform auto cleanup
933 | */
934 | private async checkAndPerformAutoCleanup(): Promise<void> {
935 | try {
936 | const currentCount = this.documents.size;
937 | const maxElements = this.config.maxElements;
938 |
939 | console.log(
940 | `VectorDatabase: Auto cleanup check - current: ${currentCount}, max: ${maxElements}`,
941 | );
942 |
943 | // Check if maximum element count is exceeded
944 | if (currentCount >= maxElements) {
945 | console.log('VectorDatabase: Document count reached limit, performing cleanup...');
946 | await this.performLRUCleanup(Math.floor(maxElements * 0.2)); // Clean up 20% of data
947 | }
948 |
949 | // Check if there's expired data
950 | if (this.config.maxRetentionDays && this.config.maxRetentionDays > 0) {
951 | await this.performTimeBasedCleanup();
952 | }
953 | } catch (error) {
954 | console.error('VectorDatabase: Auto cleanup failed:', error);
955 | }
956 | }
957 |
958 | /**
959 | * Perform LRU-based cleanup (delete oldest documents)
960 | */
961 | private async performLRUCleanup(cleanupCount: number): Promise<void> {
962 | try {
963 | console.log(
964 | `VectorDatabase: Starting LRU cleanup, removing ${cleanupCount} oldest documents`,
965 | );
966 |
967 | // Get all documents and sort by timestamp
968 | const allDocuments = Array.from(this.documents.entries());
969 | allDocuments.sort((a, b) => a[1].timestamp - b[1].timestamp);
970 |
971 | // Select documents to delete
972 | const documentsToDelete = allDocuments.slice(0, cleanupCount);
973 |
974 | for (const [label, _document] of documentsToDelete) {
975 | await this.removeDocumentByLabel(label);
976 | }
977 |
978 | // Save updated index and mappings
979 | await this.saveIndex();
980 | await this.saveDocumentMappings();
981 |
982 | console.log(
983 | `VectorDatabase: LRU cleanup completed, removed ${documentsToDelete.length} documents`,
984 | );
985 | } catch (error) {
986 | console.error('VectorDatabase: LRU cleanup failed:', error);
987 | }
988 | }
989 |
990 | /**
991 | * Perform time-based cleanup (delete expired documents)
992 | */
993 | private async performTimeBasedCleanup(): Promise<void> {
994 | try {
995 | const maxRetentionMs = this.config.maxRetentionDays! * 24 * 60 * 60 * 1000;
996 | const cutoffTime = Date.now() - maxRetentionMs;
997 |
998 | console.log(
999 | `VectorDatabase: Starting time-based cleanup, removing documents older than ${this.config.maxRetentionDays} days`,
1000 | );
1001 |
1002 | const documentsToDelete: number[] = [];
1003 |
1004 | for (const [label, document] of this.documents.entries()) {
1005 | if (document.timestamp < cutoffTime) {
1006 | documentsToDelete.push(label);
1007 | }
1008 | }
1009 |
1010 | for (const label of documentsToDelete) {
1011 | await this.removeDocumentByLabel(label);
1012 | }
1013 |
1014 | // Save updated index and mappings
1015 | if (documentsToDelete.length > 0) {
1016 | await this.saveIndex();
1017 | await this.saveDocumentMappings();
1018 | }
1019 |
1020 | console.log(
1021 | `VectorDatabase: Time-based cleanup completed, removed ${documentsToDelete.length} expired documents`,
1022 | );
1023 | } catch (error) {
1024 | console.error('VectorDatabase: Time-based cleanup failed:', error);
1025 | }
1026 | }
1027 |
1028 | /**
1029 | * Remove single document by label
1030 | */
1031 | private async removeDocumentByLabel(label: number): Promise<void> {
1032 | try {
1033 | const document = this.documents.get(label);
1034 | if (!document) {
1035 | console.warn(`VectorDatabase: Document with label ${label} not found`);
1036 | return;
1037 | }
1038 |
1039 | // Remove vector from HNSW index
1040 | if (this.index) {
1041 | try {
1042 | this.index.markDelete(label);
1043 | } catch (indexError) {
1044 | console.warn(
1045 | `VectorDatabase: Failed to mark delete in index for label ${label}:`,
1046 | indexError,
1047 | );
1048 | }
1049 | }
1050 |
1051 | // Remove from memory mapping
1052 | this.documents.delete(label);
1053 |
1054 | // Remove from tab mapping
1055 | const tabId = document.tabId;
1056 | if (this.tabDocuments.has(tabId)) {
1057 | this.tabDocuments.get(tabId)!.delete(label);
1058 | // If tab has no other documents, delete entire tab mapping
1059 | if (this.tabDocuments.get(tabId)!.size === 0) {
1060 | this.tabDocuments.delete(tabId);
1061 | }
1062 | }
1063 |
1064 | console.log(`VectorDatabase: Removed document with label ${label} from tab ${tabId}`);
1065 | } catch (error) {
1066 | console.error(`VectorDatabase: Failed to remove document with label ${label}:`, error);
1067 | }
1068 | }
1069 |
1070 | // 私有辅助方法
1071 |
1072 | private generateDocumentId(tabId: number, chunkIndex: number): string {
1073 | return `tab_${tabId}_chunk_${chunkIndex}_${Date.now()}`;
1074 | }
1075 |
1076 | private findDocumentByLabel(label: number): VectorDocument | null {
1077 | return this.documents.get(label) || null;
1078 | }
1079 |
1080 | private async syncFileSystem(direction: 'read' | 'write'): Promise<void> {
1081 | try {
1082 | if (!globalHnswlib) {
1083 | return;
1084 | }
1085 |
1086 | // If sync operation is already in progress, wait for it to complete
1087 | if (syncInProgress && pendingSyncPromise) {
1088 | console.log(`VectorDatabase: Sync already in progress, waiting...`);
1089 | await pendingSyncPromise;
1090 | return;
1091 | }
1092 |
1093 | // Mark sync start
1094 | syncInProgress = true;
1095 |
1096 | // Create sync Promise with timeout mechanism
1097 | pendingSyncPromise = new Promise<void>((resolve, reject) => {
1098 | const timeout = setTimeout(() => {
1099 | console.warn(`VectorDatabase: Filesystem sync (${direction}) timeout`);
1100 | syncInProgress = false;
1101 | pendingSyncPromise = null;
1102 | reject(new Error('Sync timeout'));
1103 | }, 5000); // 5 second timeout
1104 |
1105 | try {
1106 | globalHnswlib.EmscriptenFileSystemManager.syncFS(direction === 'read', () => {
1107 | clearTimeout(timeout);
1108 | console.log(`VectorDatabase: Filesystem sync (${direction}) completed`);
1109 | syncInProgress = false;
1110 | pendingSyncPromise = null;
1111 | resolve();
1112 | });
1113 | } catch (error) {
1114 | clearTimeout(timeout);
1115 | console.warn(`VectorDatabase: Failed to sync filesystem (${direction}):`, error);
1116 | syncInProgress = false;
1117 | pendingSyncPromise = null;
1118 | reject(error);
1119 | }
1120 | });
1121 |
1122 | await pendingSyncPromise;
1123 | } catch (error) {
1124 | console.warn(`VectorDatabase: Failed to sync filesystem (${direction}):`, error);
1125 | syncInProgress = false;
1126 | pendingSyncPromise = null;
1127 | }
1128 | }
1129 |
1130 | private async saveIndex(): Promise<void> {
1131 | try {
1132 | await this.index.writeIndex(this.config.indexFileName);
1133 | // Reduce sync frequency, only sync when necessary
1134 | if (this.documents.size % 10 === 0) {
1135 | // Sync every 10 documents
1136 | await this.syncFileSystem('write');
1137 | }
1138 | } catch (error) {
1139 | console.error('VectorDatabase: Failed to save index:', error);
1140 | }
1141 | }
1142 |
1143 | private async saveDocumentMappings(): Promise<void> {
1144 | try {
1145 | // Save document mappings to IndexedDB
1146 | const mappingData = {
1147 | documents: Array.from(this.documents.entries()),
1148 | tabDocuments: Array.from(this.tabDocuments.entries()).map(([tabId, labels]) => [
1149 | tabId,
1150 | Array.from(labels),
1151 | ]),
1152 | nextLabel: this.nextLabel,
1153 | };
1154 |
1155 | try {
1156 | // Use IndexedDB to save data, supports larger storage capacity
1157 | await IndexedDBHelper.saveData(this.config.indexFileName, mappingData);
1158 | console.log('VectorDatabase: Document mappings saved to IndexedDB');
1159 | } catch (idbError) {
1160 | console.warn(
1161 | 'VectorDatabase: Failed to save to IndexedDB, falling back to chrome.storage:',
1162 | idbError,
1163 | );
1164 |
1165 | // Fall back to chrome.storage.local
1166 | try {
1167 | const storageKey = `hnswlib_document_mappings_${this.config.indexFileName}`;
1168 | await chrome.storage.local.set({ [storageKey]: mappingData });
1169 | console.log('VectorDatabase: Document mappings saved to chrome.storage.local (fallback)');
1170 | } catch (storageError) {
1171 | console.error(
1172 | 'VectorDatabase: Failed to save to both IndexedDB and chrome.storage:',
1173 | storageError,
1174 | );
1175 | }
1176 | }
1177 | } catch (error) {
1178 | console.error('VectorDatabase: Failed to save document mappings:', error);
1179 | }
1180 | }
1181 |
1182 | public async loadDocumentMappings(): Promise<void> {
1183 | try {
1184 | // Load document mappings from IndexedDB
1185 | if (!globalHnswlib) {
1186 | return;
1187 | }
1188 |
1189 | let mappingData = null;
1190 |
1191 | try {
1192 | // First try to read from IndexedDB
1193 | mappingData = await IndexedDBHelper.loadData(this.config.indexFileName);
1194 | if (mappingData) {
1195 | console.log(`VectorDatabase: Loaded document mappings from IndexedDB`);
1196 | }
1197 | } catch (idbError) {
1198 | console.warn(
1199 | 'VectorDatabase: Failed to read from IndexedDB, trying chrome.storage:',
1200 | idbError,
1201 | );
1202 | }
1203 |
1204 | // If IndexedDB has no data, try reading from chrome.storage.local (backward compatibility)
1205 | if (!mappingData) {
1206 | try {
1207 | const storageKey = `hnswlib_document_mappings_${this.config.indexFileName}`;
1208 | const result = await chrome.storage.local.get([storageKey]);
1209 | mappingData = result[storageKey];
1210 | if (mappingData) {
1211 | console.log(
1212 | `VectorDatabase: Loaded document mappings from chrome.storage.local (fallback)`,
1213 | );
1214 |
1215 | // Migrate to IndexedDB
1216 | try {
1217 | await IndexedDBHelper.saveData(this.config.indexFileName, mappingData);
1218 | console.log('VectorDatabase: Migrated data from chrome.storage to IndexedDB');
1219 | } catch (migrationError) {
1220 | console.warn('VectorDatabase: Failed to migrate data to IndexedDB:', migrationError);
1221 | }
1222 | }
1223 | } catch (storageError) {
1224 | console.warn('VectorDatabase: Failed to read from chrome.storage.local:', storageError);
1225 | }
1226 | }
1227 |
1228 | if (mappingData) {
1229 | // Restore document mappings
1230 | this.documents.clear();
1231 | for (const [label, doc] of mappingData.documents) {
1232 | this.documents.set(label, doc);
1233 | }
1234 |
1235 | // Restore tab mappings
1236 | this.tabDocuments.clear();
1237 | for (const [tabId, labels] of mappingData.tabDocuments) {
1238 | this.tabDocuments.set(tabId, new Set(labels));
1239 | }
1240 |
1241 | // Restore nextLabel - use saved value or calculate max label + 1
1242 | if (mappingData.nextLabel !== undefined) {
1243 | this.nextLabel = mappingData.nextLabel;
1244 | } else if (this.documents.size > 0) {
1245 | // If no saved nextLabel, calculate max label + 1
1246 | const maxLabel = Math.max(...Array.from(this.documents.keys()));
1247 | this.nextLabel = maxLabel + 1;
1248 | } else {
1249 | this.nextLabel = 0;
1250 | }
1251 |
1252 | console.log(
1253 | `VectorDatabase: Loaded ${this.documents.size} document mappings, next label: ${this.nextLabel}`,
1254 | );
1255 | } else {
1256 | console.log('VectorDatabase: No existing document mappings found');
1257 | }
1258 | } catch (error) {
1259 | console.error('VectorDatabase: Failed to load document mappings:', error);
1260 | }
1261 | }
1262 | }
1263 |
1264 | // Global VectorDatabase singleton
1265 | let globalVectorDatabase: VectorDatabase | null = null;
1266 | let currentDimension: number | null = null;
1267 |
1268 | /**
1269 | * Get global VectorDatabase singleton instance
1270 | * If dimension changes, will recreate instance to ensure compatibility
1271 | */
1272 | export async function getGlobalVectorDatabase(
1273 | config?: Partial<VectorDatabaseConfig>,
1274 | ): Promise<VectorDatabase> {
1275 | const newDimension = config?.dimension || 384;
1276 |
1277 | // If dimension changes, need to recreate vector database
1278 | if (globalVectorDatabase && currentDimension !== null && currentDimension !== newDimension) {
1279 | console.log(
1280 | `VectorDatabase: Dimension changed from ${currentDimension} to ${newDimension}, recreating instance`,
1281 | );
1282 |
1283 | // Clean up old instance - this will clean up index files and document mappings
1284 | try {
1285 | await globalVectorDatabase.clear();
1286 | console.log('VectorDatabase: Successfully cleared old instance for dimension change');
1287 | } catch (error) {
1288 | console.warn('VectorDatabase: Error during cleanup:', error);
1289 | }
1290 |
1291 | globalVectorDatabase = null;
1292 | currentDimension = null;
1293 | }
1294 |
1295 | if (!globalVectorDatabase) {
1296 | globalVectorDatabase = new VectorDatabase(config);
1297 | currentDimension = newDimension;
1298 | console.log(
1299 | `VectorDatabase: Created global singleton instance with dimension ${currentDimension}`,
1300 | );
1301 | }
1302 |
1303 | return globalVectorDatabase;
1304 | }
1305 |
1306 | /**
1307 | * Synchronous version of getting global VectorDatabase instance (for backward compatibility)
1308 | * Note: If dimension change is needed, recommend using async version
1309 | */
1310 | export function getGlobalVectorDatabaseSync(
1311 | config?: Partial<VectorDatabaseConfig>,
1312 | ): VectorDatabase {
1313 | const newDimension = config?.dimension || 384;
1314 |
1315 | // If dimension changes, log warning but don't clean up (avoid race conditions)
1316 | if (globalVectorDatabase && currentDimension !== null && currentDimension !== newDimension) {
1317 | console.warn(
1318 | `VectorDatabase: Dimension mismatch detected (${currentDimension} vs ${newDimension}). Consider using async version for proper cleanup.`,
1319 | );
1320 | }
1321 |
1322 | if (!globalVectorDatabase) {
1323 | globalVectorDatabase = new VectorDatabase(config);
1324 | currentDimension = newDimension;
1325 | console.log(
1326 | `VectorDatabase: Created global singleton instance with dimension ${currentDimension}`,
1327 | );
1328 | }
1329 |
1330 | return globalVectorDatabase;
1331 | }
1332 |
1333 | /**
1334 | * Reset global VectorDatabase instance (mainly for testing or model switching)
1335 | */
1336 | export async function resetGlobalVectorDatabase(): Promise<void> {
1337 | console.log('VectorDatabase: Starting global instance reset...');
1338 |
1339 | if (globalVectorDatabase) {
1340 | try {
1341 | console.log('VectorDatabase: Clearing existing global instance...');
1342 | await globalVectorDatabase.clear();
1343 | console.log('VectorDatabase: Global instance cleared successfully');
1344 | } catch (error) {
1345 | console.warn('VectorDatabase: Failed to clear during reset:', error);
1346 | }
1347 | }
1348 |
1349 | // Additional cleanup: ensure all possible IndexedDB data is cleared
1350 | try {
1351 | console.log('VectorDatabase: Performing comprehensive IndexedDB cleanup...');
1352 |
1353 | // Clear all data in VectorDatabaseStorage database
1354 | await IndexedDBHelper.clearAllData();
1355 |
1356 | // Clear index files from hnswlib-index database
1357 | try {
1358 | console.log('VectorDatabase: Clearing HNSW index files from IndexedDB...');
1359 |
1360 | // Try to clean up possible existing index files
1361 | const possibleIndexFiles = ['tab_content_index.dat', 'content_index.dat', 'vector_index.dat'];
1362 |
1363 | // If global hnswlib instance exists, try to delete known index files
1364 | if (typeof globalHnswlib !== 'undefined' && globalHnswlib) {
1365 | for (const fileName of possibleIndexFiles) {
1366 | try {
1367 | // 1. First try to physically delete index file (using EmscriptenFileSystemManager)
1368 | try {
1369 | if (globalHnswlib.EmscriptenFileSystemManager.checkFileExists(fileName)) {
1370 | console.log(`VectorDatabase: Deleting physical index file: ${fileName}`);
1371 | globalHnswlib.EmscriptenFileSystemManager.deleteFile(fileName);
1372 | console.log(`VectorDatabase: Physical index file ${fileName} deleted successfully`);
1373 | }
1374 | } catch (fileError) {
1375 | console.log(
1376 | `VectorDatabase: Physical index file ${fileName} not found or failed to delete:`,
1377 | fileError,
1378 | );
1379 | }
1380 |
1381 | // 2. Delete index file from IndexedDB
1382 | const tempIndex = new globalHnswlib.HierarchicalNSW('cosine', 384);
1383 | await tempIndex.deleteIndex(fileName);
1384 | console.log(`VectorDatabase: Deleted IndexedDB index file: ${fileName}`);
1385 | } catch (deleteError) {
1386 | // File might not exist, this is normal
1387 | console.log(`VectorDatabase: Index file ${fileName} not found or already deleted`);
1388 | }
1389 | }
1390 |
1391 | // 3. Force sync filesystem to ensure deletion takes effect
1392 | try {
1393 | await new Promise<void>((resolve) => {
1394 | const timeout = setTimeout(() => {
1395 | console.warn('VectorDatabase: Filesystem sync timeout during cleanup');
1396 | resolve(); // Don't block the process
1397 | }, 3000);
1398 |
1399 | globalHnswlib.EmscriptenFileSystemManager.syncFS(false, () => {
1400 | clearTimeout(timeout);
1401 | console.log('VectorDatabase: Filesystem sync completed during cleanup');
1402 | resolve();
1403 | });
1404 | });
1405 | } catch (syncError) {
1406 | console.warn('VectorDatabase: Failed to sync filesystem during cleanup:', syncError);
1407 | }
1408 | }
1409 | } catch (hnswError) {
1410 | console.warn('VectorDatabase: Failed to clear HNSW index files:', hnswError);
1411 | }
1412 |
1413 | // Clear possible chrome.storage backup data (only clear vector database related data, preserve user preferences)
1414 | const possibleKeys = [
1415 | 'hnswlib_document_mappings_tab_content_index.dat',
1416 | 'hnswlib_document_mappings_content_index.dat',
1417 | 'hnswlib_document_mappings_vector_index.dat',
1418 | // Note: Don't clear selectedModel and selectedVersion, these are user preference settings
1419 | // Note: Don't clear modelState, this contains model state info and should be handled by model management logic
1420 | ];
1421 |
1422 | if (possibleKeys.length > 0) {
1423 | try {
1424 | await chrome.storage.local.remove(possibleKeys);
1425 | console.log('VectorDatabase: Chrome storage backup data cleared');
1426 | } catch (storageError) {
1427 | console.warn('VectorDatabase: Failed to clear chrome.storage backup:', storageError);
1428 | }
1429 | }
1430 |
1431 | console.log('VectorDatabase: Comprehensive cleanup completed');
1432 | } catch (cleanupError) {
1433 | console.warn('VectorDatabase: Comprehensive cleanup failed:', cleanupError);
1434 | }
1435 |
1436 | globalVectorDatabase = null;
1437 | currentDimension = null;
1438 | console.log('VectorDatabase: Global singleton instance reset completed');
1439 | }
1440 |
1441 | /**
1442 | * Specifically for data cleanup during model switching
1443 | * Clear all IndexedDB data, including HNSW index files and document mappings
1444 | */
1445 | export async function clearAllVectorData(): Promise<void> {
1446 | console.log('VectorDatabase: Starting comprehensive vector data cleanup for model switch...');
1447 |
1448 | try {
1449 | // 1. Clear global instance
1450 | if (globalVectorDatabase) {
1451 | try {
1452 | await globalVectorDatabase.clear();
1453 | } catch (error) {
1454 | console.warn('VectorDatabase: Failed to clear global instance:', error);
1455 | }
1456 | }
1457 |
1458 | // 2. Clear VectorDatabaseStorage database
1459 | try {
1460 | console.log('VectorDatabase: Clearing VectorDatabaseStorage database...');
1461 | await IndexedDBHelper.clearAllData();
1462 | } catch (error) {
1463 | console.warn('VectorDatabase: Failed to clear VectorDatabaseStorage:', error);
1464 | }
1465 |
1466 | // 3. Clear hnswlib-index database and physical files
1467 | try {
1468 | console.log('VectorDatabase: Clearing hnswlib-index database and physical files...');
1469 |
1470 | // 3.1 First try to physically delete index files (using EmscriptenFileSystemManager)
1471 | if (typeof globalHnswlib !== 'undefined' && globalHnswlib) {
1472 | const possibleIndexFiles = [
1473 | 'tab_content_index.dat',
1474 | 'content_index.dat',
1475 | 'vector_index.dat',
1476 | ];
1477 |
1478 | for (const fileName of possibleIndexFiles) {
1479 | try {
1480 | if (globalHnswlib.EmscriptenFileSystemManager.checkFileExists(fileName)) {
1481 | console.log(`VectorDatabase: Deleting physical index file: ${fileName}`);
1482 | globalHnswlib.EmscriptenFileSystemManager.deleteFile(fileName);
1483 | console.log(`VectorDatabase: Physical index file ${fileName} deleted successfully`);
1484 | }
1485 | } catch (fileError) {
1486 | console.log(
1487 | `VectorDatabase: Physical index file ${fileName} not found or failed to delete:`,
1488 | fileError,
1489 | );
1490 | }
1491 | }
1492 |
1493 | // Force sync filesystem
1494 | try {
1495 | await new Promise<void>((resolve) => {
1496 | const timeout = setTimeout(() => {
1497 | console.warn('VectorDatabase: Filesystem sync timeout during model switch cleanup');
1498 | resolve();
1499 | }, 3000);
1500 |
1501 | globalHnswlib.EmscriptenFileSystemManager.syncFS(false, () => {
1502 | clearTimeout(timeout);
1503 | console.log('VectorDatabase: Filesystem sync completed during model switch cleanup');
1504 | resolve();
1505 | });
1506 | });
1507 | } catch (syncError) {
1508 | console.warn(
1509 | 'VectorDatabase: Failed to sync filesystem during model switch cleanup:',
1510 | syncError,
1511 | );
1512 | }
1513 | }
1514 |
1515 | // 3.2 Delete entire hnswlib-index database
1516 | await new Promise<void>((resolve) => {
1517 | const deleteRequest = indexedDB.deleteDatabase('/hnswlib-index');
1518 | deleteRequest.onsuccess = () => {
1519 | console.log('VectorDatabase: Successfully deleted /hnswlib-index database');
1520 | resolve();
1521 | };
1522 | deleteRequest.onerror = () => {
1523 | console.warn(
1524 | 'VectorDatabase: Failed to delete /hnswlib-index database:',
1525 | deleteRequest.error,
1526 | );
1527 | resolve(); // Don't block the process
1528 | };
1529 | deleteRequest.onblocked = () => {
1530 | console.warn('VectorDatabase: Deletion of /hnswlib-index database was blocked');
1531 | resolve(); // Don't block the process
1532 | };
1533 | });
1534 | } catch (error) {
1535 | console.warn(
1536 | 'VectorDatabase: Failed to clear hnswlib-index database and physical files:',
1537 | error,
1538 | );
1539 | }
1540 |
1541 | // 4. Clear backup data from chrome.storage
1542 | try {
1543 | const storageKeys = [
1544 | 'hnswlib_document_mappings_tab_content_index.dat',
1545 | 'hnswlib_document_mappings_content_index.dat',
1546 | 'hnswlib_document_mappings_vector_index.dat',
1547 | ];
1548 | await chrome.storage.local.remove(storageKeys);
1549 | console.log('VectorDatabase: Chrome storage backup data cleared');
1550 | } catch (error) {
1551 | console.warn('VectorDatabase: Failed to clear chrome.storage backup:', error);
1552 | }
1553 |
1554 | // 5. Reset global state
1555 | globalVectorDatabase = null;
1556 | currentDimension = null;
1557 |
1558 | console.log('VectorDatabase: Comprehensive vector data cleanup completed successfully');
1559 | } catch (error) {
1560 | console.error('VectorDatabase: Comprehensive vector data cleanup failed:', error);
1561 | throw error;
1562 | }
1563 | }
1564 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/core/editor.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Web Editor V2 Core
3 | *
4 | * Main orchestrator for the visual editor.
5 | * Manages lifecycle of all subsystems (Shadow Host, Canvas, Interaction Engine, etc.)
6 | */
7 |
8 | import type {
9 | WebEditorApplyBatchPayload,
10 | WebEditorElementKey,
11 | WebEditorRevertElementResponse,
12 | WebEditorSelectionChangedPayload,
13 | SelectedElementSummary,
14 | WebEditorState,
15 | WebEditorTxChangedPayload,
16 | WebEditorTxChangeAction,
17 | WebEditorV2Api,
18 | } from '@/common/web-editor-types';
19 | import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
20 | import { WEB_EDITOR_V2_VERSION, WEB_EDITOR_V2_LOG_PREFIX } from '../constants';
21 | import { mountShadowHost, type ShadowHostManager } from '../ui/shadow-host';
22 | import { createToolbar, type Toolbar } from '../ui/toolbar';
23 | import { createBreadcrumbs, type Breadcrumbs } from '../ui/breadcrumbs';
24 | import { createPropertyPanel, type PropertyPanel } from '../ui/property-panel';
25 | import { createPropsBridge, type PropsBridge } from './props-bridge';
26 | import { createCanvasOverlay, type CanvasOverlay } from '../overlay/canvas-overlay';
27 | import { createHandlesController, type HandlesController } from '../overlay/handles-controller';
28 | import {
29 | createDragReorderController,
30 | type DragReorderController,
31 | } from '../drag/drag-reorder-controller';
32 | import {
33 | createEventController,
34 | type EventController,
35 | type EventModifiers,
36 | } from './event-controller';
37 | import { createPositionTracker, type PositionTracker, type TrackedRects } from './position-tracker';
38 | import { createSelectionEngine, type SelectionEngine } from '../selection/selection-engine';
39 | import {
40 | createTransactionManager,
41 | type TransactionManager,
42 | type TransactionChangeEvent,
43 | } from './transaction-manager';
44 | import { locateElement, createElementLocator } from './locator';
45 | import { sendTransactionToAgent } from './payload-builder';
46 | import { aggregateTransactionsByElement } from './transaction-aggregator';
47 | import {
48 | generateStableElementKey,
49 | generateElementLabel,
50 | generateFullElementLabel,
51 | } from './element-key';
52 | import {
53 | createExecutionTracker,
54 | type ExecutionTracker,
55 | type ExecutionState,
56 | } from './execution-tracker';
57 | import { createHmrConsistencyVerifier, type HmrConsistencyVerifier } from './hmr-consistency';
58 | import { createPerfMonitor, type PerfMonitor } from './perf-monitor';
59 | import { createDesignTokensService, type DesignTokensService } from './design-tokens';
60 |
61 | // =============================================================================
62 | // Types
63 | // =============================================================================
64 |
65 | /** Apply operation snapshot for rollback tracking */
66 | interface ApplySnapshot {
67 | txId: string;
68 | txTimestamp: number;
69 | }
70 |
71 | /** Internal editor state */
72 | interface EditorInternalState {
73 | active: boolean;
74 | shadowHost: ShadowHostManager | null;
75 | canvasOverlay: CanvasOverlay | null;
76 | handlesController: HandlesController | null;
77 | eventController: EventController | null;
78 | positionTracker: PositionTracker | null;
79 | selectionEngine: SelectionEngine | null;
80 | dragReorderController: DragReorderController | null;
81 | transactionManager: TransactionManager | null;
82 | executionTracker: ExecutionTracker | null;
83 | hmrConsistencyVerifier: HmrConsistencyVerifier | null;
84 | toolbar: Toolbar | null;
85 | breadcrumbs: Breadcrumbs | null;
86 | propertyPanel: PropertyPanel | null;
87 | /** Runtime props bridge (Phase 7) */
88 | propsBridge: PropsBridge | null;
89 | /** Design tokens service (Phase 5.3) */
90 | tokensService: DesignTokensService | null;
91 | /** Performance monitor (Phase 5.3) - disabled by default */
92 | perfMonitor: PerfMonitor | null;
93 | /** Cleanup function for perf monitor hotkey */
94 | perfHotkeyCleanup: (() => void) | null;
95 | /** Currently hovered element (for hover highlight) */
96 | hoveredElement: Element | null;
97 | /** One-shot flag: whether next hover rect update should animate */
98 | pendingHoverTransition: boolean;
99 | /** Currently selected element (for selection highlight) */
100 | selectedElement: Element | null;
101 | /** Snapshot of transaction being applied (for rollback on failure) */
102 | applyingSnapshot: ApplySnapshot | null;
103 | /** Floating toolbar position (viewport coordinates), null when docked */
104 | toolbarPosition: { left: number; top: number } | null;
105 | /** Floating property panel position (viewport coordinates), null when anchored */
106 | propertyPanelPosition: { left: number; top: number } | null;
107 | /** Cleanup for window resize clamping (floating UI) */
108 | uiResizeCleanup: (() => void) | null;
109 | }
110 |
111 | // =============================================================================
112 | // Implementation
113 | // =============================================================================
114 |
115 | /**
116 | * Create the Web Editor V2 instance.
117 | *
118 | * This is the main factory function that creates the editor API.
119 | * The returned object implements WebEditorV2Api and is exposed on window.__MCP_WEB_EDITOR_V2__
120 | */
121 | export function createWebEditorV2(): WebEditorV2Api {
122 | const state: EditorInternalState = {
123 | active: false,
124 | shadowHost: null,
125 | canvasOverlay: null,
126 | handlesController: null,
127 | eventController: null,
128 | positionTracker: null,
129 | selectionEngine: null,
130 | dragReorderController: null,
131 | transactionManager: null,
132 | executionTracker: null,
133 | hmrConsistencyVerifier: null,
134 | toolbar: null,
135 | breadcrumbs: null,
136 | propertyPanel: null,
137 | propsBridge: null,
138 | tokensService: null,
139 | perfMonitor: null,
140 | perfHotkeyCleanup: null,
141 | hoveredElement: null,
142 | pendingHoverTransition: false,
143 | selectedElement: null,
144 | applyingSnapshot: null,
145 | toolbarPosition: null,
146 | propertyPanelPosition: null,
147 | uiResizeCleanup: null,
148 | };
149 |
150 | /** Default modifiers for programmatic selection (e.g., from breadcrumbs) */
151 | const DEFAULT_MODIFIERS: EventModifiers = {
152 | alt: false,
153 | shift: false,
154 | ctrl: false,
155 | meta: false,
156 | };
157 |
158 | // ===========================================================================
159 | // Text Editing Session (Phase 2.7)
160 | // ===========================================================================
161 |
162 | interface EditSession {
163 | element: HTMLElement;
164 | beforeText: string;
165 | beforeContentEditable: string | null;
166 | beforeSpellcheck: boolean;
167 | keydownHandler: (ev: KeyboardEvent) => void;
168 | blurHandler: () => void;
169 | }
170 |
171 | let editSession: EditSession | null = null;
172 |
173 | /** Check if element is a valid text edit target */
174 | function isTextEditTarget(element: Element): element is HTMLElement {
175 | if (!(element instanceof HTMLElement)) return false;
176 | // Not for form controls
177 | if (element instanceof HTMLInputElement) return false;
178 | if (element instanceof HTMLTextAreaElement) return false;
179 | // Only for text-only targets (no element children)
180 | if (element.childElementCount > 0) return false;
181 | return true;
182 | }
183 |
184 | /** Restore element to pre-edit state */
185 | function restoreEditTarget(session: EditSession): void {
186 | const { element, beforeContentEditable, beforeSpellcheck } = session;
187 |
188 | if (beforeContentEditable === null) {
189 | element.removeAttribute('contenteditable');
190 | } else {
191 | element.setAttribute('contenteditable', beforeContentEditable);
192 | }
193 |
194 | element.spellcheck = beforeSpellcheck;
195 |
196 | // Remove event listeners
197 | element.removeEventListener('keydown', session.keydownHandler, true);
198 | element.removeEventListener('blur', session.blurHandler, true);
199 | }
200 |
201 | /** Commit the current edit session */
202 | function commitEdit(): void {
203 | const session = editSession;
204 | if (!session) return;
205 |
206 | editSession = null;
207 |
208 | const element = session.element;
209 | const afterText = element.textContent ?? '';
210 |
211 | // Normalize to text-only to avoid structure drift from contentEditable
212 | element.textContent = afterText;
213 |
214 | restoreEditTarget(session);
215 |
216 | // Record transaction if text changed
217 | if (session.beforeText !== afterText) {
218 | state.transactionManager?.recordText(element, session.beforeText, afterText);
219 | }
220 |
221 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Text edit committed`);
222 | }
223 |
224 | /** Cancel the current edit session */
225 | function cancelEdit(): void {
226 | const session = editSession;
227 | if (!session) return;
228 |
229 | editSession = null;
230 |
231 | // Restore original text
232 | session.element.textContent = session.beforeText;
233 |
234 | restoreEditTarget(session);
235 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Text edit cancelled`);
236 | }
237 |
238 | /** Start editing an element */
239 | function startEdit(element: Element, modifiers: EventModifiers): boolean {
240 | if (!isTextEditTarget(element)) return false;
241 | if (!element.isConnected) return false;
242 |
243 | // Ensure element is selected
244 | if (state.selectedElement !== element) {
245 | handleSelect(element, modifiers);
246 | }
247 |
248 | // If already editing this element, keep editing
249 | if (editSession?.element === element) return true;
250 |
251 | // Commit previous edit if any
252 | if (editSession) {
253 | commitEdit();
254 | }
255 |
256 | const beforeText = element.textContent ?? '';
257 | const beforeContentEditable = element.getAttribute('contenteditable');
258 | const beforeSpellcheck = element.spellcheck;
259 |
260 | // ESC cancels editing
261 | const keydownHandler = (ev: KeyboardEvent) => {
262 | if (ev.key !== 'Escape') return;
263 | ev.preventDefault();
264 | ev.stopPropagation();
265 | ev.stopImmediatePropagation();
266 | cancelEdit();
267 | state.eventController?.setMode('selecting');
268 | };
269 |
270 | // Blur commits editing
271 | const blurHandler = () => {
272 | commitEdit();
273 | state.eventController?.setMode('selecting');
274 | };
275 |
276 | element.addEventListener('keydown', keydownHandler, true);
277 | element.addEventListener('blur', blurHandler, true);
278 |
279 | element.setAttribute('contenteditable', 'true');
280 | element.spellcheck = false;
281 |
282 | try {
283 | element.focus({ preventScroll: true });
284 | } catch {
285 | try {
286 | element.focus();
287 | } catch {
288 | // Best-effort only
289 | }
290 | }
291 |
292 | editSession = {
293 | element,
294 | beforeText,
295 | beforeContentEditable,
296 | beforeSpellcheck,
297 | keydownHandler,
298 | blurHandler,
299 | };
300 |
301 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Text edit started`);
302 | return true;
303 | }
304 |
305 | // ===========================================================================
306 | // Event Handlers (wired to EventController callbacks)
307 | // ===========================================================================
308 |
309 | /**
310 | * Handle hover state changes from EventController
311 | */
312 | function handleHover(element: Element | null): void {
313 | const prevElement = state.hoveredElement;
314 | state.hoveredElement = element;
315 |
316 | // Determine if we should animate the hover rect transition
317 | // Only animate when switching between two valid elements (not null)
318 | const shouldAnimate = prevElement !== null && element !== null && prevElement !== element;
319 | state.pendingHoverTransition = shouldAnimate;
320 |
321 | // Delegate position tracking to PositionTracker
322 | // Use forceUpdate to avoid extra rAF frame delay
323 | if (state.positionTracker) {
324 | state.positionTracker.setHoverElement(element);
325 | state.positionTracker.forceUpdate();
326 | }
327 | }
328 |
329 | /**
330 | * Handle element selection from EventController
331 | */
332 | function handleSelect(element: Element, modifiers: EventModifiers): void {
333 | // Commit any in-progress edit when selecting a different element
334 | if (editSession && editSession.element !== element) {
335 | commitEdit();
336 | }
337 |
338 | state.selectedElement = element;
339 | state.hoveredElement = null;
340 |
341 | // Delegate position tracking to PositionTracker
342 | // Clear hover, set selection, then force immediate update
343 | if (state.positionTracker) {
344 | state.positionTracker.setHoverElement(null);
345 | state.positionTracker.setSelectionElement(element);
346 | state.positionTracker.forceUpdate();
347 | }
348 |
349 | // Update breadcrumbs to show element ancestry
350 | state.breadcrumbs?.setTarget(element);
351 |
352 | // Update property panel with selected element
353 | state.propertyPanel?.setTarget(element);
354 |
355 | // Update resize handles target (Phase 4.9)
356 | state.handlesController?.setTarget(element);
357 |
358 | // Notify HMR consistency verifier of selection change (Phase 4.8)
359 | state.hmrConsistencyVerifier?.onSelectionChange(element);
360 |
361 | // Broadcast selection to sidepanel for AgentChat context
362 | broadcastSelectionChanged(element);
363 |
364 | // Log selection with modifier info for debugging
365 | const modInfo = modifiers.alt ? ' (Alt: drill-up)' : '';
366 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Selected${modInfo}:`, element.tagName, element);
367 | }
368 |
369 | /**
370 | * Handle deselection (ESC key) from EventController
371 | */
372 | function handleDeselect(): void {
373 | state.selectedElement = null;
374 |
375 | // Clear selection tracking and force immediate update
376 | if (state.positionTracker) {
377 | state.positionTracker.setSelectionElement(null);
378 | state.positionTracker.forceUpdate();
379 | }
380 |
381 | // Clear breadcrumbs
382 | state.breadcrumbs?.setTarget(null);
383 |
384 | // Clear property panel
385 | state.propertyPanel?.setTarget(null);
386 |
387 | // Hide resize handles (Phase 4.9)
388 | state.handlesController?.setTarget(null);
389 |
390 | // Notify HMR consistency verifier of deselection (Phase 4.8)
391 | // Deselection should cancel any ongoing verification
392 | state.hmrConsistencyVerifier?.onSelectionChange(null);
393 |
394 | // Broadcast deselection to sidepanel
395 | broadcastSelectionChanged(null);
396 |
397 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Deselected`);
398 | }
399 |
400 | /**
401 | * Handle position updates from PositionTracker (scroll/resize sync)
402 | */
403 | function handlePositionUpdate(rects: TrackedRects): void {
404 | // Anchor breadcrumbs to the selection rect (viewport coordinates)
405 | state.breadcrumbs?.setAnchorRect(rects.selection);
406 |
407 | // Consume one-shot animation flag (must read before clearing)
408 | // This flag is only set when hover element changes, not for scroll/resize
409 | const animateHover = state.pendingHoverTransition;
410 | state.pendingHoverTransition = false;
411 |
412 | if (!state.canvasOverlay) return;
413 |
414 | // Update canvas overlay with new positions
415 | state.canvasOverlay.setHoverRect(rects.hover, { animate: animateHover });
416 | state.canvasOverlay.setSelectionRect(rects.selection);
417 |
418 | // Sync resize handles with latest selection rect (Phase 4.9)
419 | state.handlesController?.setSelectionRect(rects.selection);
420 |
421 | // Force immediate render to avoid extra rAF delay
422 | // This collapses the render to the same frame as position calculation
423 | state.canvasOverlay.render();
424 | }
425 |
426 | // ===========================================================================
427 | // AgentChat Integration (Phase 1.4)
428 | // ===========================================================================
429 |
430 | const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-' as const;
431 | const TX_CHANGED_BROADCAST_DEBOUNCE_MS = 100;
432 |
433 | let txChangedBroadcastTimer: number | null = null;
434 | let pendingTxAction: WebEditorTxChangeAction = 'push';
435 |
436 | /**
437 | * Broadcast aggregated transaction state to extension UI (e.g., Sidepanel).
438 | *
439 | * This runs on a short debounce because TransactionManager can emit frequent
440 | * merge events during continuous interactions (e.g., dragging sliders).
441 | *
442 | * NOTE: tabId is set to 0 here; background script will fill in the actual
443 | * tabId from sender.tab.id and update storage with per-tab keys.
444 | */
445 | function broadcastTxChanged(action: WebEditorTxChangeAction): void {
446 | // Track the action for when debounce fires
447 | pendingTxAction = action;
448 |
449 | // For 'clear' action, broadcast immediately without debounce
450 | // This ensures UI updates instantly when user applies changes
451 | const shouldBroadcastImmediately = action === 'clear';
452 |
453 | if (txChangedBroadcastTimer !== null) {
454 | window.clearTimeout(txChangedBroadcastTimer);
455 | txChangedBroadcastTimer = null;
456 | }
457 |
458 | const doBroadcast = (): void => {
459 | const tm = state.transactionManager;
460 | if (!tm) return;
461 |
462 | const undoStack = tm.getUndoStack();
463 | const redoStack = tm.getRedoStack();
464 | const elements = aggregateTransactionsByElement(undoStack);
465 |
466 | const payload: WebEditorTxChangedPayload = {
467 | tabId: 0, // Will be filled by background script from sender.tab.id
468 | action: pendingTxAction,
469 | elements,
470 | undoCount: undoStack.length,
471 | redoCount: redoStack.length,
472 | hasApplicableChanges: elements.length > 0,
473 | pageUrl: window.location.href,
474 | };
475 |
476 | // Broadcast to extension UI (background will handle storage persistence)
477 | if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) {
478 | chrome.runtime
479 | .sendMessage({
480 | type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED,
481 | payload,
482 | })
483 | .catch(() => {
484 | // Ignore if no listeners (e.g., sidepanel not open)
485 | });
486 | }
487 | };
488 |
489 | if (shouldBroadcastImmediately) {
490 | doBroadcast();
491 | } else {
492 | txChangedBroadcastTimer = window.setTimeout(doBroadcast, TX_CHANGED_BROADCAST_DEBOUNCE_MS);
493 | }
494 | }
495 |
496 | /** Last broadcasted selection key to avoid duplicate broadcasts */
497 | let lastBroadcastedSelectionKey: string | null = null;
498 |
499 | /**
500 | * Broadcast selection change to sidepanel (no debounce - immediate).
501 | * Called when user selects or deselects an element.
502 | */
503 | function broadcastSelectionChanged(element: Element | null): void {
504 | // Build selected element summary if element is provided
505 | let selected: SelectedElementSummary | null = null;
506 |
507 | if (element) {
508 | const elementKey = generateStableElementKey(element);
509 |
510 | // Dedupe: skip if same element already broadcasted
511 | if (elementKey === lastBroadcastedSelectionKey) return;
512 | lastBroadcastedSelectionKey = elementKey;
513 |
514 | const locator = createElementLocator(element);
515 | selected = {
516 | elementKey,
517 | locator,
518 | label: generateElementLabel(element),
519 | fullLabel: generateFullElementLabel(element),
520 | tagName: element.tagName.toLowerCase(),
521 | updatedAt: Date.now(),
522 | };
523 | } else {
524 | // Deselection - clear tracking
525 | if (lastBroadcastedSelectionKey === null) return; // Already deselected
526 | lastBroadcastedSelectionKey = null;
527 | }
528 |
529 | const payload: WebEditorSelectionChangedPayload = {
530 | tabId: 0, // Will be filled by background script from sender.tab.id
531 | selected,
532 | pageUrl: window.location.href,
533 | };
534 |
535 | // Broadcast immediately (no debounce for selection changes)
536 | if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) {
537 | chrome.runtime
538 | .sendMessage({
539 | type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED,
540 | payload,
541 | })
542 | .catch(() => {
543 | // Ignore if no listeners (e.g., sidepanel not open)
544 | });
545 | }
546 | }
547 |
548 | /**
549 | * Broadcast "editor cleared" state when stopping.
550 | * Sends empty TX and null selection to remove chips from sidepanel.
551 | */
552 | function broadcastEditorCleared(): void {
553 | // Reset selection dedupe so next start can broadcast correctly
554 | lastBroadcastedSelectionKey = null;
555 |
556 | if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return;
557 |
558 | const pageUrl = window.location.href;
559 |
560 | // Send empty TX state
561 | const txPayload: WebEditorTxChangedPayload = {
562 | tabId: 0,
563 | action: 'clear',
564 | elements: [],
565 | undoCount: 0,
566 | redoCount: 0,
567 | hasApplicableChanges: false,
568 | pageUrl,
569 | };
570 |
571 | // Send null selection
572 | const selectionPayload: WebEditorSelectionChangedPayload = {
573 | tabId: 0,
574 | selected: null,
575 | pageUrl,
576 | };
577 |
578 | chrome.runtime
579 | .sendMessage({
580 | type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED,
581 | payload: txPayload,
582 | })
583 | .catch(() => {});
584 |
585 | chrome.runtime
586 | .sendMessage({
587 | type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED,
588 | payload: selectionPayload,
589 | })
590 | .catch(() => {});
591 | }
592 |
593 | /**
594 | * Handle transaction changes from TransactionManager
595 | */
596 | function handleTransactionChange(event: TransactionChangeEvent): void {
597 | // Log transaction events for debugging
598 | const { action, undoCount, redoCount } = event;
599 | console.log(
600 | `${WEB_EDITOR_V2_LOG_PREFIX} Transaction: ${action} (undo: ${undoCount}, redo: ${redoCount})`,
601 | );
602 |
603 | // Update toolbar UI with undo/redo counts
604 | state.toolbar?.setHistory(undoCount, redoCount);
605 |
606 | // Refresh property panel after undo/redo to reflect current styles
607 | if (action === 'undo' || action === 'redo') {
608 | state.propertyPanel?.refresh();
609 | }
610 |
611 | // Broadcast aggregated TX state for AgentChat integration (Phase 1.4)
612 | broadcastTxChanged(action as WebEditorTxChangeAction);
613 |
614 | // Notify HMR consistency verifier of transaction change (Phase 4.8)
615 | state.hmrConsistencyVerifier?.onTransactionChange(event);
616 | }
617 |
618 | /**
619 | * Check if the transaction being applied is still the latest in undo stack.
620 | * Used to determine if we should auto-rollback on failure.
621 | *
622 | * Returns a detailed status to distinguish between:
623 | * - 'ok': Transaction is still latest, safe to rollback
624 | * - 'no_snapshot': No apply in progress
625 | * - 'tm_unavailable': TransactionManager not available
626 | * - 'stack_empty': Undo stack is empty (tx was already undone)
627 | * - 'tx_changed': User made new edits or tx was merged
628 | */
629 | type ApplyTxStatus = 'ok' | 'no_snapshot' | 'tm_unavailable' | 'stack_empty' | 'tx_changed';
630 |
631 | function checkApplyingTxStatus(): ApplyTxStatus {
632 | const snapshot = state.applyingSnapshot;
633 | if (!snapshot) return 'no_snapshot';
634 |
635 | const tm = state.transactionManager;
636 | if (!tm) return 'tm_unavailable';
637 |
638 | const undoStack = tm.getUndoStack();
639 | if (undoStack.length === 0) return 'stack_empty';
640 |
641 | const latest = undoStack[undoStack.length - 1]!;
642 |
643 | // Check both id and timestamp to handle merged transactions
644 | if (latest.id !== snapshot.txId || latest.timestamp !== snapshot.txTimestamp) {
645 | return 'tx_changed';
646 | }
647 |
648 | return 'ok';
649 | }
650 |
651 | /**
652 | * Attempt to rollback the applying transaction on failure.
653 | * Returns a descriptive error message based on rollback result.
654 | *
655 | * Rollback is only attempted when:
656 | * - The transaction is still the latest in undo stack
657 | * - No new edits were made during the apply operation
658 | */
659 | function attemptRollbackOnFailure(originalError: string): string {
660 | const status = checkApplyingTxStatus();
661 |
662 | // Cannot rollback: TM not available or no snapshot
663 | if (status === 'no_snapshot' || status === 'tm_unavailable') {
664 | console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed, unable to revert (${status})`);
665 | return `${originalError} (unable to revert)`;
666 | }
667 |
668 | // Stack is empty - tx was already undone (race condition or user action)
669 | if (status === 'stack_empty') {
670 | console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed, stack empty (already reverted?)`);
671 | return `${originalError} (already reverted)`;
672 | }
673 |
674 | // User made new edits during apply - don't rollback their work
675 | if (status === 'tx_changed') {
676 | console.warn(
677 | `${WEB_EDITOR_V2_LOG_PREFIX} Apply failed but new edits detected, skipping auto-rollback`,
678 | );
679 | return `${originalError} (new edits detected, not reverted)`;
680 | }
681 |
682 | // Status is 'ok' - safe to attempt rollback
683 | const tm = state.transactionManager!;
684 | const undone = tm.undo();
685 | if (undone) {
686 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed, changes auto-reverted`);
687 | return `${originalError} (changes reverted)`;
688 | }
689 |
690 | // undo() returned null - likely locateElement() failed
691 | console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed and auto-revert also failed`);
692 | return `${originalError} (revert failed)`;
693 | }
694 |
695 | /**
696 | * Apply the latest transaction to Agent (Apply to Code)
697 | *
698 | * Phase 2.10: On failure, automatically attempts to undo the transaction
699 | * to revert DOM changes. The transaction moves to redo stack so user can retry.
700 | */
701 | async function applyLatestTransaction(): Promise<{ requestId?: string; sessionId?: string }> {
702 | const tm = state.transactionManager;
703 | if (!tm) {
704 | throw new Error('Transaction manager not ready');
705 | }
706 |
707 | // Prevent concurrent apply operations
708 | if (state.applyingSnapshot) {
709 | throw new Error('Apply already in progress');
710 | }
711 |
712 | const undoStack = tm.getUndoStack();
713 | const tx = undoStack.length > 0 ? undoStack[undoStack.length - 1] : null;
714 | if (!tx) {
715 | throw new Error('No changes to apply');
716 | }
717 |
718 | // Apply-to-Code currently supports only style/text transactions
719 | if (tx.type !== 'style' && tx.type !== 'text') {
720 | throw new Error(`Apply does not support "${tx.type}" transactions yet`);
721 | }
722 |
723 | // Snapshot the transaction for rollback tracking
724 | state.applyingSnapshot = {
725 | txId: tx.id,
726 | txTimestamp: tx.timestamp,
727 | };
728 |
729 | // Markers indicating error was already processed by attemptRollbackOnFailure
730 | const ROLLBACK_MARKERS = [
731 | '(changes reverted)',
732 | '(new edits detected',
733 | '(revert failed)',
734 | '(unable to revert)',
735 | '(already reverted)',
736 | ];
737 |
738 | const isAlreadyProcessed = (err: unknown): boolean =>
739 | err instanceof Error && ROLLBACK_MARKERS.some((m) => err.message.includes(m));
740 |
741 | try {
742 | const resp = await sendTransactionToAgent(tx);
743 | const r = resp as {
744 | success?: unknown;
745 | requestId?: unknown;
746 | sessionId?: unknown;
747 | error?: unknown;
748 | } | null;
749 |
750 | if (r && r.success === true) {
751 | const requestId = typeof r.requestId === 'string' ? r.requestId : undefined;
752 | const sessionId = typeof r.sessionId === 'string' ? r.sessionId : undefined;
753 |
754 | // Start tracking execution status if we have a requestId
755 | if (requestId && sessionId && state.executionTracker) {
756 | state.executionTracker.track(requestId, sessionId);
757 | }
758 |
759 | // Start HMR consistency verification (Phase 4.8)
760 | state.hmrConsistencyVerifier?.start({
761 | tx,
762 | requestId,
763 | sessionId,
764 | element: state.selectedElement,
765 | });
766 |
767 | return { requestId, sessionId };
768 | }
769 |
770 | // Agent returned failure response - attempt rollback
771 | const errorMsg = typeof r?.error === 'string' ? r.error : 'Agent request failed';
772 | throw new Error(attemptRollbackOnFailure(errorMsg));
773 | } catch (error) {
774 | // Re-throw if already processed by attemptRollbackOnFailure
775 | if (isAlreadyProcessed(error)) {
776 | throw error;
777 | }
778 |
779 | // Network error or other unprocessed exception - attempt rollback
780 | const originalMsg = error instanceof Error ? error.message : String(error);
781 | throw new Error(attemptRollbackOnFailure(originalMsg));
782 | } finally {
783 | // Clear snapshot regardless of outcome
784 | state.applyingSnapshot = null;
785 | }
786 | }
787 |
788 | /**
789 | * Apply all applicable transactions to Agent (batch Apply to Code)
790 | *
791 | * Phase 1.4: Aggregates the undo stack by element and sends a single batch request.
792 | * Unlike applyLatestTransaction, this does NOT auto-rollback on failure.
793 | */
794 | async function applyAllTransactions(): Promise<{ requestId?: string; sessionId?: string }> {
795 | const tm = state.transactionManager;
796 | if (!tm) {
797 | throw new Error('Transaction manager not ready');
798 | }
799 |
800 | // Prevent concurrent apply operations
801 | if (state.applyingSnapshot) {
802 | throw new Error('Apply already in progress');
803 | }
804 |
805 | const undoStack = tm.getUndoStack();
806 | if (undoStack.length === 0) {
807 | throw new Error('No changes to apply');
808 | }
809 |
810 | // Block unsupported transaction types
811 | for (const tx of undoStack) {
812 | if (tx.type === 'move') {
813 | throw new Error('Apply does not support reorder operations yet');
814 | }
815 | if (tx.type === 'structure') {
816 | throw new Error('Apply does not support structure operations yet');
817 | }
818 | if (tx.type !== 'style' && tx.type !== 'text' && tx.type !== 'class') {
819 | throw new Error(`Apply does not support "${tx.type}" transactions`);
820 | }
821 | }
822 |
823 | const elements = aggregateTransactionsByElement(undoStack);
824 | if (elements.length === 0) {
825 | throw new Error('No net changes to apply');
826 | }
827 |
828 | // Snapshot latest transaction for concurrency tracking
829 | const latestTx = undoStack[undoStack.length - 1]!;
830 | state.applyingSnapshot = {
831 | txId: latestTx.id,
832 | txTimestamp: latestTx.timestamp,
833 | };
834 |
835 | try {
836 | if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
837 | throw new Error('Chrome runtime API not available');
838 | }
839 |
840 | const payload: WebEditorApplyBatchPayload = {
841 | tabId: 0, // Will be filled by background script
842 | elements,
843 | excludedKeys: [], // TODO: Read from storage if exclude feature is implemented
844 | pageUrl: window.location.href,
845 | };
846 |
847 | const resp = await chrome.runtime.sendMessage({
848 | type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY_BATCH,
849 | payload,
850 | });
851 |
852 | const r = resp as {
853 | success?: unknown;
854 | requestId?: unknown;
855 | sessionId?: unknown;
856 | error?: unknown;
857 | } | null;
858 |
859 | if (r && r.success === true) {
860 | const requestId = typeof r.requestId === 'string' ? r.requestId : undefined;
861 | const sessionId = typeof r.sessionId === 'string' ? r.sessionId : undefined;
862 |
863 | // Start tracking execution status if we have a requestId
864 | if (requestId && sessionId && state.executionTracker) {
865 | state.executionTracker.track(requestId, sessionId);
866 | }
867 |
868 | // Clear transaction history after successful apply
869 | // This prevents undo/redo since changes are now committed to code
870 | tm.clear();
871 |
872 | // Deselect current element after successful apply
873 | // This clears the selection chip in the UI
874 | handleDeselect();
875 |
876 | return { requestId, sessionId };
877 | }
878 |
879 | const errorMsg = typeof r?.error === 'string' ? r.error : 'Agent request failed';
880 | throw new Error(errorMsg);
881 | } finally {
882 | state.applyingSnapshot = null;
883 | }
884 | }
885 |
886 | /**
887 | * Revert a specific element to its baseline state (Phase 2 - Selective Undo).
888 | * Creates compensating transactions so the user can undo the revert.
889 | */
890 | async function revertElement(
891 | elementKey: WebEditorElementKey,
892 | ): Promise<WebEditorRevertElementResponse> {
893 | const key = String(elementKey ?? '').trim();
894 | if (!key) {
895 | return { success: false, error: 'elementKey is required' };
896 | }
897 |
898 | const tm = state.transactionManager;
899 | if (!tm) {
900 | return { success: false, error: 'Transaction manager not ready' };
901 | }
902 |
903 | if (state.applyingSnapshot) {
904 | return { success: false, error: 'Cannot revert while Apply is in progress' };
905 | }
906 |
907 | try {
908 | const undoStack = tm.getUndoStack();
909 | const summaries = aggregateTransactionsByElement(undoStack);
910 | const summary = summaries.find((s) => s.elementKey === key);
911 |
912 | if (!summary) {
913 | return { success: false, error: 'Element not found in current changes' };
914 | }
915 |
916 | const element = locateElement(summary.locator);
917 | if (!element || !element.isConnected) {
918 | return { success: false, error: 'Failed to locate element for revert' };
919 | }
920 |
921 | const reverted: NonNullable<WebEditorRevertElementResponse['reverted']> = {};
922 | let didRevert = false;
923 |
924 | // Revert class first so subsequent locators are based on baseline classes.
925 | const classChanges = summary.netEffect.classChanges;
926 | if (classChanges) {
927 | const baselineClasses = Array.isArray(classChanges.before) ? classChanges.before : [];
928 | const beforeClasses = (() => {
929 | try {
930 | const list = (element as HTMLElement).classList;
931 | if (list && typeof list[Symbol.iterator] === 'function') {
932 | return Array.from(list).filter(Boolean);
933 | }
934 | } catch {
935 | // Fallback for non-HTMLElement
936 | }
937 |
938 | const raw = element.getAttribute('class') ?? '';
939 | return raw
940 | .split(/\s+/)
941 | .map((t) => t.trim())
942 | .filter(Boolean);
943 | })();
944 |
945 | const tx = tm.recordClass(element, beforeClasses, baselineClasses);
946 | if (tx) {
947 | reverted.class = true;
948 | didRevert = true;
949 | }
950 | }
951 |
952 | // Revert text content
953 | const textChange = summary.netEffect.textChange;
954 | if (textChange) {
955 | const baselineText = String(textChange.before ?? '');
956 | const beforeText = element.textContent ?? '';
957 |
958 | if (beforeText !== baselineText) {
959 | element.textContent = baselineText;
960 | const tx = tm.recordText(element, beforeText, baselineText);
961 | if (tx) {
962 | reverted.text = true;
963 | didRevert = true;
964 | }
965 | }
966 | }
967 |
968 | // Revert styles
969 | const styleChanges = summary.netEffect.styleChanges;
970 | if (styleChanges) {
971 | const before = styleChanges.before ?? {};
972 | const after = styleChanges.after ?? {};
973 |
974 | const properties = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]))
975 | .map((p) => String(p ?? '').trim())
976 | .filter(Boolean);
977 |
978 | if (properties.length > 0) {
979 | const handle = tm.beginMultiStyle(element, properties);
980 | if (handle) {
981 | handle.set(before);
982 | const tx = handle.commit({ merge: false });
983 | if (tx) {
984 | reverted.style = true;
985 | didRevert = true;
986 | }
987 | }
988 | }
989 | }
990 |
991 | if (!didRevert) {
992 | return { success: false, error: 'No changes were reverted' };
993 | }
994 |
995 | // Ensure property panel reflects reverted values immediately
996 | state.propertyPanel?.refresh();
997 |
998 | return { success: true, reverted };
999 | } catch (error) {
1000 | console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Revert element failed:`, error);
1001 | return {
1002 | success: false,
1003 | error: error instanceof Error ? error.message : String(error),
1004 | };
1005 | }
1006 | }
1007 |
1008 | /**
1009 | * Clear current selection (called from sidepanel after send).
1010 | * Triggers handleDeselect which broadcasts null selection to sidepanel.
1011 | */
1012 | function clearSelection(): void {
1013 | if (!state.selectedElement) {
1014 | // Already deselected
1015 | return;
1016 | }
1017 |
1018 | // Use EventController to properly transition to hover mode
1019 | // This triggers onDeselect callback → handleDeselect → broadcastSelectionChanged(null)
1020 | if (state.eventController) {
1021 | state.eventController.setMode('hover');
1022 |
1023 | // Edge case: if setMode('hover') didn't trigger deselect (e.g., already in hover mode
1024 | // but selectedElement was set programmatically), manually call handleDeselect
1025 | if (state.selectedElement) {
1026 | handleDeselect();
1027 | }
1028 | } else {
1029 | // Fallback if eventController not available: directly call handleDeselect
1030 | handleDeselect();
1031 | }
1032 |
1033 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Selection cleared (from sidepanel)`);
1034 | }
1035 |
1036 | /**
1037 | * Handle transaction apply errors
1038 | */
1039 | function handleTransactionError(error: unknown): void {
1040 | console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Transaction apply error:`, error);
1041 | }
1042 |
1043 | /**
1044 | * Start the editor
1045 | */
1046 | function start(): void {
1047 | if (state.active) {
1048 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Already active`);
1049 | return;
1050 | }
1051 |
1052 | try {
1053 | // Mount Shadow DOM host
1054 | state.shadowHost = mountShadowHost({});
1055 |
1056 | // Initialize Canvas Overlay
1057 | const elements = state.shadowHost.getElements();
1058 | if (!elements?.overlayRoot) {
1059 | throw new Error('Shadow host overlayRoot not available');
1060 | }
1061 | state.canvasOverlay = createCanvasOverlay({
1062 | container: elements.overlayRoot,
1063 | });
1064 |
1065 | // Initialize Performance Monitor (Phase 5.3) - disabled by default
1066 | state.perfMonitor = createPerfMonitor({
1067 | container: elements.overlayRoot,
1068 | fpsUiIntervalMs: 500,
1069 | memorySampleIntervalMs: 1000,
1070 | });
1071 |
1072 | // Register hotkey: Ctrl/Cmd + Shift + P toggles perf monitor
1073 | const perfHotkeyHandler = (event: KeyboardEvent): void => {
1074 | // Ignore key repeats to avoid rapid toggles when holding the shortcut
1075 | if (event.repeat) return;
1076 |
1077 | const isMod = event.metaKey || event.ctrlKey;
1078 | if (!isMod) return;
1079 | if (!event.shiftKey) return;
1080 | if (event.altKey) return;
1081 |
1082 | const key = (event.key || '').toLowerCase();
1083 | if (key !== 'p') return;
1084 |
1085 | const monitor = state.perfMonitor;
1086 | if (!monitor) return;
1087 |
1088 | monitor.toggle();
1089 |
1090 | // Prevent browser shortcuts (e.g., print dialog)
1091 | event.preventDefault();
1092 | event.stopPropagation();
1093 | event.stopImmediatePropagation();
1094 | };
1095 |
1096 | const hotkeyOptions: AddEventListenerOptions = { capture: true, passive: false };
1097 | window.addEventListener('keydown', perfHotkeyHandler, hotkeyOptions);
1098 | state.perfHotkeyCleanup = () => {
1099 | window.removeEventListener('keydown', perfHotkeyHandler, hotkeyOptions);
1100 | };
1101 |
1102 | // Initialize Selection Engine for intelligent element picking
1103 | state.selectionEngine = createSelectionEngine({
1104 | isOverlayElement: state.shadowHost.isOverlayElement,
1105 | });
1106 |
1107 | // Initialize Position Tracker for scroll/resize synchronization
1108 | state.positionTracker = createPositionTracker({
1109 | onPositionUpdate: handlePositionUpdate,
1110 | });
1111 |
1112 | // Initialize Transaction Manager for undo/redo support
1113 | // Use isEventFromUi (not isOverlayElement) to properly check event source
1114 | state.transactionManager = createTransactionManager({
1115 | enableKeyBindings: true,
1116 | // Include both Shadow UI events and events from editing element
1117 | // This prevents Ctrl/Cmd+Z from triggering global undo while editing text
1118 | isEventFromEditorUi: (event) => {
1119 | if (state.shadowHost?.isEventFromUi(event)) return true;
1120 | // Also ignore events from the editing element (allow native contentEditable undo)
1121 | const session = editSession;
1122 | if (session?.element) {
1123 | try {
1124 | const path = typeof event.composedPath === 'function' ? event.composedPath() : null;
1125 | if (path?.some((node) => node === session.element)) return true;
1126 | } catch {
1127 | // Fallback
1128 | const target = event.target;
1129 | if (target instanceof Node && session.element.contains(target)) return true;
1130 | }
1131 | }
1132 | return false;
1133 | },
1134 | onChange: handleTransactionChange,
1135 | onApplyError: handleTransactionError,
1136 | });
1137 |
1138 | // Initialize Resize Handles Controller (Phase 4.9)
1139 | state.handlesController = createHandlesController({
1140 | container: elements.overlayRoot,
1141 | canvasOverlay: state.canvasOverlay,
1142 | transactionManager: state.transactionManager,
1143 | positionTracker: state.positionTracker,
1144 | });
1145 |
1146 | // Initialize Drag Reorder Controller (Phase 2.4-2.6)
1147 | state.dragReorderController = createDragReorderController({
1148 | isOverlayElement: state.shadowHost.isOverlayElement,
1149 | uiRoot: elements.uiRoot,
1150 | canvasOverlay: state.canvasOverlay,
1151 | positionTracker: state.positionTracker,
1152 | transactionManager: state.transactionManager,
1153 | });
1154 |
1155 | // Initialize Event Controller for interaction handling
1156 | // Wire up SelectionEngine's findBestTargetFromEvent for Shadow DOM-aware selection (click only)
1157 | // Hover uses fast elementFromPoint for 60FPS performance
1158 | state.eventController = createEventController({
1159 | isOverlayElement: state.shadowHost.isOverlayElement,
1160 | onHover: handleHover,
1161 | onSelect: handleSelect,
1162 | onDeselect: handleDeselect,
1163 | onStartEdit: startEdit,
1164 | findTargetForSelect: (_x, _y, modifiers, event) =>
1165 | state.selectionEngine?.findBestTargetFromEvent(event, modifiers) ?? null,
1166 | getSelectedElement: () => state.selectedElement,
1167 | onStartDrag: (ev) => state.dragReorderController?.onDragStart(ev) ?? false,
1168 | onDragMove: (ev) => state.dragReorderController?.onDragMove(ev),
1169 | onDragEnd: (ev) => state.dragReorderController?.onDragEnd(ev),
1170 | onDragCancel: (ev) => state.dragReorderController?.onDragCancel(ev),
1171 | });
1172 |
1173 | // Initialize ExecutionTracker for tracking Agent execution status (Phase 3.10)
1174 | state.executionTracker = createExecutionTracker({
1175 | onStatusChange: (execState: ExecutionState) => {
1176 | // Map execution status to toolbar status (only used when HMR verifier is not active)
1177 | // When verifier is active, it controls toolbar status after execution completes
1178 | const verifierPhase = state.hmrConsistencyVerifier?.getSnapshot().phase ?? 'idle';
1179 | const verifierActive = verifierPhase !== 'idle';
1180 |
1181 | // Only update toolbar directly if verifier is not handling it
1182 | if (!verifierActive || execState.status !== 'completed') {
1183 | const statusMap: Record<string, string> = {
1184 | pending: 'applying',
1185 | starting: 'starting',
1186 | running: 'running',
1187 | locating: 'locating',
1188 | applying: 'applying',
1189 | completed: 'completed',
1190 | failed: 'failed',
1191 | error: 'failed', // Server may return 'error', treat same as 'failed'
1192 | timeout: 'timeout',
1193 | cancelled: 'cancelled',
1194 | };
1195 | type ToolbarStatusType = Parameters<NonNullable<typeof state.toolbar>['setStatus']>[0];
1196 | const toolbarStatus = (statusMap[execState.status] ?? 'running') as ToolbarStatusType;
1197 | state.toolbar?.setStatus(toolbarStatus, execState.message);
1198 | }
1199 |
1200 | // Forward to HMR consistency verifier (Phase 4.8)
1201 | state.hmrConsistencyVerifier?.onExecutionStatus(execState);
1202 | },
1203 | });
1204 |
1205 | // Initialize HMR Consistency Verifier (Phase 4.8)
1206 | state.hmrConsistencyVerifier = createHmrConsistencyVerifier({
1207 | transactionManager: state.transactionManager,
1208 | getSelectedElement: () => state.selectedElement,
1209 | onReselect: (element) => handleSelect(element, DEFAULT_MODIFIERS),
1210 | onDeselect: handleDeselect,
1211 | setToolbarStatus: (status, message) => state.toolbar?.setStatus(status, message),
1212 | isOverlayElement: state.shadowHost?.isOverlayElement,
1213 | selectionEngine: state.selectionEngine ?? undefined,
1214 | });
1215 |
1216 | // Initialize Toolbar UI
1217 | state.toolbar = createToolbar({
1218 | container: elements.uiRoot,
1219 | dock: 'top',
1220 | initialPosition: state.toolbarPosition,
1221 | onPositionChange: (position) => {
1222 | state.toolbarPosition = position;
1223 | },
1224 | getApplyBlockReason: () => {
1225 | const tm = state.transactionManager;
1226 | if (!tm) return undefined;
1227 |
1228 | const undoStack = tm.getUndoStack();
1229 | if (undoStack.length === 0) return undefined;
1230 |
1231 | // Check all transactions for unsupported types (Phase 1.4)
1232 | // NOTE: We only do O(n) type checking here, NOT aggregation.
1233 | // Full net effect check happens in applyAllTransactions() to avoid
1234 | // performance issues during frequent merge events.
1235 | for (const tx of undoStack) {
1236 | if (tx.type === 'move') {
1237 | return 'Apply does not support reorder operations yet';
1238 | }
1239 | if (tx.type === 'structure') {
1240 | return 'Apply does not support structure operations yet';
1241 | }
1242 | if (tx.type !== 'style' && tx.type !== 'text' && tx.type !== 'class') {
1243 | return `Apply does not support "${tx.type}" transactions`;
1244 | }
1245 | }
1246 |
1247 | return undefined;
1248 | },
1249 | getSelectedElement: () => state.selectedElement,
1250 | onStructure: (data) => {
1251 | const target = state.selectedElement;
1252 | if (!target) return;
1253 |
1254 | const tm = state.transactionManager;
1255 | if (!tm) return;
1256 |
1257 | const tx = tm.applyStructure(target, data);
1258 | if (!tx) return;
1259 |
1260 | // Update selection based on action type
1261 | // For wrap/stack: select the new wrapper
1262 | // For unwrap: select the unwrapped child
1263 | // For duplicate: select the clone
1264 | // For delete: deselect
1265 | if (data.action === 'delete') {
1266 | handleDeselect();
1267 | } else {
1268 | // The transaction's targetLocator points to the new selection target
1269 | // For wrap/stack: wrapper
1270 | // For unwrap: child
1271 | // For duplicate: clone
1272 | const newTarget = locateElement(tx.targetLocator);
1273 | if (newTarget && newTarget.isConnected) {
1274 | handleSelect(newTarget, DEFAULT_MODIFIERS);
1275 | }
1276 | }
1277 | },
1278 | onApply: applyAllTransactions,
1279 | onUndo: () => state.transactionManager?.undo(),
1280 | onRedo: () => state.transactionManager?.redo(),
1281 | onRequestClose: () => stop(),
1282 | });
1283 |
1284 | // Initialize toolbar history display
1285 | state.toolbar.setHistory(
1286 | state.transactionManager.getUndoStack().length,
1287 | state.transactionManager.getRedoStack().length,
1288 | );
1289 |
1290 | // Initialize Breadcrumbs UI (shows selected element ancestry)
1291 | state.breadcrumbs = createBreadcrumbs({
1292 | container: elements.uiRoot,
1293 | dock: 'top',
1294 | onSelect: (element) => {
1295 | // When a breadcrumb is clicked, select that ancestor element
1296 | if (element.isConnected) {
1297 | handleSelect(element, DEFAULT_MODIFIERS);
1298 | }
1299 | },
1300 | });
1301 |
1302 | // Initialize Props Bridge (Phase 7)
1303 | state.propsBridge = createPropsBridge({});
1304 |
1305 | // Initialize Design Tokens Service (Phase 5.3)
1306 | state.tokensService = createDesignTokensService();
1307 |
1308 | // Initialize Property Panel (Phase 3)
1309 | state.propertyPanel = createPropertyPanel({
1310 | container: elements.uiRoot,
1311 | transactionManager: state.transactionManager,
1312 | propsBridge: state.propsBridge,
1313 | tokensService: state.tokensService,
1314 | initialPosition: state.propertyPanelPosition,
1315 | onPositionChange: (position) => {
1316 | state.propertyPanelPosition = position;
1317 | },
1318 | defaultTab: 'design',
1319 | onSelectElement: (element) => {
1320 | // When an element is selected from Components tree
1321 | if (element.isConnected) {
1322 | handleSelect(element, DEFAULT_MODIFIERS);
1323 | }
1324 | },
1325 | onRequestClose: () => stop(),
1326 | });
1327 |
1328 | // Clamp floating UI positions on window resize (session-only persistence)
1329 | let uiResizeRafId: number | null = null;
1330 |
1331 | const clampFloatingUi = (): void => {
1332 | const toolbarPos = state.toolbarPosition;
1333 | const panelPos = state.propertyPanelPosition;
1334 |
1335 | if (state.toolbar && toolbarPos) {
1336 | state.toolbar.setPosition(toolbarPos);
1337 | }
1338 | if (state.propertyPanel && panelPos) {
1339 | state.propertyPanel.setPosition(panelPos);
1340 | }
1341 | };
1342 |
1343 | const onWindowResize = (): void => {
1344 | if (!state.active) return;
1345 | if (uiResizeRafId !== null) return;
1346 | uiResizeRafId = window.requestAnimationFrame(() => {
1347 | uiResizeRafId = null;
1348 | clampFloatingUi();
1349 | });
1350 | };
1351 |
1352 | window.addEventListener('resize', onWindowResize, { passive: true });
1353 | state.uiResizeCleanup = () => {
1354 | window.removeEventListener('resize', onWindowResize);
1355 | if (uiResizeRafId !== null) {
1356 | window.cancelAnimationFrame(uiResizeRafId);
1357 | uiResizeRafId = null;
1358 | }
1359 | };
1360 |
1361 | // Ensure restored positions are visible on first render
1362 | clampFloatingUi();
1363 |
1364 | state.active = true;
1365 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Started`);
1366 | } catch (error) {
1367 | // Cleanup on failure (reverse order)
1368 | state.uiResizeCleanup?.();
1369 | state.uiResizeCleanup = null;
1370 | state.propertyPanel?.dispose();
1371 | state.propertyPanel = null;
1372 | state.tokensService?.dispose();
1373 | state.tokensService = null;
1374 | state.propsBridge?.dispose();
1375 | state.propsBridge = null;
1376 | state.breadcrumbs?.dispose();
1377 | state.breadcrumbs = null;
1378 | state.toolbar?.dispose();
1379 | state.toolbar = null;
1380 | state.eventController?.dispose();
1381 | state.eventController = null;
1382 | state.dragReorderController?.dispose();
1383 | state.dragReorderController = null;
1384 | state.handlesController?.dispose();
1385 | state.handlesController = null;
1386 | state.transactionManager?.dispose();
1387 | state.transactionManager = null;
1388 | state.positionTracker?.dispose();
1389 | state.positionTracker = null;
1390 | state.selectionEngine?.dispose();
1391 | state.selectionEngine = null;
1392 | state.perfHotkeyCleanup?.();
1393 | state.perfHotkeyCleanup = null;
1394 | state.perfMonitor?.dispose();
1395 | state.perfMonitor = null;
1396 | state.canvasOverlay?.dispose();
1397 | state.canvasOverlay = null;
1398 | state.shadowHost?.dispose();
1399 | state.shadowHost = null;
1400 | state.hoveredElement = null;
1401 | state.selectedElement = null;
1402 | state.applyingSnapshot = null;
1403 | state.active = false;
1404 |
1405 | console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Failed to start:`, error);
1406 | }
1407 | }
1408 |
1409 | /**
1410 | * Stop the editor
1411 | */
1412 | function stop(): void {
1413 | if (!state.active) {
1414 | return;
1415 | }
1416 |
1417 | state.active = false;
1418 |
1419 | // Cancel pending debounced broadcasts (Phase 1.4)
1420 | if (txChangedBroadcastTimer !== null) {
1421 | window.clearTimeout(txChangedBroadcastTimer);
1422 | txChangedBroadcastTimer = null;
1423 | }
1424 |
1425 | try {
1426 | // Cleanup in reverse order of initialization
1427 |
1428 | // Commit any in-progress text edit before cleanup
1429 | if (editSession) {
1430 | commitEdit();
1431 | }
1432 |
1433 | // Cleanup resize listener for floating UI
1434 | state.uiResizeCleanup?.();
1435 | state.uiResizeCleanup = null;
1436 |
1437 | // Cleanup Property Panel (Phase 3)
1438 | state.propertyPanel?.dispose();
1439 | state.propertyPanel = null;
1440 |
1441 | // Cleanup Design Tokens Service (Phase 5.3)
1442 | state.tokensService?.dispose();
1443 | state.tokensService = null;
1444 |
1445 | // Cleanup Props Bridge (Phase 7) - best effort cleanup
1446 | void state.propsBridge?.cleanup();
1447 | state.propsBridge = null;
1448 |
1449 | // Cleanup Breadcrumbs UI
1450 | state.breadcrumbs?.dispose();
1451 | state.breadcrumbs = null;
1452 |
1453 | // Cleanup Toolbar UI
1454 | state.toolbar?.dispose();
1455 | state.toolbar = null;
1456 |
1457 | // Cleanup Event Controller (stops event interception)
1458 | state.eventController?.dispose();
1459 | state.eventController = null;
1460 |
1461 | // Cleanup Drag Reorder Controller
1462 | state.dragReorderController?.dispose();
1463 | state.dragReorderController = null;
1464 |
1465 | // Cleanup Resize Handles Controller (Phase 4.9)
1466 | state.handlesController?.dispose();
1467 | state.handlesController = null;
1468 |
1469 | // Cleanup Execution Tracker (Phase 3.10)
1470 | state.executionTracker?.dispose();
1471 | state.executionTracker = null;
1472 |
1473 | // Cleanup HMR Consistency Verifier (Phase 4.8)
1474 | state.hmrConsistencyVerifier?.dispose();
1475 | state.hmrConsistencyVerifier = null;
1476 |
1477 | // Cleanup Transaction Manager (clears history)
1478 | state.transactionManager?.dispose();
1479 | state.transactionManager = null;
1480 |
1481 | // Cleanup Position Tracker (stops scroll/resize monitoring)
1482 | state.positionTracker?.dispose();
1483 | state.positionTracker = null;
1484 |
1485 | // Cleanup Selection Engine
1486 | state.selectionEngine?.dispose();
1487 | state.selectionEngine = null;
1488 |
1489 | // Cleanup Performance Monitor (Phase 5.3)
1490 | state.perfHotkeyCleanup?.();
1491 | state.perfHotkeyCleanup = null;
1492 | state.perfMonitor?.dispose();
1493 | state.perfMonitor = null;
1494 |
1495 | // Cleanup Canvas Overlay
1496 | state.canvasOverlay?.dispose();
1497 | state.canvasOverlay = null;
1498 |
1499 | // Cleanup Shadow DOM host
1500 | state.shadowHost?.dispose();
1501 | state.shadowHost = null;
1502 |
1503 | // Clear element references and apply state
1504 | state.hoveredElement = null;
1505 | state.selectedElement = null;
1506 | state.applyingSnapshot = null;
1507 |
1508 | console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Stopped`);
1509 | } catch (error) {
1510 | console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Error during cleanup:`, error);
1511 |
1512 | // Force cleanup
1513 | state.propertyPanel = null;
1514 | state.propsBridge = null;
1515 | state.breadcrumbs = null;
1516 | state.toolbar = null;
1517 | state.eventController = null;
1518 | state.dragReorderController = null;
1519 | state.handlesController = null;
1520 | state.transactionManager = null;
1521 | state.positionTracker = null;
1522 | state.selectionEngine = null;
1523 | state.perfHotkeyCleanup = null;
1524 | state.perfMonitor = null;
1525 | state.canvasOverlay = null;
1526 | state.shadowHost = null;
1527 | state.hoveredElement = null;
1528 | state.selectedElement = null;
1529 | state.applyingSnapshot = null;
1530 | } finally {
1531 | // Always broadcast clear state to sidepanel (removes chips)
1532 | broadcastEditorCleared();
1533 | }
1534 | }
1535 |
1536 | /**
1537 | * Toggle the editor on/off
1538 | */
1539 | function toggle(): boolean {
1540 | if (state.active) {
1541 | stop();
1542 | } else {
1543 | start();
1544 | }
1545 | return state.active;
1546 | }
1547 |
1548 | /**
1549 | * Get current editor state
1550 | */
1551 | function getState(): WebEditorState {
1552 | return {
1553 | active: state.active,
1554 | version: WEB_EDITOR_V2_VERSION,
1555 | };
1556 | }
1557 |
1558 | return {
1559 | start,
1560 | stop,
1561 | toggle,
1562 | getState,
1563 | revertElement,
1564 | clearSelection,
1565 | };
1566 | }
1567 |
```