mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-24 23:24:04 +08:00
feat: refact recorder
This commit is contained in:
@@ -107,6 +107,8 @@ export const TOOL_MESSAGE_TYPES = {
|
||||
// Record & Replay content script bridge
|
||||
RR_RECORDER_CONTROL: 'rr_recorder_control',
|
||||
RR_RECORDER_EVENT: 'rr_recorder_event',
|
||||
// Record & Replay timeline feed (background -> content overlay)
|
||||
RR_TIMELINE_UPDATE: 'rr_timeline_update',
|
||||
// DOM observer trigger bridge
|
||||
SET_DOM_TRIGGERS: 'set_dom_triggers',
|
||||
DOM_TRIGGER_FIRED: 'dom_trigger_fired',
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { appendSteps, addNavigationStep } from './flow-builder';
|
||||
import { addNavigationStep } from './flow-builder';
|
||||
import { STEP_TYPES } from '@/common/step-types';
|
||||
import { ensureRecorderInjected, broadcastControlToTab, REC_CMD } from './content-injection';
|
||||
import type { RecordingSessionManager } from './session-manager';
|
||||
@@ -23,7 +23,7 @@ export function initBrowserEventListeners(session: RecordingSessionManager): voi
|
||||
type: STEP_TYPES.SWITCH_TAB,
|
||||
...(url ? { urlContains: url } : {}),
|
||||
};
|
||||
appendSteps(flow, [step]);
|
||||
session.appendSteps([step]);
|
||||
} catch (e) {
|
||||
console.warn('onActivated handler failed', e);
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,5 +1,6 @@
|
||||
import type { Flow, Step } from '../types';
|
||||
import { STEP_TYPES } from '@/common/step-types';
|
||||
import { recordingSession } from './session-manager';
|
||||
|
||||
const WORKFLOW_VERSION = 1;
|
||||
|
||||
@@ -47,6 +48,14 @@ export function appendSteps(flow: Flow, steps: Step[]): void {
|
||||
}
|
||||
|
||||
export function addNavigationStep(flow: Flow, url: string): void {
|
||||
const step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url };
|
||||
const step: Step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url } as Step;
|
||||
try {
|
||||
// Prefer centralized session append (single broadcast path) when active and matching flow
|
||||
const sessFlow = recordingSession.getFlow?.();
|
||||
if (recordingSession.getStatus?.() === 'recording' && sessFlow === flow) {
|
||||
recordingSession.appendSteps([step]);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
appendSteps(flow, [step]);
|
||||
}
|
||||
|
||||
+5
-3
@@ -54,12 +54,14 @@ class RecorderManagerImpl {
|
||||
async stop(): Promise<{ success: boolean; error?: string; flow?: Flow }> {
|
||||
if (session.getStatus() === 'idle' || !session.getFlow())
|
||||
return { success: false, error: 'No active recording' };
|
||||
// Best-effort STOP for all tabs that participated in this session
|
||||
// Capture tabs first, then synchronously mark session as stopped
|
||||
const tabs = session.getActiveTabs();
|
||||
const flow = await session.stopSession();
|
||||
// After state becomes idle, no further START should be triggered by listeners
|
||||
// Best-effort STOP broadcast to remove overlays in all participating tabs
|
||||
try {
|
||||
const tabs = session.getActiveTabs();
|
||||
await Promise.all(tabs.map(async (id) => broadcastControlToTab(id, REC_CMD.STOP)));
|
||||
} catch {}
|
||||
const flow = await session.stopSession();
|
||||
if (flow) await saveFlow(flow);
|
||||
return flow ? { success: true, flow } : { success: true };
|
||||
}
|
||||
|
||||
+18
-5
@@ -1,4 +1,6 @@
|
||||
import type { Flow, Step } from '../types';
|
||||
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import { appendSteps as appendFlowSteps } from './flow-builder';
|
||||
|
||||
export type RecordingStatus = 'idle' | 'recording' | 'paused';
|
||||
|
||||
@@ -81,12 +83,23 @@ export class RecordingSessionManager {
|
||||
appendSteps(steps: Step[]): void {
|
||||
const f = this.state.flow;
|
||||
if (!f || !Array.isArray(steps) || steps.length === 0) return;
|
||||
for (const st of steps) {
|
||||
// id should be ensured by builder; push directly
|
||||
f.steps.push(st);
|
||||
}
|
||||
// Reuse builder implementation (ids + meta update) for consistency
|
||||
appendFlowSteps(f, steps);
|
||||
this.broadcastTimelineUpdate(steps);
|
||||
}
|
||||
|
||||
// Broadcast timeline update to origin tab top-frame
|
||||
broadcastTimelineUpdate(steps: Step[]): void {
|
||||
try {
|
||||
(f.meta as any).updatedAt = new Date().toISOString();
|
||||
if (!steps || steps.length === 0) return;
|
||||
const origin = this.state.originTabId;
|
||||
if (origin != null) {
|
||||
chrome.tabs.sendMessage(
|
||||
origin,
|
||||
{ action: TOOL_MESSAGE_TYPES.RR_TIMELINE_UPDATE, steps },
|
||||
{ frameId: 0 },
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
BATCH_SEND_MS: 80,
|
||||
SCROLL_DEBOUNCE_MS: 350,
|
||||
SENSITIVE_INPUT_TYPES: new Set(['password']),
|
||||
UI_MAX_STEPS: 30,
|
||||
};
|
||||
|
||||
// Memoization caches for selector computations during recording
|
||||
@@ -151,6 +152,11 @@
|
||||
constructor(recorder) {
|
||||
this.recorder = recorder;
|
||||
this._box = null;
|
||||
// Timeline elements state
|
||||
this._timeline = null;
|
||||
this._count = 0;
|
||||
this._timelineBox = null;
|
||||
this._collapsed = false;
|
||||
}
|
||||
ensure() {
|
||||
const rec = this.recorder;
|
||||
@@ -175,14 +181,52 @@
|
||||
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
|
||||
<input id="__rr_enable_highlight" type="checkbox" style="vertical-align:middle;" />高亮
|
||||
</label>
|
||||
<button id="__rr_toggle_timeline" style="background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.5); border-radius:6px; padding:2px 6px; cursor:pointer; font-size:12px;">折叠</button>
|
||||
<button id="__rr_pause" style="background:#fff; color:#111; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">暂停</button>
|
||||
<button id="__rr_stop" style="background:#111; color:#fff; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">停止</button>
|
||||
</div>`;
|
||||
document.documentElement.appendChild(root);
|
||||
// Build timeline container just below the panel
|
||||
try {
|
||||
const timeline = document.createElement('div');
|
||||
timeline.id = '__rr_rec_timeline';
|
||||
Object.assign(timeline.style, {
|
||||
marginTop: '8px',
|
||||
width: '360px',
|
||||
maxHeight: '220px',
|
||||
overflow: 'auto',
|
||||
background: 'rgba(17,24,39,0.85)',
|
||||
color: '#F9FAFB',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
});
|
||||
const header = document.createElement('div');
|
||||
header.textContent = '已录制步骤';
|
||||
header.style.opacity = '0.8';
|
||||
header.style.marginBottom = '4px';
|
||||
const list = document.createElement('ol');
|
||||
list.id = '__rr_rec_timeline_list';
|
||||
list.style.listStyle = 'none';
|
||||
list.style.margin = '0';
|
||||
list.style.padding = '0';
|
||||
list.style.display = 'flex';
|
||||
list.style.flexDirection = 'column';
|
||||
list.style.gap = '4px';
|
||||
timeline.appendChild(header);
|
||||
timeline.appendChild(list);
|
||||
root.appendChild(timeline);
|
||||
this._timeline = list;
|
||||
this._timelineBox = timeline;
|
||||
} catch {}
|
||||
const btnPause = root.querySelector('#__rr_pause');
|
||||
const btnStop = root.querySelector('#__rr_stop');
|
||||
const hideChk = root.querySelector('#__rr_hide_values');
|
||||
const highlightChk = root.querySelector('#__rr_enable_highlight');
|
||||
const btnToggle = root.querySelector('#__rr_toggle_timeline');
|
||||
hideChk.checked = !!rec.hideInputValues;
|
||||
hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked));
|
||||
highlightChk.checked = !!rec.highlightEnabled;
|
||||
@@ -190,11 +234,22 @@
|
||||
rec.highlightEnabled = !!highlightChk.checked;
|
||||
rec._updateHoverListener();
|
||||
});
|
||||
if (btnToggle) {
|
||||
btnToggle.addEventListener('click', () => {
|
||||
this._collapsed = !this._collapsed;
|
||||
if (this._timelineBox)
|
||||
this._timelineBox.style.display = this._collapsed ? 'none' : 'block';
|
||||
btnToggle.textContent = this._collapsed ? '展开' : '折叠';
|
||||
});
|
||||
}
|
||||
btnPause.addEventListener('click', () => {
|
||||
if (!rec.isPaused) rec.pause();
|
||||
else rec.resume();
|
||||
});
|
||||
btnStop.addEventListener('click', () => {
|
||||
try {
|
||||
chrome.runtime.sendMessage({ type: 'rr_stop_recording' });
|
||||
} catch {}
|
||||
rec.stop();
|
||||
});
|
||||
this._box = document.createElement('div');
|
||||
@@ -216,6 +271,8 @@
|
||||
const root = document.getElementById('__rr_rec_overlay');
|
||||
if (root) root.remove();
|
||||
if (this._box) this._box.remove();
|
||||
this._timeline = null;
|
||||
this._timelineBox = null;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -225,6 +282,77 @@
|
||||
if (badge) badge.textContent = this.recorder.isPaused ? '已暂停' : '录制中';
|
||||
if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? '继续' : '暂停';
|
||||
}
|
||||
|
||||
// Reset the timeline list content
|
||||
resetTimeline() {
|
||||
this._count = 0;
|
||||
const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
|
||||
if (list) list.innerHTML = '';
|
||||
}
|
||||
|
||||
// Append a new recorded step into the timeline UI
|
||||
appendStep(step) {
|
||||
const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
|
||||
if (!list) return;
|
||||
this._count += 1;
|
||||
const item = document.createElement('li');
|
||||
const text = this._formatStepText(step, this._count);
|
||||
item.setAttribute('data-step-id', step.id || '');
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'flex-start';
|
||||
item.style.gap = '6px';
|
||||
item.innerHTML = `
|
||||
<span style="min-width:20px; text-align:right; opacity:0.8;">${this._count}.</span>
|
||||
<span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;">${text}</span>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
while (list.children.length > CONFIG.UI_MAX_STEPS) {
|
||||
list.removeChild(list.firstChild);
|
||||
}
|
||||
const container = list.parentElement;
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// Update the last timeline entry when a step is coalesced/modified
|
||||
updateLastStep(step) {
|
||||
const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
|
||||
if (!list) return;
|
||||
const last = list.lastElementChild;
|
||||
if (!last) return;
|
||||
const text = this._formatStepText(step, this._count);
|
||||
const spans = last.querySelectorAll('span');
|
||||
if (spans && spans[1]) spans[1].textContent = text;
|
||||
}
|
||||
|
||||
// Create a short, human-readable text for a recorded step
|
||||
_formatStepText(step, _idx) {
|
||||
try {
|
||||
if (!step || typeof step !== 'object') return '未知步骤';
|
||||
const t = step.type;
|
||||
const sel = step.target && step.target.selector ? step.target.selector : '';
|
||||
if (t === 'click' || t === 'dblclick') {
|
||||
return `${t === 'dblclick' ? '双击' : '点击'}: ${sel || '(document)'}`;
|
||||
}
|
||||
if (t === 'fill') {
|
||||
const val = step.value;
|
||||
const shown = typeof val === 'string' && val.length > 0 ? val : String(val);
|
||||
return `输入: ${sel} = ${shown}`;
|
||||
}
|
||||
if (t === 'scroll') {
|
||||
const mode = step.mode === 'container' ? '容器' : '页面';
|
||||
const off = step.offset || {};
|
||||
return `滚动(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`;
|
||||
}
|
||||
if (t === 'openTab') return `打开标签页: ${step.url || ''}`;
|
||||
if (t === 'switchTab') return `切换标签页: 包含 ${step.urlContains || ''}`;
|
||||
if (t === 'switchFrame')
|
||||
return `切换Frame: 包含 ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`;
|
||||
if (t === 'waitFor') return `等待: ${sel || step.until || ''}`;
|
||||
return `${t}`;
|
||||
} catch (_) {
|
||||
return '步骤';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
@@ -266,6 +394,7 @@
|
||||
this.isPaused = false;
|
||||
this._attach();
|
||||
this.ui.ensure();
|
||||
this.ui.resetTimeline();
|
||||
this._send({ kind: 'start', flow: this.pendingFlow });
|
||||
}
|
||||
|
||||
@@ -363,7 +492,8 @@
|
||||
try {
|
||||
if (window !== window.top && !this.frameSwitchPushed) {
|
||||
const href = String(location && location.href ? location.href : '');
|
||||
this.pendingFlow.steps.push({ type: 'switchFrame', frame: { urlContains: href } });
|
||||
const frameStep = { type: 'switchFrame', frame: { urlContains: href } };
|
||||
this.pendingFlow.steps.push(frameStep);
|
||||
this.frameSwitchPushed = true;
|
||||
}
|
||||
} catch {}
|
||||
@@ -661,6 +791,17 @@
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
try {
|
||||
if (!request || !request.action) return false;
|
||||
if (request.action === 'rr_timeline_update') {
|
||||
const rec = getRecorder();
|
||||
rec.ui.ensure();
|
||||
if (rec.isRecording && rec.pendingFlow && (!rec.ui._timeline || rec.ui._count === 0)) {
|
||||
// no-op; rely purely on updates per design
|
||||
}
|
||||
const steps = Array.isArray(request.steps) ? request.steps : [];
|
||||
for (const st of steps) rec.ui.appendStep(st);
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
if (request.action === 'rr_recorder_control') {
|
||||
const rec = getRecorder();
|
||||
const cmd = request.cmd;
|
||||
|
||||
Reference in New Issue
Block a user