feat: web editor

This commit is contained in:
hangerye
2025-12-18 09:24:54 +08:00
parent d52cb5c062
commit 4649638f4e
8 changed files with 1620 additions and 0 deletions
@@ -51,6 +51,9 @@ export const BACKGROUND_MESSAGE_TYPES = {
ELEMENT_MARKER_UPDATE: 'element_marker_update',
ELEMENT_MARKER_DELETE: 'element_marker_delete',
ELEMENT_MARKER_VALIDATE: 'element_marker_validate',
// Web editor (in-page visual editing)
WEB_EDITOR_TOGGLE: 'web_editor_toggle',
WEB_EDITOR_APPLY: 'web_editor_apply',
} as const;
// Offscreen message types
@@ -7,6 +7,7 @@ import { initStorageManagerListener } from './storage-manager';
import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
import { initRecordReplayListeners } from './record-replay';
import { initElementMarkerListeners } from './element-marker';
import { initWebEditorListeners } from './web-editor';
/**
* Background script entry point
@@ -21,6 +22,8 @@ export default defineBackground(() => {
initRecordReplayListeners();
// Element marker: context menu + CRUD listeners
initElementMarkerListeners();
// Web editor: toggle edit-mode overlay
initWebEditorListeners();
// Conditionally initialize semantic similarity engine if model cache exists
initializeSemanticEngineIfCached()
@@ -0,0 +1,333 @@
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
const CONTEXT_MENU_ID = 'web_editor_toggle';
const COMMAND_KEY = 'toggle_web_editor';
const DEFAULT_NATIVE_SERVER_PORT = 12306;
type WebEditorInstructionType = 'update_text' | 'update_style';
interface WebEditorFingerprint {
tag: string;
id?: string;
classes: string[];
text?: string;
}
interface WebEditorApplyPayload {
pageUrl: string;
targetFile?: string;
fingerprint: WebEditorFingerprint;
techStackHint?: string[];
instruction: {
type: WebEditorInstructionType;
description: string;
text?: string;
style?: Record<string, string>;
};
}
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<string, string> | undefined {
if (!value || typeof value !== 'object') return undefined;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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 normalizeApplyPayload(raw: unknown): WebEditorApplyPayload {
const obj = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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');
}
return {
pageUrl,
targetFile,
fingerprint,
techStackHint: techStackHint.length ? techStackHint : undefined,
instruction,
};
}
function buildAgentPrompt(payload: WebEditorApplyPayload): string {
const lines: string[] = [];
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('');
lines.push(`Page URL: ${payload.pageUrl}`);
lines.push('');
lines.push('Selected element fingerprint (best-effort):');
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('');
if (payload.targetFile) {
lines.push(`Target file (best-effort): ${payload.targetFile}`);
lines.push(
'If this path is invalid or points to node_modules, ignore it and locate by fingerprint instead.',
);
lines.push('');
}
if (payload.techStackHint?.length) {
lines.push(`Tech hints: ${payload.techStackHint.join(', ')}`);
lines.push('');
}
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) {
lines.push(`- new text: ${JSON.stringify(payload.instruction.text)}`);
}
if (payload.instruction.type === 'update_style' && payload.instruction.style) {
lines.push(`- style map: ${JSON.stringify(payload.instruction.style, null, 2)}`);
}
lines.push('');
lines.push('How to locate the code to change:');
if (payload.targetFile) {
lines.push(
`1) Open ${payload.targetFile} and locate the element by matching classes/text/fingerprint.`,
);
lines.push(
'2) If not found, fall back to repo-wide search using the fingerprint text/classes.',
);
} else {
lines.push(
'1) Use repo-wide search (rg) with the fingerprint text/classes to find the component.',
);
}
lines.push('');
lines.push('Constraints:');
lines.push('- Prefer the smallest safe edit.');
lines.push('- If Tailwind is used, prefer updating className instead of adding inline styles.');
lines.push('- If CSS Modules / styled-components are used, update the correct styling source.');
lines.push('- Do not change unrelated behavior.');
lines.push('');
lines.push(
'Output: apply the change in the repo, then reply with a short summary of what you changed.',
);
return lines.join('\n');
}
async function ensureContextMenu(): Promise<void> {
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);
}
}
async function ensureEditorInjected(tabId: number): Promise<void> {
try {
const pong: any = await chrome.tabs.sendMessage(
tabId,
{ action: 'web_editor_ping' } as any,
{ frameId: 0 } as any,
);
if (pong?.status === 'pong') return;
} catch {
// Fallthrough to executeScript
}
try {
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/web-editor.js'],
world: 'ISOLATED',
} as any);
} catch (error) {
console.warn('[WebEditor] Failed to inject editor script:', error);
}
}
async function toggleEditorInTab(tabId: number): Promise<{ active?: boolean }> {
await ensureEditorInjected(tabId);
try {
const resp: any = await chrome.tabs.sendMessage(
tabId,
{ action: 'web_editor_toggle' } as any,
{ frameId: 0 } as any,
);
return { active: typeof resp?.active === 'boolean' ? resp.active : undefined };
} catch (error) {
console.warn('[WebEditor] Failed to toggle editor in tab:', error);
return {};
}
}
async function getActiveTabId(): Promise<number | null> {
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(() => {});
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 {
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;
}
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',
'agent-project-root-override',
]);
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() || '';
const projectRootOverride =
normalizeString(stored?.['agent-project-root-override']).trim() || '';
if (!projectId && !projectRootOverride) {
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: projectId || undefined,
projectRoot: projectRootOverride || undefined,
}),
});
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(() => ({}));
return sendResponse({ success: true, requestId: json?.requestId });
})().catch((error) => {
sendResponse({
success: false,
error: String(error instanceof Error ? error.message : error),
});
});
return true;
}
} catch (error) {
sendResponse({
success: false,
error: String(error instanceof Error ? error.message : error),
});
}
return false;
});
}
@@ -247,6 +247,21 @@
</div>
</div>
<div class="section">
<h2 class="section-title">网页编辑器</h2>
<div class="rr-grid">
<div class="rr-controls" style="width: 100%">
<button
class="semantic-engine-button"
@click="toggleWebEditor"
title="在当前页面切换编辑模式(可用快捷键 Ctrl/Command + Shift + E)"
>
切换编辑模式
</button>
</div>
</div>
</div>
<!-- Model Cache Management Section -->
<ModelCacheManagement
:cache-stats="cacheStats"
@@ -584,6 +599,14 @@ async function openElementMarkerSidepanel() {
}
}
async function toggleWebEditor() {
try {
await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE });
} catch (error) {
console.warn('切换网页编辑模式失败:', error);
}
}
function openBuilderWindow(flowId?: string, focusNodeId?: string) {
const url = new URL(chrome.runtime.getURL('builder.html'));
if (flowId) url.searchParams.set('flowId', flowId);
@@ -0,0 +1,848 @@
/* eslint-disable */
(() => {
const GLOBAL_KEY = '__MCP_WEB_EDITOR__';
if (window[GLOBAL_KEY]) return;
const IS_MAIN = window === window.top;
const COLORS = {
hover: '#3b82f6', // blue-500
selected: '#22c55e', // green-500
backdrop: 'rgba(15, 23, 42, 0.15)', // slate-900 @ 15%
};
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
const normalizeTextSnippet = (value, maxLen) => {
return String(value || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLen || 80);
};
const containsPoint = (rect, x, y) => {
if (!rect) return false;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};
const getElementLabel = (el) => {
if (!(el instanceof Element)) return '';
const tag = String(el.tagName || '').toLowerCase();
const id = el.id ? `#${el.id}` : '';
const classes =
el.classList && el.classList.length
? `.${Array.from(el.classList).slice(0, 3).join('.')}`
: '';
return `${tag}${id}${classes}`;
};
const detectTailwind = (classes) => {
try {
const patterns = [
/^bg-/,
/^text-/,
/^p[trblxy]?-/,
/^m[trblxy]?-/,
/^flex$/,
/^grid$/,
/^items-/,
/^justify-/,
/^gap-/,
/^rounded/,
/^shadow/,
/^border/,
];
for (const cls of classes || []) {
if (patterns.some((p) => p.test(cls))) return true;
}
} catch {}
return false;
};
const findReactFileFromFiber = (fiber) => {
try {
let current = fiber;
for (let i = 0; i < 40 && current; i++) {
const src = current._debugSource;
if (src && src.fileName && typeof src.fileName === 'string') return src.fileName;
const owner = current._debugOwner;
const ownerSrc = owner && owner._debugSource;
if (ownerSrc && ownerSrc.fileName && typeof ownerSrc.fileName === 'string')
return ownerSrc.fileName;
current = current.return;
}
} catch {}
return '';
};
const findReactSourceFile = (el) => {
try {
let node = el;
for (let depth = 0; depth < 15 && node; depth++) {
const keys = Object.keys(node);
for (const k of keys) {
if (k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) {
const fiber = node[k];
const found = findReactFileFromFiber(fiber);
if (found) return found;
}
}
node = node.parentElement;
}
} catch {}
return '';
};
const findVueSourceFile = (el) => {
try {
let node = el;
for (let depth = 0; depth < 15 && node; depth++) {
const inst = node.__vueParentComponent;
if (inst && inst.type && inst.type.__file) return String(inst.type.__file);
node = node.parentElement;
}
} catch {}
return '';
};
const resolveTargetFile = (el) => {
try {
let node = el;
for (let depth = 0; depth < 20 && node; depth++) {
const reactFile = findReactSourceFile(node);
if (reactFile && !reactFile.includes('node_modules')) return reactFile;
const vueFile = findVueSourceFile(node);
if (vueFile && !vueFile.includes('node_modules')) return vueFile;
node = node.parentElement;
}
} catch {}
return '';
};
const findMeaningfulElement = (el, clientX, clientY) => {
try {
let current = el instanceof Element ? el : null;
for (let i = 0; i < 8 && current; i++) {
const tag = String(current.tagName || '').toUpperCase();
if (tag === 'HTML' || tag === 'BODY') {
const deeper = document.elementFromPoint(clientX, clientY);
if (deeper && deeper !== current && deeper instanceof Element) {
current = deeper;
continue;
}
return current;
}
let style;
try {
style = window.getComputedStyle(current);
} catch {
return current;
}
const bg = String(style.backgroundColor || '').toLowerCase();
const isTransparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)';
const borderWidth = [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
]
.map((x) => String(x || '0px'))
.join(',');
const hasBorder = borderWidth !== '0px,0px,0px,0px';
if (!isTransparentBg || hasBorder) return current;
const rect = current.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return current;
let bestChild = null;
let bestArea = Infinity;
const children = Array.from(current.children || []);
for (const child of children) {
if (!(child instanceof Element)) continue;
const r = child.getBoundingClientRect();
if (!r || r.width <= 0 || r.height <= 0) continue;
if (!containsPoint(r, clientX, clientY)) continue;
const area = r.width * r.height;
if (area < bestArea) {
bestArea = area;
bestChild = child;
}
}
if (!bestChild) return current;
const childRect = bestChild.getBoundingClientRect();
const sameSize =
Math.abs(rect.width - childRect.width) < 2 &&
Math.abs(rect.height - childRect.height) < 2;
if (!sameSize) return current;
current = bestChild;
}
} catch {}
return el instanceof Element ? el : null;
};
const createToastHost = () => {
const host = document.createElement('div');
Object.assign(host.style, {
position: 'fixed',
left: '12px',
bottom: '12px',
zIndex: 2147483647,
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'none',
});
return host;
};
const showToast = (state, message, kind) => {
try {
if (!state.toastHost) return;
const item = document.createElement('div');
const bg =
kind === 'error'
? 'rgba(220, 38, 38, 0.92)'
: kind === 'success'
? 'rgba(22, 163, 74, 0.92)'
: 'rgba(15, 23, 42, 0.92)';
Object.assign(item.style, {
background: bg,
color: '#fff',
padding: '8px 10px',
borderRadius: '10px',
fontSize: '12px',
fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
boxShadow: '0 6px 18px rgba(0,0,0,0.22)',
maxWidth: '340px',
lineHeight: '1.35',
});
item.textContent = String(message || '');
state.toastHost.appendChild(item);
setTimeout(() => {
try {
item.remove();
} catch {}
}, 2800);
} catch {}
};
const buildStyleMapFromInput = (raw) => {
const out = {};
const text = String(raw || '').trim();
if (!text) return out;
const parts = text
.split(';')
.map((s) => s.trim())
.filter(Boolean);
for (const part of parts) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (!key || !value) continue;
out[key] = value;
}
return out;
};
const applyInlineStyleMap = (el, styles) => {
try {
if (!(el instanceof Element)) return;
const entries = Object.entries(styles || {});
for (const [key, value] of entries) {
if (!key || !value) continue;
try {
el.style.setProperty(key, value);
} catch {}
}
} catch {}
};
const state = {
active: false,
root: null,
canvas: null,
ctx: null,
raf: 0,
dpr: 1,
viewport: { w: 0, h: 0 },
hoveredEl: null,
selectedEl: null,
hoverRect: null,
selectedRect: null,
toolbar: null,
toastHost: null,
inputText: '',
inputStyle: '',
lastPointer: { x: 0, y: 0 },
};
const ensureCanvas = () => {
if (!state.canvas || !state.ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1);
const h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1);
if (state.viewport.w === w && state.viewport.h === h && Math.abs(state.dpr - dpr) < 0.01)
return;
state.dpr = dpr;
state.viewport = { w, h };
state.canvas.width = Math.round(w * dpr);
state.canvas.height = Math.round(h * dpr);
state.canvas.style.width = `${w}px`;
state.canvas.style.height = `${h}px`;
state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const drawRect = (rect, color, dashed) => {
if (!rect || !state.ctx) return;
const ctx = state.ctx;
const x = Math.round(rect.left) + 0.5;
const y = Math.round(rect.top) + 0.5;
const w = Math.max(0, Math.round(rect.width));
const h = Math.max(0, Math.round(rect.height));
if (w <= 0 || h <= 0) return;
ctx.save();
ctx.lineWidth = 2;
ctx.strokeStyle = color;
ctx.fillStyle = `${color}22`;
if (dashed) ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.fill();
ctx.stroke();
ctx.restore();
};
const draw = () => {
if (!state.active || !state.ctx) return;
ensureCanvas();
const ctx = state.ctx;
ctx.clearRect(0, 0, state.viewport.w, state.viewport.h);
// Keep selected rect fresh in case HMR/layout changes.
try {
if (state.selectedEl && state.selectedEl instanceof Element) {
state.selectedRect = state.selectedEl.getBoundingClientRect();
}
} catch {}
try {
if (state.hoveredEl && state.hoveredEl instanceof Element) {
state.hoverRect = state.hoveredEl.getBoundingClientRect();
}
} catch {}
drawRect(state.hoverRect, COLORS.hover, true);
drawRect(state.selectedRect, COLORS.selected, false);
// Keep toolbar anchored to the selected element.
positionToolbar();
};
const tick = () => {
if (!state.active) return;
draw();
state.raf = requestAnimationFrame(tick);
};
const isInToolbar = (target) => {
try {
if (!target || !(target instanceof Node)) return false;
if (!state.toolbar) return false;
return state.toolbar.contains(target);
} catch {
return false;
}
};
const updateHover = (el, clientX, clientY) => {
const picked = findMeaningfulElement(el, clientX, clientY);
state.hoveredEl = picked;
try {
state.hoverRect = picked ? picked.getBoundingClientRect() : null;
} catch {
state.hoverRect = null;
}
};
const positionToolbar = () => {
try {
if (!state.toolbar || !state.selectedRect) return;
const pad = 10;
const maxW = 420;
const rect = state.selectedRect;
const preferredLeft = clamp(
Math.round(rect.left),
pad,
Math.max(pad, window.innerWidth - maxW - pad),
);
const preferredTop = Math.round(rect.top - 12);
const top = preferredTop < 80 ? Math.round(rect.bottom + 12) : preferredTop;
Object.assign(state.toolbar.style, {
left: `${preferredLeft}px`,
top: `${clamp(top, pad, Math.max(pad, window.innerHeight - 180))}px`,
});
} catch {}
};
const updateToolbarHeader = () => {
try {
if (!state.toolbar) return;
const label = state.toolbar.querySelector('[data-role="label"]');
if (!label) return;
label.textContent = state.selectedEl ? getElementLabel(state.selectedEl) : 'No selection';
} catch {}
};
const buildApplyPayload = (instruction) => {
const el = state.selectedEl;
const tag = el && el.tagName ? String(el.tagName || '').toLowerCase() : 'unknown';
const id = el && el.id ? String(el.id) : undefined;
const classes = el && el.classList ? Array.from(el.classList).slice(0, 24) : [];
const text = normalizeTextSnippet(el ? el.textContent : '', 96);
const fingerprint = { tag, id, classes, text };
const targetFile = el ? resolveTargetFile(el) : '';
const hints = [];
try {
if (el) {
const r = findReactSourceFile(el);
const v = findVueSourceFile(el);
if (r) hints.push('React');
if (v) hints.push('Vue');
}
if (detectTailwind(classes)) hints.push('Tailwind');
} catch {}
return {
pageUrl: String(location && location.href ? location.href : ''),
targetFile: targetFile || undefined,
fingerprint,
techStackHint: hints.length ? hints : undefined,
instruction,
};
};
const onMouseMove = (e) => {
if (!state.active) return;
if (isInToolbar(e.target)) return;
state.lastPointer = { x: e.clientX, y: e.clientY };
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
updateHover(el, e.clientX, e.clientY);
};
const onClick = (e) => {
if (!state.active) return;
if (isInToolbar(e.target)) return;
try {
e.preventDefault();
e.stopPropagation();
} catch {}
const el = state.hoveredEl;
if (!el) return;
state.selectedEl = el;
try {
state.selectedRect = el.getBoundingClientRect();
} catch {
state.selectedRect = null;
}
updateToolbarHeader();
positionToolbar();
};
const intercept = (e) => {
if (!state.active) return;
if (isInToolbar(e.target)) return;
// Allow scroll/wheel to keep navigation usable in edit mode.
if (e.type === 'wheel') return;
try {
e.preventDefault();
e.stopPropagation();
} catch {}
};
const onKeyDown = (e) => {
if (!state.active) return;
if (isInToolbar(e.target)) return;
if (e.key === 'Escape') {
try {
e.preventDefault();
e.stopPropagation();
} catch {}
stop();
return;
}
};
const buildToolbar = () => {
const box = document.createElement('div');
state.toolbar = box;
Object.assign(box.style, {
position: 'fixed',
left: '12px',
top: '12px',
zIndex: 2147483647,
pointerEvents: 'auto',
width: 'min(420px, calc(100vw - 24px))',
background: 'rgba(255,255,255,0.96)',
border: '1px solid rgba(148, 163, 184, 0.6)',
borderRadius: '12px',
boxShadow: '0 10px 30px rgba(0,0,0,0.18)',
fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
color: '#0f172a',
overflow: 'hidden',
});
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
padding: '10px 12px',
background: 'rgba(248,250,252,0.9)',
borderBottom: '1px solid rgba(148, 163, 184, 0.35)',
});
const label = document.createElement('div');
label.setAttribute('data-role', 'label');
Object.assign(label.style, {
fontSize: '12px',
fontWeight: '600',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '280px',
});
label.textContent = 'Select an element';
const btnExit = document.createElement('button');
btnExit.textContent = 'Exit (Esc)';
Object.assign(btnExit.style, {
fontSize: '12px',
padding: '6px 10px',
borderRadius: '10px',
border: '1px solid rgba(148,163,184,0.6)',
background: '#fff',
cursor: 'pointer',
});
btnExit.addEventListener('click', () => stop());
header.appendChild(label);
header.appendChild(btnExit);
const body = document.createElement('div');
Object.assign(body.style, {
padding: '10px 12px 12px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
});
const mkRow = (titleText) => {
const row = document.createElement('div');
Object.assign(row.style, {
display: 'flex',
flexDirection: 'column',
gap: '6px',
});
const title = document.createElement('div');
title.textContent = titleText;
Object.assign(title.style, { fontSize: '12px', fontWeight: '600', color: '#334155' });
row.appendChild(title);
return { row, title };
};
const mkActions = () => {
const actions = document.createElement('div');
Object.assign(actions.style, {
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
});
return actions;
};
const mkButton = (text, variant) => {
const btn = document.createElement('button');
btn.textContent = text;
const bg = variant === 'primary' ? '#0f172a' : '#fff';
const color = variant === 'primary' ? '#fff' : '#0f172a';
Object.assign(btn.style, {
fontSize: '12px',
padding: '7px 10px',
borderRadius: '10px',
border: '1px solid rgba(148,163,184,0.6)',
background: bg,
color,
cursor: 'pointer',
});
return btn;
};
// Text edit
const textRow = mkRow('Text');
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.placeholder = 'New text…';
Object.assign(textInput.style, {
width: '100%',
padding: '8px 10px',
borderRadius: '10px',
border: '1px solid rgba(148,163,184,0.6)',
fontSize: '12px',
outline: 'none',
});
textInput.addEventListener('input', () => {
state.inputText = textInput.value;
});
const textActions = mkActions();
const btnApplyText = mkButton('Apply (DOM)', 'secondary');
btnApplyText.addEventListener('click', () => {
if (!state.selectedEl) return showToast(state, 'No selection', 'error');
const v = String(state.inputText || '').trim();
if (!v) return showToast(state, 'Text is empty', 'error');
try {
state.selectedEl.textContent = v;
showToast(state, 'Text applied (DOM)', 'success');
} catch {
showToast(state, 'Failed to apply text', 'error');
}
});
const btnSyncText = mkButton('Sync to Code', 'primary');
btnSyncText.addEventListener('click', async () => {
if (!state.selectedEl) return showToast(state, 'No selection', 'error');
const v = String(state.inputText || '').trim();
if (!v) return showToast(state, 'Text is empty', 'error');
const payload = buildApplyPayload({
type: 'update_text',
description: `Set the element text to: ${JSON.stringify(v)}`,
text: v,
});
try {
const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload });
if (resp && resp.success) {
showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success');
} else {
showToast(state, resp?.error || 'Agent request failed', 'error');
}
} catch (err) {
showToast(state, String(err && err.message ? err.message : err), 'error');
}
});
textActions.appendChild(btnApplyText);
textActions.appendChild(btnSyncText);
textRow.row.appendChild(textInput);
textRow.row.appendChild(textActions);
// Style edit
const styleRow = mkRow('Style (CSS declarations)');
const styleInput = document.createElement('input');
styleInput.type = 'text';
styleInput.placeholder = 'e.g. background-color: #f3f4f6; padding: 12px';
Object.assign(styleInput.style, {
width: '100%',
padding: '8px 10px',
borderRadius: '10px',
border: '1px solid rgba(148,163,184,0.6)',
fontSize: '12px',
outline: 'none',
});
styleInput.addEventListener('input', () => {
state.inputStyle = styleInput.value;
});
const styleActions = mkActions();
const btnApplyStyle = mkButton('Apply (DOM)', 'secondary');
btnApplyStyle.addEventListener('click', () => {
if (!state.selectedEl) return showToast(state, 'No selection', 'error');
const map = buildStyleMapFromInput(state.inputStyle);
const keys = Object.keys(map);
if (!keys.length) return showToast(state, 'No valid declarations', 'error');
applyInlineStyleMap(state.selectedEl, map);
showToast(state, 'Style applied (DOM)', 'success');
});
const btnSyncStyle = mkButton('Sync to Code', 'primary');
btnSyncStyle.addEventListener('click', async () => {
if (!state.selectedEl) return showToast(state, 'No selection', 'error');
const map = buildStyleMapFromInput(state.inputStyle);
const keys = Object.keys(map);
if (!keys.length) return showToast(state, 'No valid declarations', 'error');
const decl = keys.map((k) => `${k}: ${map[k]}`).join('; ');
const payload = buildApplyPayload({
type: 'update_style',
description: `Apply CSS declarations: ${decl}`,
style: map,
});
try {
const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload });
if (resp && resp.success) {
showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success');
} else {
showToast(state, resp?.error || 'Agent request failed', 'error');
}
} catch (err) {
showToast(state, String(err && err.message ? err.message : err), 'error');
}
});
styleActions.appendChild(btnApplyStyle);
styleActions.appendChild(btnSyncStyle);
styleRow.row.appendChild(styleInput);
styleRow.row.appendChild(styleActions);
body.appendChild(textRow.row);
body.appendChild(styleRow.row);
box.appendChild(header);
box.appendChild(body);
return box;
};
const start = () => {
if (!IS_MAIN) return;
if (state.active) return;
state.active = true;
const root = document.createElement('div');
state.root = root;
root.id = '__mcp_web_editor_root';
Object.assign(root.style, {
position: 'fixed',
inset: '0',
zIndex: 2147483647,
pointerEvents: 'none',
});
const canvas = document.createElement('canvas');
state.canvas = canvas;
Object.assign(canvas.style, {
position: 'fixed',
inset: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
});
root.appendChild(canvas);
try {
const ctx = canvas.getContext('2d');
state.ctx = ctx;
} catch {
state.ctx = null;
}
const toolbar = buildToolbar();
root.appendChild(toolbar);
const toastHost = createToastHost();
state.toastHost = toastHost;
root.appendChild(toastHost);
document.documentElement.appendChild(root);
document.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
document.addEventListener('click', onClick, true);
document.addEventListener('mousedown', intercept, true);
document.addEventListener('mouseup', intercept, true);
document.addEventListener('dblclick', intercept, true);
document.addEventListener('contextmenu', intercept, true);
document.addEventListener('submit', intercept, true);
document.addEventListener('keydown', onKeyDown, true);
// Visual cue
showToast(state, 'Web Editor: ON (Esc to exit)', 'info');
// Start RAF
state.raf = requestAnimationFrame(tick);
};
const stop = () => {
if (!IS_MAIN) return;
if (!state.active) return;
state.active = false;
try {
if (state.raf) cancelAnimationFrame(state.raf);
} catch {}
state.raf = 0;
try {
document.removeEventListener('mousemove', onMouseMove, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('mousedown', intercept, true);
document.removeEventListener('mouseup', intercept, true);
document.removeEventListener('dblclick', intercept, true);
document.removeEventListener('contextmenu', intercept, true);
document.removeEventListener('submit', intercept, true);
document.removeEventListener('keydown', onKeyDown, true);
} catch {}
try {
state.root && state.root.remove();
} catch {}
state.root = null;
state.canvas = null;
state.ctx = null;
state.hoveredEl = null;
state.selectedEl = null;
state.hoverRect = null;
state.selectedRect = null;
state.toolbar = null;
state.toastHost = null;
state.inputText = '';
state.inputStyle = '';
};
const toggle = () => {
if (!IS_MAIN) return false;
if (state.active) {
stop();
return false;
}
start();
return true;
};
// Expose minimal API for debugging
window[GLOBAL_KEY] = {
start,
stop,
toggle,
getState: () => ({ active: state.active }),
};
// Message handler (background -> tab)
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (!IS_MAIN) return false;
if (request && request.action === 'web_editor_ping') {
sendResponse({ status: 'pong' });
return false;
}
if (request && request.action === 'web_editor_toggle') {
const active = toggle();
sendResponse({ active });
return true;
}
if (request && request.action === 'web_editor_start') {
start();
sendResponse({ active: true });
return true;
}
if (request && request.action === 'web_editor_stop') {
stop();
sendResponse({ active: false });
return true;
}
} catch (e) {
try {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
} catch {}
return true;
}
return false;
});
})();
+4
View File
@@ -88,6 +88,10 @@ export default defineConfig({
suggested_key: { default: 'Ctrl+Shift+O' },
description: 'Open workflow sidepanel',
},
toggle_web_editor: {
suggested_key: { default: 'Ctrl+Shift+E', mac: 'Command+Shift+E' },
description: 'Toggle Web Editor mode',
},
},
web_accessible_resources: [
{
+179
View File
@@ -0,0 +1,179 @@
# Web Visual Editor (Chrome Extension) — Implementation Plan
## 0) Goal
Build a **visual in-page editor** for local dev pages (e.g. `localhost`) that:
- Enters/exits **Edit Mode** via **keyboard shortcut**, **right-click context menu**, or a **Popup button**.
- Lets the user **hover + click to select** an element with a high-performance overlay.
- Allows **quick visual edits** (v1: text + inline styles) with optimistic DOM updates.
- Sends a structured “intent payload” to the existing **AgentChat** backend so the AI Agent can **persist changes into source code**.
Non-goals (v1):
- Full Figma/Webflow-grade layout editing, multi-select, constraints, snapping, history/undo.
- Cross-origin iframe editing (same-origin can be added later).
- Perfect framework source mapping in production builds (dev-mode metadata first).
## 1) Context Scan (Evidence-Based)
### 1.1 Similar patterns we will reuse
1. **Record/Replay triggers** (hotkeys + context menus)
- `app/chrome-extension/wxt.config.ts` (manifest `commands`)
- `app/chrome-extension/entrypoints/background/record-replay/index.ts` (`chrome.commands` + `chrome.contextMenus`)
2. **Injected overlay + selection**
- `app/chrome-extension/inject-scripts/accessibility-tree-helper.js` (`rr_picker_start` overlay + capture listeners)
3. **Background-driven injection + context menu**
- `app/chrome-extension/entrypoints/background/element-marker/index.ts` (idempotent inject + overlay control)
### 1.2 Integration points (data-path)
- **Popup (Vue)** → `chrome.runtime.sendMessage` → **Background**
- **Background** → `chrome.scripting.executeScript` → **Injected Script** (ISOLATED world)
- **Injected Script** → `chrome.runtime.sendMessage` → **Background**
- **Background** → HTTP `POST /agent/chat/:sessionId/act` → **Native Server** → **AgentChatService** → **Engine** (Codex/Claude/…)
### 1.3 Tech stack + conventions
- Extension: Vue 3 + WXT + TS, injected scripts are plain JS under `app/chrome-extension/inject-scripts/`.
- Backend: Fastify (native server), AgentChat already exists.
- Formatting: Prettier + ESLint.
- Tests: Jest exists in `app/native-server` (extension has no automated tests today).
## 2) Key Questions (Prioritized)
High:
- What is the **minimum set of editing operations** to ship v1 end-to-end (visual edit → AI persists)?
- What is the **source localization contract** we can reliably provide (React/Vue dev metadata vs fallback fingerprint)?
- How do we ensure **Edit Mode does not trigger page actions** (navigation/form submit) while keeping scrolling usable?
Medium:
- Should we open the Side Panel (AgentChat) automatically after “Sync to Code”?
- How should we store per-tab editor state (ephemeral per page load vs persisted)?
Low:
- Cross-frame (same-origin iframe) support.
- Advanced overlay rendering (guides, spacing, multi-rect).
## 3) Target Contract (v1)
### 3.1 Background messages
- `BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE`
- Called from Popup / commands / context menu
- Effect: inject editor script if needed, then toggle edit mode on the active tab
- `BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY`
- Called from injected script when the user clicks “Sync to Code”
- Effect: build an AgentChat prompt and call `/agent/chat/:sessionId/act`
### 3.2 Injected script messages (tab → injected)
- `action: "web_editor_ping"` → `{ status: "pong" }`
- `action: "web_editor_toggle"` → `{ active: boolean }`
- (optional) `action: "web_editor_start" | "web_editor_stop"`
### 3.3 Apply payload schema (injected → background)
```ts
type WebEditorInstructionType = 'update_text' | 'update_style';
interface WebEditorFingerprint {
tag: string;
id?: string;
classes: string[];
text?: string; // short snippet
}
interface WebEditorApplyPayload {
pageUrl: string;
targetFile?: string; // best-effort (React/Vue dev metadata)
fingerprint: WebEditorFingerprint;
techStackHint?: string[];
instruction: {
type: WebEditorInstructionType;
description: string;
text?: string;
style?: Record<string, string>;
};
}
```
### 3.4 Prompt template (background → AgentChat)
The prompt MUST be deterministic, explicit, and tool-friendly:
- If `targetFile` is available: instruct to edit that file (and related CSS modules if needed).
- Else: instruct to `rg` search using `fingerprint.text` and/or stable classes.
- Prefer Tailwind class edits if Tailwind is detected; otherwise update CSS module / inline styles.
- Apply the requested change and keep other behavior unchanged.
## 4) Milestones & Tasks (Checklist)
### M1 — Edit Mode activation (toggle)
- [x] Add manifest command: `toggle_web_editor` (suggested key: `Ctrl+Shift+E`)
- [x] Add background module `web-editor` with:
- [x] context menu item “Toggle Web Editor”
- [x] `chrome.commands` listener for `toggle_web_editor`
- [x] runtime message handler for Popup toggle
- [x] idempotent injection + `web_editor_toggle` tab message
- [x] Add Popup section with a “Toggle Web Editor” button
### M2 — In-page overlay + selection (visual layer)
- [x] Add `inject-scripts/web-editor.js`:
- [x] High-perf overlay using a fixed `<canvas>` (DPR-aware) + RAF loop
- [x] Hover highlight + click-to-select
- [x] Smart selection heuristics (skip transparent wrappers when appropriate)
- [x] Capture-phase event interception (prevent page actions while editing)
- [x] Exit via `Esc`
### M3 — Basic visual editing (v1)
- [x] Floating toolbar for selected element
- [x] Text edit (optimistic `textContent` update)
- [x] Inline style edit (CSS declarations → `style.setProperty`)
### M4 — Source localization (best-effort)
- [x] React dev metadata detection (Fiber `_debugSource.fileName`)
- [x] Vue 3 dev metadata detection (`instance.type.__file`)
- [x] `node_modules` escape: ignore and climb DOM parents
- [x] Fallback fingerprint capture (tag/id/classes/text snippet)
### M5 — Agent bridge (persist to code)
- [x] Read Agent project selection from `chrome.storage.local`:
- `agent-selected-project-id`
- `agent-project-root-override`
- [x] Background calls `POST /agent/chat/:sessionId/act` with `{ instruction, projectId, projectRoot }`
- [x] Injected UI shows success/error toast with returned `requestId`
### M6 — Verification
- [ ] `pnpm -r lint` (or scoped package lint) (blocked by pre-existing errors)
- [ ] `pnpm --filter mcp-chrome-bridge test` (native server regression) (currently failing in repo)
- [ ] Manual smoke:
- Toggle Edit Mode via popup / context menu / hotkey
- Select element, edit text/style, “Sync to Code”
- Confirm AgentChat receives request (sidepanel) and code changes trigger HMR
## 5) Risks & Mitigations (v1)
- **React/Vue metadata missing in prod** → fallback fingerprint + `rg` search prompt.
- **Overlay intercepts too much** → allow scroll/wheel; allow toolbar interactions.
- **CORS / server unavailable** → do HTTP from background + show actionable error.
## 6) Progress Log
2025-12-17: Implemented M1–M5 end-to-end. Repo-wide lint/typecheck/tests are currently failing due to unrelated pre-existing issues; manual smoke test is the recommended verification step for this feature.
+227
View File
@@ -0,0 +1,227 @@
# 技术白皮书:AI 驱动的 Web 前端可视化工作台
**Project Codename**: Localhost AR Workbench
**版本**: V3.0 (Final Architecture)
**核心理念**:
1. **AR 架构**: 视觉层与逻辑层解耦,确保极致性能。
2. **文件粒度定位**: 利用 LLM 的语义理解能力,降低定位的工程复杂度。
3. **Lazy AI**: 本地算法优先,云端 AI 兜底。
---
## 1. 原始需求与设计意图 (Intent & Context)
### 1.1 原始问题 (The Problem)
- **割裂的开发体验**: 开发者在 Chrome DevTools 调样式,然后去 IDE 找文件、改代码,来回切换,上下文丢失。
- **Cursor 的局限**: Cursor 虽然强大,但它是基于文本(Code)的,无法直观地调整视觉属性(如 Margin, Flex 布局,阴影),也无法在运行时看到真实数据渲染的效果。
- **现有插件的痛点**: 类似 VisBug 的工具只能临时修改 DOM,刷新即逝,无法持久化到源码;且在 React/Vue 复杂页面中性能极差,交互手感像“玩具”。
### 1.2 产品目标 (The Solution)
构建一个**浏览器插件**,作为覆盖层(Overlay)运行在本地开发环境(Localhost)上。
- **视觉编辑**: 用户可以直接拖拽组件、修改样式、编辑文案。
- **代码落地**: 所有操作通过 AI Agent 自动逆向修改本地源代码,并触发 HMR(热更新)。
- **体验对标**: 达到 Figma/Webflow 级别的流畅度(60FPS)和智能度。
---
## 2. 核心架构设计 (System Architecture)
系统采用 **“三层分离”** 架构,解决性能与逻辑的冲突。
### 2.1 Layer 1: 宿主层 (The Host)
- **内容**: 用户原本运行的网页 (React/Vue/HTML)。
- **状态**: 被“冻结”。在编辑模式下,宿主页面的原生事件被拦截,DOM 仅作为渲染结果的提供者。
### 2.2 Layer 2: 视觉层 (The Visual Overlay)
- **技术栈**: **Canvas (Pixi.js / Konva)**。
- **决策原因**:
- **性能**: 在万级节点的页面上(如淘宝),用 DOM 做高亮框会触发 Layout Thrashing (布局抖动),导致卡顿。Canvas 绘制开销与 DOM 数量无关,稳定 60FPS。
- **隔离**: 视觉元素(标尺、蓝框、残影)不会污染宿主页面的 CSS 继承关系。
- **能力**: 方便绘制非矩形元素、距离标注红线等设计软件特有的 UI。
- **职责**: 只负责“画画”,不处理逻辑。
### 2.3 Layer 3: 逻辑与控制层 (The Brain)
- **技术栈**: Native JavaScript (Injected Script)。
- **职责**:
- **定位**: 算出鼠标点到了哪个文件。
- **交互**: 计算拖拽插入点、去噪算法。
- **通信**: 将意图打包发给本地 Server。
---
## 3. 关键技术实现细节 (Deep Dive)
### 3.1 定位策略:文件粒度 (File-Level Localization)
**决策变更**: 从“精确行号”降级为“文件粒度”。
**决策原因**:
1. 获取精确行号(SourceMap)成本高、不稳定(Vue SFC 编译后行号偏移)。
2. LLM 具备极强的语义搜索能力,只要给它文件内容和 DOM 上下文,它能自己找到代码位置。
**实现算法**:
插件遍历选中元素及其父级,寻找框架元数据:
- **React 场景**:
- 检查 DOM 属性 `__reactFiber$xxxx`。
- 遍历 Fiber 树:`fiber._debugSource.fileName`。
- **Vue 3 场景**:
- 检查 DOM 属性 `__vueParentComponent`。
- 获取 `instance.type.__file`。
- **Node Modules 处理**:
- **逻辑**: 如果路径包含 `node_modules`,视为无效路径,继续向上遍历父节点。
- **目的**: 用户点击 Antd Button,我们希望定位到调用 Button 的 `App.tsx`,而不是 Antd 的源码包。
- **兜底策略 (纯 HTML/老项目)**:
- 如果找不到路径,提取 **DOM 指纹**(TagName + ID + Class + 关键文本)。
- Payload: `{ type: "fingerprint_search", snippet: "..." }`。让后端用 `ripgrep` 全局搜。
### 3.2 智能选择与去噪 (Smart Selection Heuristics)
**问题**: 网页中充斥着用于布局的 Wrapper Div(无背景、无边框),用户很难选中真正的“卡片”。
**技术实现**:
1. **穿透规则 (Passthrough Rule)**:
- 当 `mousemove` 命中元素 `el` 时,检查:
```javascript
const style = getComputedStyle(el);
const isTransparent =
style.backgroundColor === 'rgba(0, 0, 0, 0)' && style.borderWidth === '0px';
const isSameSize = Math.abs(elRect.width - childRect.width) < 2;
```
- 如果满足条件,**忽略当前元素**,递归检查其子元素,直到找到“实体”。
2. **组件识别**:
- 利用 DOM 结构哈希,识别页面中重复的结构。
- 交互:选中一个 Item,Canvas 用虚线框出其他所有同类 Item。
### 3.3 事件接管与隔离 (Event Interception)
**问题**: 编辑模式下,点击“提交”按钮不应触发 Form 提交。
**技术实现**:
1. **捕获阶段拦截 (Capture Phase)**:
```javascript
window.addEventListener(
'click',
(e) => {
if (!isEditingMode) return;
e.stopPropagation(); // 阻止冒泡
e.preventDefault(); // 阻止默认行为
// 执行选中逻辑
},
true,
); // true = 开启捕获
```
2. **Shadow DOM 穿透**:
- 原生 `elementFromPoint` 遇到 Shadow Root 会停止。
- **解决方案**: 使用 `event.composedPath()` 获取完整的冒泡路径,取路径中第一个非 ShadowRoot 的节点。
### 3.4 React/Vue 重渲染与一致性 (Optimistic UI)
**问题**: 用户拖拽修改了 DOM,React 状态更新触发重渲染,DOM 被重置,导致“闪烁”。
**技术实现**:
1. **样式修改 (Style)**:
- 直接修改 DOM `style` 属性。React 重渲染虽会覆盖,但 HMR 通常紧随其后,闪烁可接受。
2. **结构修改 (Layout/Drag) - 幽灵模式**:
- **严禁**直接操作真实 DOM (如 `insertBefore`),这会导致 React 崩溃。
- **策略**:
- 拖拽时:Canvas 绘制“幽灵节点”跟随。
- 松手时:**保持 Canvas 上的幽灵节点显示**,真实 DOM 不动。
- 生效时:AI 修改源码 -> HMR 触发 -> 真实 DOM 变动 -> 移除 Canvas 幽灵。
### 3.5 坐标系统 (The Coordinate System)
**问题**: 页面 Zoom、High DPI 屏幕、Transform 导致 Canvas 对不齐。
**技术实现**:
- 统一坐标公式:
```javascript
CanvasX = (ClientX + Window.scrollX) * window.devicePixelRatio;
CanvasY = (ClientY + Window.scrollY) * window.devicePixelRatio;
```
- **Iframe 处理**:
- 同源:脚本注入 Iframe 内部。
- 跨域:UI 提示不可编辑。
---
## 4. 后端 Agent 执行逻辑 (The Executor)
**核心变更**: 不再依赖复杂的 AST 分析脚本,完全依赖 LLM。
### 4.1 数据协议 (The Payload)
前端发送给本地 Server 的 JSON 包:
```json
{
"projectRoot": "/Users/dev/my-app",
"targetFile": "src/components/UserProfile.tsx", // 优先使用文件路径
"fallbackFingerprint": {
// 如果没有文件路径,用这个搜
"tag": "div",
"classes": ["card", "p-4"],
"text": "User Settings"
},
"instruction": {
"type": "update_style",
"description": "Change background color to soft gray (#f3f4f6)"
},
"techStackHint": ["Tailwind", "React"] // 前端探测到的技术栈
}
```
### 4.2 Agent 工作流 (The AI Workflow)
1. **定位**:
- 有 `targetFile` -> 直接读取文件内容。
- 无 `targetFile` -> 使用 `ripgrep` 搜索 `fallbackFingerprint.text` 或 `classes` 找到文件。
2. **Prompt 构建**:
> "你是一个前端专家。我将给你一个 React 组件文件。
> **任务**: 找到包含文本 'User Settings' 且类名为 'card' 的 `div`。
> **修改**: 将其背景色改为软灰色。
> **约束**: 检测文件是否使用 Tailwind。如果是,添加 `bg-gray-100`;如果是 CSS Modules,修改对应 .css 文件;如果是内联样式,修改 style 属性。
> **输出**: 仅输出修改后的文件代码。"
3. **写入**: 覆盖原文件 -> 触发 Webpack/Vite HMR。
---
## 5. 开发路线图 (Implementation Roadmap)
### Phase 1: 视觉与交互原型 (The "Lens")
- **目标**: 实现不卡顿的高亮与选中。
- **任务**:
1. 搭建 Shadow DOM + Canvas 基础架构。
2. 实现坐标转换与 `requestAnimationFrame` 渲染循环。
3. 实现“去噪算法”,确保鼠标能精准吸附到卡片而非容器。
4. 实现事件拦截,确保点击页面不跳转。
### Phase 2: 定位与通信 (The "Bridge")
- **目标**: 打通 浏览器 -> 本地文件系统 的链路。
- **任务**:
1. 编写 `getFileSource` 算法 (React Fiber / Vue Instance 探测)。
2. 搭建本地 Node.js Server + WebSocket。
3. 实现“Node Modules 跳出”逻辑。
### Phase 3: AI 闭环 (The "Brain")
- **目标**: 实现样式与内容的真实修改。
- **任务**:
1. 接入 LLM API (OpenAI/Claude)。
2. 设计并调优 Prompt,专门处理 Tailwind、CSS-in-JS 等不同场景。
3. 实现 HMR 后的 Canvas 状态同步 (避免 HMR 时高亮框错位)。
---
## 6. 总结 (Conclusion)
本项目通过 **“文件粒度定位”** 大幅降低了工程落地的复杂度,通过 **“Canvas 渲染层”** 解决了前端插件常见的性能瓶颈,通过 **“Lazy AI”** 策略平衡了成本与智能。这套架构在理论上完全可行,且能够提供远超现有开源工具的商业级体验。