import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; import { WEB_EDITOR_V2_ACTIONS, WEB_EDITOR_V1_ACTIONS, type ElementChangeSummary, type WebEditorApplyBatchPayload, type WebEditorTxChangedPayload, type WebEditorHighlightElementPayload, type WebEditorRevertElementPayload, type WebEditorCancelExecutionPayload, type WebEditorCancelExecutionResponse, } from '@/common/web-editor-types'; import { openAgentChatSidepanel } from '../utils/sidepanel'; const CONTEXT_MENU_ID = 'web_editor_toggle'; const COMMAND_KEY = 'toggle_web_editor'; const DEFAULT_NATIVE_SERVER_PORT = 12306; /** Storage key prefix for TX change session data (per-tab isolation) */ const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-'; const WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-'; /** Storage key prefix for excluded element keys (per-tab isolation, managed by sidepanel) */ const WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-'; /** Storage key for AgentChat selected session ID */ const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id'; // In-memory execution status cache (per requestId) interface ExecutionStatusEntry { status: string; message?: string; updatedAt: number; result?: { success: boolean; summary?: string; error?: string }; } const executionStatusCache = new Map(); const STATUS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes function cleanupExpiredStatuses(): void { const now = Date.now(); for (const [key, entry] of executionStatusCache) { if (now - entry.updatedAt > STATUS_CACHE_TTL) { executionStatusCache.delete(key); } } } function setExecutionStatus( requestId: string, status: string, message?: string, result?: ExecutionStatusEntry['result'], ): void { executionStatusCache.set(requestId, { status, message, updatedAt: Date.now(), result, }); // Periodic cleanup if (executionStatusCache.size > 100) { cleanupExpiredStatuses(); } } function getExecutionStatus(requestId: string): ExecutionStatusEntry | undefined { return executionStatusCache.get(requestId); } // SSE connections for status updates (per sessionId) const sseConnections = new Map(); /** * Start SSE subscription for a session to receive status updates */ async function subscribeToSessionStatus( sessionId: string, requestId: string, port: number, ): Promise { // Close existing connection for this session if any const existing = sseConnections.get(sessionId); if (existing) { existing.abort.abort(); sseConnections.delete(sessionId); } const abortController = new AbortController(); sseConnections.set(sessionId, { abort: abortController, lastRequestId: requestId }); // Set initial status setExecutionStatus(requestId, 'starting', 'Connecting to Agent...'); const sseUrl = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/stream`; try { const response = await fetch(sseUrl, { method: 'GET', headers: { Accept: 'text/event-stream' }, signal: abortController.signal, }); if (!response.ok || !response.body) { setExecutionStatus(requestId, 'running', 'Agent processing...'); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; setExecutionStatus(requestId, 'running', 'Agent processing...'); // Read SSE stream while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (line.startsWith('data:')) { try { const data = JSON.parse(line.slice(5).trim()); handleSseEvent(requestId, data); } catch { // Ignore parse errors } } } } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { // Intentionally aborted, not an error return; } // Connection error - mark as unknown but not failed (Agent may still be running) const cached = getExecutionStatus(requestId); if (cached && !['completed', 'failed', 'cancelled'].includes(cached.status)) { setExecutionStatus(requestId, 'running', 'Agent processing (connection lost)...'); } } finally { sseConnections.delete(sessionId); } } /** * Handle SSE event from Agent stream */ function handleSseEvent(requestId: string, event: unknown): void { if (!event || typeof event !== 'object') return; const e = event as Record; const type = e.type; const data = e.data as Record | undefined; // Check if this event is for our request const eventRequestId = data?.requestId as string | undefined; if (eventRequestId && eventRequestId !== requestId) return; if (type === 'status' && data) { const status = data.status as string; const message = data.message as string | undefined; // Map Agent status to our status // - 'ready' -> 'running' (ready is a running sub-state) // - 'error' -> 'failed' (normalize server 'error' to UI 'failed') let mappedStatus = status; if (status === 'ready') mappedStatus = 'running'; if (status === 'error') mappedStatus = 'failed'; setExecutionStatus(requestId, mappedStatus, message); } else if (type === 'message' && data) { // Update status to show we're receiving messages const cached = getExecutionStatus(requestId); if (cached && cached.status === 'starting') { setExecutionStatus(requestId, 'running', 'Agent is working...'); } // Check for completion indicators in message content const role = data.role as string | undefined; const isFinal = data.isFinal as boolean | undefined; if (role === 'assistant' && isFinal) { const content = data.content as string | undefined; setExecutionStatus(requestId, 'completed', 'Completed', { success: true, summary: content?.slice(0, 200), }); } } else if (type === 'error') { const errorMsg = (e.error as string) || 'Unknown error'; setExecutionStatus(requestId, 'failed', errorMsg, { success: false, error: errorMsg, }); } } /** * Web Editor version configuration * - v1: Legacy inject-scripts/web-editor.js (IIFE, ~850 lines) * - v2: New TypeScript-based web-editor-v2.js (WXT unlisted script) * * Set USE_WEB_EDITOR_V2 to true to enable v2. * This flag allows gradual rollout and easy rollback. */ const USE_WEB_EDITOR_V2 = true; /** Script path for v1 (legacy) */ const V1_SCRIPT_PATH = 'inject-scripts/web-editor.js'; /** Script path for v2 (WXT unlisted script output) */ const V2_SCRIPT_PATH = 'web-editor-v2.js'; /** Script path for Phase 7 props agent (MAIN world) */ const PROPS_AGENT_SCRIPT_PATH = 'inject-scripts/props-agent.js'; type WebEditorInstructionType = 'update_text' | 'update_style'; interface WebEditorFingerprint { tag: string; id?: string; classes: string[]; text?: string; } /** Debug source from React/Vue fiber (file, line, component name) */ interface DebugSource { file: string; line?: number; column?: number; componentName?: string; } /** Style operation details (before/after diff) */ interface StyleOperation { type: 'update_style'; before: Record; after: Record; removed: string[]; } interface WebEditorApplyPayload { pageUrl: string; targetFile?: string; fingerprint: WebEditorFingerprint; techStackHint?: string[]; instruction: { type: WebEditorInstructionType; description: string; text?: string; style?: Record; }; // V2 extended fields (best-effort, optional) selectorCandidates?: string[]; debugSource?: DebugSource; operation?: StyleOperation; } function normalizeString(value: unknown): string { return typeof value === 'string' ? value : ''; } function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((item) => normalizeString(item)).filter(Boolean); } function normalizeStyleMap(value: unknown): Record | undefined { if (!value || typeof value !== 'object') return undefined; const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { const key = normalizeString(k).trim(); const val = normalizeString(v).trim(); if (!key || !val) continue; out[key] = val; } return Object.keys(out).length ? out : undefined; } function normalizeStyleMapAllowEmpty(value: unknown): Record | undefined { if (!value || typeof value !== 'object') return undefined; const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { const key = normalizeString(k).trim(); if (!key) continue; // Allow empty values (represents removed styles) out[key] = normalizeString(v).trim(); } return Object.keys(out).length ? out : undefined; } function normalizeDebugSource(value: unknown): DebugSource | undefined { if (!value || typeof value !== 'object') return undefined; const obj = value as Record; const file = normalizeString(obj.file).trim(); if (!file) return undefined; const source: DebugSource = { file }; const line = Number(obj.line); if (Number.isFinite(line) && line > 0) source.line = line; const column = Number(obj.column); if (Number.isFinite(column) && column >= 0) source.column = column; const componentName = normalizeString(obj.componentName).trim(); if (componentName) source.componentName = componentName; return source; } function normalizeOperation(value: unknown): StyleOperation | undefined { if (!value || typeof value !== 'object') return undefined; const obj = value as Record; if (obj.type !== 'update_style') return undefined; const before = normalizeStyleMapAllowEmpty(obj.before); const after = normalizeStyleMapAllowEmpty(obj.after); const removed = normalizeStringArray(obj.removed); if (!before && !after && removed.length === 0) return undefined; return { type: 'update_style', before: before ?? {}, after: after ?? {}, removed, }; } function normalizeApplyPayload(raw: unknown): WebEditorApplyPayload { const obj = (raw && typeof raw === 'object' ? raw : {}) as Record; const pageUrl = normalizeString(obj.pageUrl).trim(); const targetFile = normalizeString(obj.targetFile).trim() || undefined; const techStackHint = normalizeStringArray(obj.techStackHint); const fingerprintRaw = ( obj.fingerprint && typeof obj.fingerprint === 'object' ? obj.fingerprint : {} ) as Record; const fingerprint: WebEditorFingerprint = { tag: normalizeString(fingerprintRaw.tag).trim() || 'unknown', id: normalizeString(fingerprintRaw.id).trim() || undefined, classes: normalizeStringArray(fingerprintRaw.classes), text: normalizeString(fingerprintRaw.text).trim() || undefined, }; const instructionRaw = ( obj.instruction && typeof obj.instruction === 'object' ? obj.instruction : {} ) as Record; const type = normalizeString(instructionRaw.type).trim() as WebEditorInstructionType; if (type !== 'update_text' && type !== 'update_style') { throw new Error('Invalid instruction.type'); } const instruction = { type, description: normalizeString(instructionRaw.description).trim() || '', text: normalizeString(instructionRaw.text).trim() || undefined, style: normalizeStyleMap(instructionRaw.style), }; if (!pageUrl) { throw new Error('pageUrl is required'); } if (!instruction.description) { throw new Error('instruction.description is required'); } // V2 extended fields (optional) const selectorCandidates = normalizeStringArray(obj.selectorCandidates); const debugSource = normalizeDebugSource(obj.debugSource); const operation = normalizeOperation(obj.operation); return { pageUrl, targetFile, fingerprint, techStackHint: techStackHint.length ? techStackHint : undefined, instruction, selectorCandidates: selectorCandidates.length ? selectorCandidates : undefined, debugSource, operation, }; } /** * Normalize and validate batch apply payload. * Runtime validation for WebEditorApplyBatchPayload. */ function normalizeApplyBatchPayload(raw: unknown): WebEditorApplyBatchPayload { const obj = (raw && typeof raw === 'object' ? raw : {}) as Record; const tabIdRaw = Number(obj.tabId); const tabId = Number.isFinite(tabIdRaw) && tabIdRaw > 0 ? tabIdRaw : 0; const elements = Array.isArray(obj.elements) ? (obj.elements as ElementChangeSummary[]) : []; const excludedKeys = Array.isArray(obj.excludedKeys) ? obj.excludedKeys.map((k) => normalizeString(k).trim()).filter((k): k is string => Boolean(k)) : []; const pageUrl = normalizeString(obj.pageUrl).trim() || undefined; return { tabId, elements, excludedKeys, pageUrl }; } /** * Build a batch prompt for multiple element changes. * Designed for AgentChat integration to apply multiple visual edits at once. */ function buildAgentPromptBatch(elements: readonly ElementChangeSummary[], pageUrl: string): string { const lines: string[] = []; // Header lines.push('You are a senior frontend engineer working in a local codebase.'); lines.push( 'Goal: persist a batch of visual edits from the browser into the source code with minimal changes.', ); lines.push(''); // Page context lines.push(`Page URL: ${pageUrl}`); lines.push(''); lines.push('## Batch Changes'); lines.push(`Total elements: ${elements.length}`); lines.push(''); lines.push( 'For each element, prefer "source" (file/line/component) when available; otherwise use selectors/fingerprint to locate it.', ); lines.push(''); // Element details elements.forEach((element, index) => { const title = element.fullLabel || element.label || element.elementKey; lines.push(`### ${index + 1}. ${title}`); lines.push(`- elementKey: ${element.elementKey}`); lines.push(`- change type: ${element.type}`); // Debug source (high-confidence location) const ds = element.debugSource ?? element.locator?.debugSource; if (ds?.file) { const loc = ds.line ? `${ds.file}:${ds.line}${ds.column ? `:${ds.column}` : ''}` : ds.file; lines.push(`- source: ${loc}${ds.componentName ? ` (${ds.componentName})` : ''}`); } // Locator hints for fallback if (element.locator?.selectors?.length) { lines.push('- selectors:'); for (const sel of element.locator.selectors.slice(0, 5)) { lines.push(` - ${sel}`); } } if (element.locator?.fingerprint) { lines.push(`- fingerprint: ${element.locator.fingerprint}`); } if (Array.isArray(element.locator?.path) && element.locator.path.length > 0) { lines.push(`- path: ${JSON.stringify(element.locator.path)}`); } if (element.locator?.shadowHostChain?.length) { lines.push(`- shadowHostChain: ${JSON.stringify(element.locator.shadowHostChain)}`); } lines.push(''); // Net effect details const net = element.netEffect; lines.push('#### Net Effect (apply these final values)'); if (net.textChange) { lines.push('##### Text'); lines.push(`- before: ${JSON.stringify(net.textChange.before)}`); lines.push(`- after: ${JSON.stringify(net.textChange.after)}`); lines.push(''); } if (net.classChanges) { lines.push('##### Classes'); lines.push(`- before: ${net.classChanges.before.join(' ')}`); lines.push(`- after: ${net.classChanges.after.join(' ')}`); lines.push(''); } if (net.styleChanges) { lines.push('##### Styles (before → after)'); const before = net.styleChanges.before ?? {}; const after = net.styleChanges.after ?? {}; const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); for (const key of Array.from(allKeys).sort()) { const beforeVal = before[key] ?? '(unset)'; const afterRaw = Object.prototype.hasOwnProperty.call(after, key) ? after[key] : '(unset)'; const afterVal = afterRaw === '' ? '(removed)' : afterRaw; if (beforeVal !== afterVal) { lines.push(`- ${key}: "${beforeVal}" → "${afterVal}"`); } } lines.push(''); } // Fallback message if no specific changes if (!net.textChange && !net.classChanges && !net.styleChanges) { lines.push( '- No net effect details available; use locator hints to inspect the element in code.', ); lines.push(''); } }); // Instructions lines.push('## How to Apply'); lines.push('1. Use "source" when available to go directly to the component file.'); lines.push('2. Otherwise, use selectors/fingerprint/path to locate the element in the codebase.'); lines.push('3. Apply the net effect with minimal changes and correct styling conventions.'); lines.push('4. Avoid generated/bundled outputs; update source files only.'); lines.push(''); // Output format lines.push('## Constraints'); lines.push('- Make the smallest safe edit possible for each element'); lines.push( '- If Tailwind/CSS Modules/styled-components are used, update the correct styling source', ); lines.push('- Do not change unrelated behavior or formatting'); lines.push(''); lines.push( '## Output\nApply all the changes in the repo, then reply with a short summary of what file(s) you modified and the exact changes made.', ); return lines.join('\n'); } function buildAgentPrompt(payload: WebEditorApplyPayload): string { const lines: string[] = []; // Header lines.push('You are a senior frontend engineer working in a local codebase.'); lines.push( 'Goal: persist a visual edit from the browser into the source code with minimal changes.', ); lines.push(''); // Page context lines.push(`Page URL: ${payload.pageUrl}`); lines.push(''); // == Source Location (high-confidence if debugSource available) == const ds = payload.debugSource; if (ds?.file) { lines.push('## Source Location (from React/Vue debug info)'); const loc = ds.line ? `${ds.file}:${ds.line}${ds.column ? `:${ds.column}` : ''}` : ds.file; lines.push(`- file: ${loc}`); if (ds.componentName) lines.push(`- component: ${ds.componentName}`); lines.push(''); lines.push('This is high-confidence source location extracted from framework debug info.'); lines.push('Start your search here. Only fall back to fingerprint if this file is invalid.'); lines.push(''); } else if (payload.targetFile) { lines.push(`## Target File (best-effort): ${payload.targetFile}`); lines.push( 'If this path is invalid or points to node_modules, fall back to fingerprint search.', ); lines.push(''); } // == Element Fingerprint == lines.push('## Element Fingerprint'); lines.push(`- tag: ${payload.fingerprint.tag}`); if (payload.fingerprint.id) lines.push(`- id: ${payload.fingerprint.id}`); if (payload.fingerprint.classes?.length) { lines.push(`- classes: ${payload.fingerprint.classes.join(' ')}`); } if (payload.fingerprint.text) lines.push(`- text: ${payload.fingerprint.text}`); lines.push(''); // == CSS Selectors (for precise matching) == if (payload.selectorCandidates?.length) { lines.push('## CSS Selectors (ordered by specificity)'); for (const sel of payload.selectorCandidates.slice(0, 5)) { lines.push(`- ${sel}`); } lines.push(''); lines.push('Use these selectors to grep the codebase if file location is unavailable.'); lines.push(''); } // == Tech Stack == if (payload.techStackHint?.length) { lines.push(`## Tech Stack: ${payload.techStackHint.join(', ')}`); lines.push(''); } // == Requested Change == lines.push('## Requested Change'); lines.push(`- type: ${payload.instruction.type}`); lines.push(`- description: ${payload.instruction.description}`); if (payload.instruction.type === 'update_text' && payload.instruction.text !== undefined) { lines.push(`- new text: ${JSON.stringify(payload.instruction.text)}`); } // For style updates, show detailed before/after diff if available if (payload.instruction.type === 'update_style') { const op = payload.operation; if (op && (Object.keys(op.before).length > 0 || Object.keys(op.after).length > 0)) { lines.push(''); lines.push('### Style Changes (before → after)'); const allKeys = new Set([...Object.keys(op.before), ...Object.keys(op.after)]); for (const key of allKeys) { const before = op.before[key] ?? '(unset)'; const after = op.after[key] ?? '(removed)'; if (before !== after) { lines.push(` ${key}: "${before}" → "${after}"`); } } if (op.removed.length > 0) { lines.push(` [Removed]: ${op.removed.join(', ')}`); } } else if (payload.instruction.style) { lines.push(`- style map: ${JSON.stringify(payload.instruction.style, null, 2)}`); } } lines.push(''); // == Instructions == lines.push('## How to Apply'); if (ds?.file) { lines.push(`1. Open ${ds.file}${ds.line ? ` around line ${ds.line}` : ''}`); if (ds.componentName) { lines.push(`2. Locate the "${ds.componentName}" component definition`); } lines.push( `3. Find the element matching tag="${payload.fingerprint.tag}"${payload.fingerprint.classes?.length ? ` with classes including "${payload.fingerprint.classes[0]}"` : ''}`, ); lines.push('4. Apply the requested style/text change'); } else if (payload.targetFile) { lines.push(`1. Open ${payload.targetFile}`); lines.push('2. Search for the element by matching fingerprint (tag, classes, text)'); lines.push('3. If not found, use repo-wide search with selectors or class names'); lines.push('4. Apply the requested change'); } else { lines.push('1. Use repo-wide search (rg) with class names or text from fingerprint'); if (payload.selectorCandidates?.length) { lines.push(`2. Try searching for: "${payload.selectorCandidates[0]}"`); } lines.push('3. Locate the component/template containing this element'); lines.push('4. Apply the requested change'); } lines.push(''); // == Constraints == lines.push('## Constraints'); lines.push('- Make the smallest safe edit possible'); if (payload.techStackHint?.includes('Tailwind')) { lines.push('- Tailwind detected: prefer updating className over inline styles'); } if (payload.techStackHint?.includes('React') || payload.techStackHint?.includes('Vue')) { lines.push('- Update the component source, not generated/bundled code'); } lines.push('- If CSS Modules or styled-components are used, update the correct styling source'); lines.push('- Do not change unrelated behavior or formatting'); lines.push(''); // == Output == lines.push( '## Output\nApply the change in the repo, then reply with a short summary of what file(s) you modified and the exact change made.', ); return lines.join('\n'); } async function ensureContextMenu(): Promise { try { if (!(chrome as any).contextMenus?.create) return; try { await chrome.contextMenus.remove(CONTEXT_MENU_ID); } catch {} await chrome.contextMenus.create({ id: CONTEXT_MENU_ID, title: '切换网页编辑模式', contexts: ['all'], }); } catch (error) { console.warn('[WebEditor] Failed to ensure context menu:', error); } } /** * Get the appropriate action constants based on version */ function getActions() { return USE_WEB_EDITOR_V2 ? WEB_EDITOR_V2_ACTIONS : WEB_EDITOR_V1_ACTIONS; } /** * Ensure the web editor script is injected into the tab * Supports both v1 (legacy) and v2 (new) versions * * V1 and V2 use different action names to avoid conflicts: * - V1: web_editor_ping, web_editor_toggle, etc. * - V2: web_editor_ping_v2, web_editor_toggle_v2, etc. */ async function ensureEditorInjected(tabId: number): Promise { const scriptPath = USE_WEB_EDITOR_V2 ? V2_SCRIPT_PATH : V1_SCRIPT_PATH; const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]'; const actions = getActions(); // Try to ping existing instance using version-specific action try { const pong: { status?: string; version?: number } = await chrome.tabs.sendMessage( tabId, { action: actions.PING }, { frameId: 0 }, ); if (pong?.status === 'pong') { // Already injected with correct version return; } } catch { // No existing instance, fallthrough to inject } // Inject the script try { await chrome.scripting.executeScript({ target: { tabId }, files: [scriptPath], world: 'ISOLATED', }); console.log(`${logPrefix} Script injected successfully`); } catch (error) { console.warn(`${logPrefix} Failed to inject editor script:`, error); } } /** * Inject props agent into MAIN world for Phase 7 Props editing * Only inject for v2 editor */ async function ensurePropsAgentInjected(tabId: number): Promise { if (!USE_WEB_EDITOR_V2) return; try { await chrome.scripting.executeScript({ target: { tabId }, files: [PROPS_AGENT_SCRIPT_PATH], world: 'MAIN', }); } catch (error) { // Best-effort: some pages (chrome://, extensions, PDF) block injection console.warn('[WebEditorV2] Failed to inject props agent:', error); } } /** * Send cleanup event to props agent */ async function sendPropsAgentCleanup(tabId: number): Promise { if (!USE_WEB_EDITOR_V2) return; try { // Dispatch cleanup event in ISOLATED world // CustomEvent crosses worlds and is observed by MAIN agent await chrome.scripting.executeScript({ target: { tabId }, func: () => { try { window.dispatchEvent(new CustomEvent('web-editor-props:cleanup')); } catch { // ignore } }, world: 'ISOLATED', }); } catch (error) { // Best-effort cleanup; ignore failures if tab is gone or injection blocked console.warn('[WebEditorV2] Failed to send props agent cleanup:', error); } } // ============================================================================= // Phase 7.1.6: Early Injection for Props Agent // ============================================================================= /** * Content script ID prefix for early injection (document_start). * Registered scripts persist across sessions and survive browser restarts. */ const PROPS_AGENT_EARLY_INJECTION_ID_PREFIX = 'mcp_we_props_early'; /** * Result of early injection registration */ interface EarlyInjectionResult { id: string; host: string; matches: string[]; alreadyRegistered: boolean; } /** * Sanitize a string for use in content script ID * Only allows alphanumeric, underscore, and hyphen */ function sanitizeContentScriptId(input: string): string { const cleaned = String(input ?? '') .toLowerCase() .replace(/[^a-z0-9_-]+/g, '_') .replace(/^_+|_+$/g, ''); return cleaned.slice(0, 80) || 'site'; } /** * Build match patterns from tab URL for early injection. * Returns patterns for the specific host only (not all URLs). */ function buildEarlyInjectionPatterns(tabUrl: string): { host: string; matches: string[] } { let url: URL; try { url = new URL(tabUrl); } catch { throw new Error('Invalid tab URL'); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error(`Early injection only supports http/https pages (got ${url.protocol})`); } const host = url.hostname.trim(); if (!host) { throw new Error('Unable to derive host from tab URL'); } // Match all paths on this host for both http and https return { host, matches: [`*://${host}/*`] }; } /** * Register props agent for early injection (document_start, MAIN world). * This allows capturing React DevTools hook before React initializes. * * The registration is per-host and persists across sessions. */ async function registerPropsAgentEarlyInjection(tabUrl: string): Promise { const { host, matches } = buildEarlyInjectionPatterns(tabUrl); const id = `${PROPS_AGENT_EARLY_INJECTION_ID_PREFIX}_${sanitizeContentScriptId(host)}`; // Check if already registered (idempotent) let alreadyRegistered = false; try { const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [id] }); alreadyRegistered = existing.some((s) => s.id === id); } catch { // API might not support getRegisteredContentScripts in all contexts alreadyRegistered = false; } if (!alreadyRegistered) { await chrome.scripting.registerContentScripts([ { id, js: [PROPS_AGENT_SCRIPT_PATH], matches, runAt: 'document_start', world: 'MAIN', allFrames: false, persistAcrossSessions: true, }, ]); console.log(`[WebEditorV2] Registered early injection for ${host}`); } return { id, host, matches, alreadyRegistered }; } async function toggleEditorInTab(tabId: number): Promise<{ active?: boolean }> { await ensureEditorInjected(tabId); const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]'; const actions = getActions(); try { const resp: { active?: boolean } = await chrome.tabs.sendMessage( tabId, { action: actions.TOGGLE }, { frameId: 0 }, ); const active = typeof resp?.active === 'boolean' ? resp.active : undefined; // Phase 7: Inject props agent on start; cleanup on stop if (active === true) { await ensurePropsAgentInjected(tabId); } else if (active === false) { await sendPropsAgentCleanup(tabId); } return { active }; } catch (error) { console.warn(`${logPrefix} Failed to toggle editor in tab:`, error); return {}; } } async function getActiveTabId(): Promise { try { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tabId = tabs?.[0]?.id; return typeof tabId === 'number' ? tabId : null; } catch { return null; } } export function initWebEditorListeners(): void { ensureContextMenu().catch(() => {}); // Clean up session storage when tab is closed to avoid stale data chrome.tabs.onRemoved.addListener((tabId) => { try { const keys = [ `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`, `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`, `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`, ]; chrome.storage.session.remove(keys).catch(() => {}); } catch {} }); if ((chrome as any).contextMenus?.onClicked?.addListener) { chrome.contextMenus.onClicked.addListener(async (info, tab) => { try { if (info.menuItemId !== CONTEXT_MENU_ID) return; const tabId = tab?.id; if (typeof tabId !== 'number') return; await toggleEditorInTab(tabId); } catch {} }); } chrome.commands.onCommand.addListener(async (command) => { try { if (command !== COMMAND_KEY) return; const tabId = await getActiveTabId(); if (typeof tabId !== 'number') return; await toggleEditorInTab(tabId); } catch {} }); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { try { // Phase 7.1.6: Handle early injection registration request if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION) { (async () => { const senderTab = (_sender as chrome.runtime.MessageSender)?.tab; const senderTabId = senderTab?.id; const senderTabUrl = senderTab?.url; if (typeof senderTabId !== 'number' || typeof senderTabUrl !== 'string') { return sendResponse({ success: false, error: 'Sender tab information is required', }); } try { const result = await registerPropsAgentEarlyInjection(senderTabUrl); // Respond first, then reload (to avoid message port closing during navigation) sendResponse({ success: true, ...result }); // Small delay to ensure response is sent before navigation await new Promise((resolve) => setTimeout(resolve, 50)); // Reload the tab so early injection takes effect try { await chrome.tabs.reload(senderTabId); } catch { // Best-effort: some tabs may block reload } } catch (err) { sendResponse({ success: false, error: err instanceof Error ? err.message : String(err), }); } })(); return true; // Async response } // ===================================================================== // WEB_EDITOR_OPEN_SOURCE: Open component source file in VSCode // ===================================================================== if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE) { (async () => { try { const payload = message.payload as { debugSource?: unknown } | undefined; const debugSource = payload?.debugSource; if (!debugSource || typeof debugSource !== 'object') { return sendResponse({ success: false, error: 'debugSource is required' }); } const rec = debugSource as Record; const file = typeof rec.file === 'string' ? rec.file.trim() : ''; if (!file) { return sendResponse({ success: false, error: 'debugSource.file is required' }); } // Read server port and selected project const stored = await chrome.storage.local.get([ 'nativeServerPort', 'agent-selected-project-id', ]); const portRaw = stored.nativeServerPort; const port = Number.isFinite(Number(portRaw)) ? Number(portRaw) : DEFAULT_NATIVE_SERVER_PORT; const projectId = stored['agent-selected-project-id']; if (!projectId || typeof projectId !== 'string') { return sendResponse({ success: false, error: 'No project selected. Please select a project in AgentChat first.', }); } // Prepare line/column const lineRaw = Number(rec.line); const columnRaw = Number(rec.column); const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined; const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined; // Call native-server to open file (server will validate project and path) const openResp = await fetch( `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open-file`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filePath: file, line, column, }), }, ); // Try to parse JSON response for detailed error let result: { success: boolean; error?: string }; try { result = await openResp.json(); } catch { const text = await openResp.text().catch(() => ''); result = { success: false, error: text || `HTTP ${openResp.status}`, }; } sendResponse(result); } catch (err) { sendResponse({ success: false, error: err instanceof Error ? err.message : String(err), }); } })(); return true; // Async response } if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE) { getActiveTabId() .then(async (tabId) => { if (typeof tabId !== 'number') return sendResponse({ success: false }); const result = await toggleEditorInTab(tabId); sendResponse({ success: true, ...result }); }) .catch(() => sendResponse({ success: false })); return true; } // ======================================================================= // Phase 1.5: Handle TX_CHANGED broadcast from web-editor // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) { (async () => { const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id; if (typeof senderTabId !== 'number') { sendResponse({ success: false, error: 'Sender tabId is required' }); return; } const rawPayload = message.payload as WebEditorTxChangedPayload | undefined; if (!rawPayload || typeof rawPayload !== 'object') { sendResponse({ success: false, error: 'Invalid payload' }); return; } // Hydrate payload with tabId from sender const payload: WebEditorTxChangedPayload = { ...rawPayload, tabId: senderTabId }; const storageKey = `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${senderTabId}`; // Persist to session storage for cold-start recovery // Remove keys on clear to avoid stale data (rollback still has edits, so keep it) if (payload.action === 'clear') { // Clear TX state and excluded keys together const excludedKey = `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${senderTabId}`; await chrome.storage.session.remove([storageKey, excludedKey]); } else { await chrome.storage.session.set({ [storageKey]: payload }); } // Broadcast to sidepanel (best-effort, ignore errors if sidepanel is closed) chrome.runtime .sendMessage({ type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED, payload, }) .catch(() => { // Ignore errors - sidepanel may be closed }); sendResponse({ success: true }); })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } // ======================================================================= // Selection sync: Handle SELECTION_CHANGED broadcast from web-editor // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) { (async () => { const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id; if (typeof senderTabId !== 'number') { sendResponse({ success: false, error: 'Sender tabId is required' }); return; } const rawPayload = message.payload as | import('@/common/web-editor-types').WebEditorSelectionChangedPayload | undefined; if (!rawPayload || typeof rawPayload !== 'object') { sendResponse({ success: false, error: 'Invalid payload' }); return; } // Hydrate payload with tabId from sender const payload = { ...rawPayload, tabId: senderTabId }; const storageKey = `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${senderTabId}`; // Persist to session storage for cold-start recovery // Remove key on deselection to avoid stale data if (payload.selected === null) { await chrome.storage.session.remove(storageKey); } else { await chrome.storage.session.set({ [storageKey]: payload }); } // Broadcast to sidepanel (best-effort, ignore errors if sidepanel is closed) chrome.runtime .sendMessage({ type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED, payload, }) .catch(() => { // Ignore errors - sidepanel may be closed }); sendResponse({ success: true }); })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } // ======================================================================= // Clear selection: Handle CLEAR_SELECTION from sidepanel (after send) // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CLEAR_SELECTION) { (async () => { const payload = message.payload as { tabId?: number } | undefined; const targetTabId = payload?.tabId; if (typeof targetTabId !== 'number' || targetTabId <= 0) { sendResponse({ success: false, error: 'Invalid tabId' }); return; } // Forward to content script (web-editor-v2) try { await chrome.tabs.sendMessage(targetTabId, { action: WEB_EDITOR_V2_ACTIONS.CLEAR_SELECTION, }); sendResponse({ success: true }); } catch (error) { // Tab may be closed or web-editor not active - this is expected sendResponse({ success: false, error: error instanceof Error ? error.message : 'Failed to send to tab', }); } })().catch((error) => { // Catch any unhandled errors in the async IIFE sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } // ======================================================================= // Phase 1.5: Handle APPLY_BATCH from web-editor toolbar // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY_BATCH) { const payload = normalizeApplyBatchPayload(message.payload); (async () => { const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id; const senderWindowId = (_sender as chrome.runtime.MessageSender)?.tab?.windowId; // Read storage for server port and selected session const stored = await chrome.storage.local.get([ 'nativeServerPort', STORAGE_KEY_SELECTED_SESSION, ]); const portRaw = stored?.nativeServerPort; const port = Number.isFinite(Number(portRaw)) ? Number(portRaw) : DEFAULT_NATIVE_SERVER_PORT; const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim(); // Best-effort: open AgentChat sidepanel so user can see the session // Pass sessionId for deep linking directly to chat view if (typeof senderTabId === 'number') { openAgentChatSidepanel(senderTabId, senderWindowId, sessionId || undefined).catch( () => {}, ); } if (!sessionId) { // No session selected - sidepanel is already being opened (best-effort) // User needs to select or create a session manually sendResponse({ success: false, error: 'No Agent session selected. Please select or create a session in AgentChat, then try Apply again.', }); return; } // Hydrate payload with tabId const hydratedPayload: WebEditorApplyBatchPayload = typeof senderTabId === 'number' ? { ...payload, tabId: senderTabId } : payload; // Read excluded keys from session storage (per-tab, managed by sidepanel) let sessionExcludedKeys: string[] = []; if (typeof senderTabId === 'number') { const excludedSessionKey = `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${senderTabId}`; try { if (chrome.storage?.session?.get) { const stored = (await chrome.storage.session.get(excludedSessionKey)) as Record< string, unknown >; const raw = stored?.[excludedSessionKey]; sessionExcludedKeys = Array.isArray(raw) ? raw.map((k) => normalizeString(k).trim()).filter(Boolean) : []; } } catch { // Best-effort: ignore session storage failures } } // Filter out excluded elements (union: payload excludedKeys + session excludedKeys) const excluded = new Set([...hydratedPayload.excludedKeys, ...sessionExcludedKeys]); const elements = hydratedPayload.elements.filter((e) => !excluded.has(e.elementKey)); if (elements.length === 0) { sendResponse({ success: false, error: 'No elements selected to apply.' }); return; } // Build page URL from payload or sender tab const pageUrl = normalizeString(hydratedPayload.pageUrl).trim() || normalizeString((_sender as chrome.runtime.MessageSender)?.tab?.url).trim() || 'unknown'; // Build batch prompt and send to agent const instruction = buildAgentPromptBatch(elements, pageUrl); const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`; // Extract element labels for compact display const elementLabels = elements.slice(0, 5).map((e) => e.label); const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instruction, // Pass dbSessionId so backend loads session-level configuration (engine, model, options) dbSessionId: sessionId, // Display text for UI (compact representation) displayText: `Apply ${elements.length} change${elements.length === 1 ? '' : 's'}`, // Client metadata for special message rendering clientMeta: { kind: 'web_editor_apply_batch', pageUrl, elementCount: elements.length, elementLabels, }, }), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); sendResponse({ success: false, error: text || `HTTP ${resp.status}`, }); return; } const json: any = await resp.json().catch(() => ({})); const requestId = json?.requestId as string | undefined; if (requestId) { // Start SSE subscription for status updates (fire and forget) subscribeToSessionStatus(sessionId, requestId, port).catch(() => {}); } sendResponse({ success: true, requestId, sessionId }); })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } // ======================================================================= // Phase 1.8: Handle HIGHLIGHT_ELEMENT from sidepanel chips hover // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_HIGHLIGHT_ELEMENT) { const payload = message.payload as WebEditorHighlightElementPayload | undefined; (async () => { // Validate payload const tabId = payload?.tabId; if (typeof tabId !== 'number' || !Number.isFinite(tabId) || tabId <= 0) { sendResponse({ success: false, error: 'Invalid tabId' }); return; } const mode = payload?.mode; if (mode !== 'hover' && mode !== 'clear') { sendResponse({ success: false, error: 'Invalid mode' }); return; } // Clear mode: forward directly without locator/selector validation // This prevents overlay residue when sidepanel unmounts if (mode === 'clear') { try { const response = await chrome.tabs.sendMessage(tabId, { action: WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT, mode: 'clear', }); sendResponse({ success: true, response }); } catch (error) { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); } return; } // Hover mode: validate and forward locator const locator = payload?.locator; if (!locator || typeof locator !== 'object') { sendResponse({ success: false, error: 'Invalid locator' }); return; } // Extract best selector for fallback highlighting const selectors = Array.isArray(locator.selectors) ? locator.selectors : []; const primarySelector = selectors.find( (s): s is string => typeof s === 'string' && s.trim().length > 0, ); if (!primarySelector) { sendResponse({ success: false, error: 'No valid selector in locator' }); return; } // Forward to web-editor content script try { const response = await chrome.tabs.sendMessage(tabId, { action: WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT, locator, // Full locator for Shadow DOM/iframe support selector: primarySelector, // Backward compatibility fallback mode, elementKey: payload.elementKey, }); sendResponse({ success: true, response }); } catch (error) { // Content script might not be available sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); } })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } // ======================================================================= // Phase 2: Handle REVERT_ELEMENT from sidepanel chips // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_REVERT_ELEMENT) { const payload = message.payload as WebEditorRevertElementPayload | undefined; (async () => { // Validate payload const tabId = payload?.tabId; if (typeof tabId !== 'number' || !Number.isFinite(tabId) || tabId <= 0) { sendResponse({ success: false, error: 'Invalid tabId' }); return; } const elementKey = payload?.elementKey; if (typeof elementKey !== 'string' || !elementKey.trim()) { sendResponse({ success: false, error: 'Invalid elementKey' }); return; } // Forward to web-editor content script (frameId: 0 for main frame only) try { const response = await chrome.tabs.sendMessage( tabId, { action: WEB_EDITOR_V2_ACTIONS.REVERT_ELEMENT, elementKey, }, { frameId: 0 }, ); sendResponse({ success: true, ...response }); } catch (error) { // Content script might not be available sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); } })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY) { const payload = normalizeApplyPayload(message.payload); (async () => { const senderTabId = (_sender as any)?.tab?.id; const sessionId = typeof senderTabId === 'number' ? `web-editor-${senderTabId}` : 'web-editor'; const stored = await chrome.storage.local.get([ 'nativeServerPort', 'agent-selected-project-id', ]); const portRaw = stored?.nativeServerPort; const port = Number.isFinite(Number(portRaw)) ? Number(portRaw) : DEFAULT_NATIVE_SERVER_PORT; const projectId = normalizeString(stored?.['agent-selected-project-id']).trim() || ''; if (!projectId) { return sendResponse({ success: false, error: 'No Agent project selected. Open Side Panel → 智能助手 and select/create a project first.', }); } const instruction = buildAgentPrompt(payload); const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instruction, projectId, }), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); return sendResponse({ success: false, error: text || `HTTP ${resp.status}`, }); } const json: any = await resp.json().catch(() => ({})); const requestId = json?.requestId as string | undefined; if (requestId) { // Start SSE subscription for status updates (fire and forget) subscribeToSessionStatus(sessionId, requestId, port).catch(() => {}); } return sendResponse({ success: true, requestId, sessionId }); })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); }); return true; } if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_STATUS_QUERY) { const { requestId } = message; if (!requestId || typeof requestId !== 'string') { sendResponse({ success: false, error: 'requestId is required' }); return false; } const entry = getExecutionStatus(requestId); if (!entry) { // No status yet - likely still pending or not tracked sendResponse({ success: true, status: 'pending', message: 'Waiting for status...' }); } else { sendResponse({ success: true, status: entry.status, message: entry.message, result: entry.result, }); } return false; // Synchronous response } // ======================================================================= // Cancel Execution: Handle WEB_EDITOR_CANCEL_EXECUTION from toolbar/sidepanel // ======================================================================= if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CANCEL_EXECUTION) { const payload = message.payload as WebEditorCancelExecutionPayload | undefined; (async () => { // Validate payload const sessionId = payload?.sessionId?.trim(); const requestId = payload?.requestId?.trim(); if (!sessionId) { sendResponse({ success: false, error: 'sessionId is required', } as WebEditorCancelExecutionResponse); return; } if (!requestId) { sendResponse({ success: false, error: 'requestId is required', } as WebEditorCancelExecutionResponse); return; } // Get server port const stored = await chrome.storage.local.get(['nativeServerPort']); const port = stored.nativeServerPort || DEFAULT_NATIVE_SERVER_PORT; try { // Call cancel API const cancelUrl = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`; const response = await fetch(cancelUrl, { method: 'DELETE' }); if (!response.ok) { const errorText = await response.text().catch(() => `HTTP ${response.status}`); sendResponse({ success: false, error: errorText, } as WebEditorCancelExecutionResponse); return; } // Update local execution status cache setExecutionStatus(requestId, 'cancelled', 'Execution cancelled by user'); // Abort SSE connection for this session const sseConnection = sseConnections.get(sessionId); if (sseConnection && sseConnection.lastRequestId === requestId) { sseConnection.abort.abort(); sseConnections.delete(sessionId); } sendResponse({ success: true } as WebEditorCancelExecutionResponse); } catch (error) { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), } as WebEditorCancelExecutionResponse); } })().catch((error) => { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), } as WebEditorCancelExecutionResponse); }); return true; // Will respond asynchronously } } catch (error) { sendResponse({ success: false, error: String(error instanceof Error ? error.message : error), }); } return false; }); }