feat: add builder editor

This commit is contained in:
hangerye
2025-10-10 20:38:43 +08:00
parent ff07256ada
commit ce7a5e5bb9
26 changed files with 4043 additions and 113 deletions
+1
View File
@@ -108,6 +108,7 @@ export const STORAGE_KEYS = {
RR_FLOWS: 'rr_flows',
RR_RUNS: 'rr_runs',
RR_PUBLISHED: 'rr_published_flows',
RR_SCHEDULES: 'rr_schedules',
} as const;
// Notification Configuration
@@ -34,6 +34,10 @@ export const BACKGROUND_MESSAGE_TYPES = {
RR_EXPORT_FLOW: 'rr_export_flow',
RR_EXPORT_ALL: 'rr_export_all',
RR_IMPORT_FLOW: 'rr_import_flow',
// Scheduling
RR_SCHEDULE_FLOW: 'rr_schedule_flow',
RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow',
RR_LIST_SCHEDULES: 'rr_list_schedules',
} as const;
// Offscreen message types
@@ -1,6 +1,6 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '../tools';
import {
import type {
Flow,
RunLogEntry,
RunRecord,
@@ -13,6 +13,8 @@ import {
StepDrag,
StepWait,
StepScript,
NodeBase as DagNode,
Edge as DagEdge,
} from './types';
import { appendRun } from './flow-store';
import { locateElement } from './selector-engine';
@@ -27,6 +29,7 @@ export interface RunOptions {
timeoutMs?: number;
startUrl?: string;
args?: Record<string, any>;
startNodeId?: string; // start executing from this node/step id if present
}
export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<RunResult> {
@@ -39,12 +42,52 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
}
if (options.args) Object.assign(vars, options.args);
// prepare tab & binding check
if (options.startUrl) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: options.startUrl } });
}
if (options.refresh) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } });
// Helper: ensure target tab according to tabTarget/startUrl, and optionally refresh
const ensureTab = async () => {
const target = options.tabTarget || 'current';
const startUrl = options.startUrl;
const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
if (target === 'new') {
let urlToOpen = startUrl;
if (!urlToOpen) {
// duplicate current active tab's URL when startUrl not provided
urlToOpen = active?.url || 'about:blank';
}
const created = await chrome.tabs.create({ url: urlToOpen, active: true });
// Best-effort wait for loading to begin and settle a bit
await new Promise((r) => setTimeout(r, 500));
} else {
// current tab target
if (startUrl) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: startUrl } });
} else if (options.refresh) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } });
}
}
};
await ensureTab();
// helper to apply assign mapping: { varName: 'a.b[0].c' }
function applyAssign(target: Record<string, any>, source: any, assign: Record<string, string>) {
const getByPath = (obj: any, path: string) => {
try {
const parts = path
.replace(/\[(\d+)\]/g, '.$1')
.split('.')
.filter(Boolean);
let cur = obj;
for (const p of parts) {
if (cur == null) return undefined;
cur = cur[p as any];
}
return cur;
} catch {
return undefined;
}
};
for (const [k, v] of Object.entries(assign || {})) {
target[k] = getByPath(source, String(v));
}
}
// Ensure helper scripts are present for overlay/collectVariables
@@ -64,7 +107,10 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
if (needed.length > 0) {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,
args: { eventName: 'collectVariables', payload: undefined },
args: {
eventName: 'collectVariables',
payload: JSON.stringify({ variables: needed, useOverlay: true }),
},
});
// Fallback: if direct collectVariables without payload not supported, call with explicit variables
let values: Record<string, any> | null = null;
@@ -84,6 +130,7 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
return await chrome.tabs.sendMessage(tabId, {
action: 'collectVariables',
variables: needed,
useOverlay: true,
} as any);
});
if (res2 && res2.success && res2.values) values = res2.values;
@@ -234,9 +281,32 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
}
}
// If DAG present, linearize to steps for M1 (default edges, topo order)
const stepsToRun: Step[] = (() => {
try {
if (Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0) {
const nodes = ((flow as any).nodes || []) as DagNode[];
const edges = (((flow as any).edges || []) as DagEdge[]).filter(
(e) => !e.label || e.label === 'default',
);
const order = topoOrder(nodes, edges);
return order.map((n) => mapDagNodeToStep(n));
}
} catch {
// ignore and fallback
}
return flow.steps || [];
})();
// If a startNodeId is provided, slice the plan to start from that node/step id
const startIdx = options.startNodeId
? stepsToRun.findIndex((s) => s?.id === options.startNodeId)
: -1;
const steps = startIdx >= 0 ? stepsToRun.slice(startIdx) : stepsToRun.slice();
try {
const pendingAfterScripts: StepScript[] = [];
for (const step of flow.steps) {
for (const step of steps) {
const t0 = Date.now();
const maxRetries = Math.max(0, step.retry?.count ?? 0);
const baseInterval = Math.max(0, step.retry?.intervalMs ?? 0);
@@ -266,7 +336,138 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
}
let stepLogged = false;
// Helper get current active tab URL and status
const getActiveTabInfo = async () => {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
return { url: tab?.url || '', status: (tab as any)?.status || '' };
};
// Wait for navigation completion or readiness
const waitForNavigation = async (prevUrl: string, timeoutMs: number) => {
const deadline = Date.now() + Math.max(1000, Math.min(timeoutMs || 15000, 30000));
let sawLoading = false;
while (Date.now() < deadline) {
const { url, status } = await getActiveTabInfo();
if (url && url !== prevUrl) return true;
if (status === 'loading') sawLoading = true;
if (sawLoading && status === 'complete') return true;
await new Promise((r) => setTimeout(r, 200));
}
// as a last attempt, try a brief network idle wait
try {
await waitForNetworkIdle(2000, 800);
return true;
} catch (e) {
// noop
void 0;
}
throw new Error('navigation timeout');
};
switch (step.type) {
case 'http': {
const s = step as any;
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_REQUEST,
args: {
url: s.url,
method: s.method || 'GET',
headers: s.headers || {},
body: s.body,
},
});
const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;
try {
const payload = text ? JSON.parse(text) : null;
if (s.saveAs && payload !== undefined) vars[s.saveAs] = payload;
if (s.assign && payload !== undefined) applyAssign(vars, payload, s.assign);
} catch {
// ignore parse error
}
break;
}
case 'extract': {
const s = step as any;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
let value: any = null;
if (s.js && String(s.js).trim()) {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (code: string) => {
try {
return (0, eval)(code);
} catch (e) {
return null;
}
},
args: [String(s.js)],
} as any);
value = result;
} else if (s.selector) {
const attr = String(s.attr || 'text');
const sel = String(s.selector);
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (selector: string, attr: string) => {
try {
const el = document.querySelector(selector) as any;
if (!el) return null;
if (attr === 'text' || attr === 'textContent')
return (el.textContent || '').trim();
return el.getAttribute ? el.getAttribute(attr) : null;
} catch {
return null;
}
},
args: [sel, attr],
} as any);
value = result;
}
if (s.saveAs) vars[s.saveAs] = value;
break;
}
case 'openTab': {
const s = step as any;
if (s.newWindow) {
await chrome.windows.create({ url: s.url || undefined, focused: true });
} else {
await chrome.tabs.create({ url: s.url || undefined, active: true });
}
break;
}
case 'switchTab': {
const s = step as any;
let targetTabId: number | undefined = s.tabId;
if (!targetTabId) {
const tabs = await chrome.tabs.query({});
const hit = tabs.find(
(t) =>
(s.urlContains && (t.url || '').includes(String(s.urlContains))) ||
(s.titleContains && (t.title || '').includes(String(s.titleContains))),
);
targetTabId = (hit && hit.id) as number | undefined;
}
if (targetTabId) {
await handleCallTool({
name: TOOL_NAMES.BROWSER.SWITCH_TAB,
args: { tabId: targetTabId },
});
} else {
throw new Error('switchTab: no matching tab');
}
break;
}
case 'closeTab': {
const s = step as any;
const args: any = {};
if (Array.isArray(s.tabIds) && s.tabIds.length) args.tabIds = s.tabIds;
if (s.url) args.url = s.url;
const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.CLOSE_TABS, args });
if ((res as any).isError) throw new Error('closeTab failed');
break;
}
case 'scroll': {
const s = step as StepScroll;
const top = s.offset?.y ?? undefined;
@@ -390,20 +591,34 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
} catch {
/* ignore */
}
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.CLICK,
args: {
ref: located?.ref || (step as any).target?.ref,
selector: !located?.ref
? (step as any).target?.candidates?.find(
(c: any) => c.type === 'css' || c.type === 'attr',
)?.value
: undefined,
waitForNavigation: (step as any).after?.waitForNavigation || false,
timeout: Math.max(1000, Math.min(step.timeoutMs || 10000, 30000)),
},
});
const prevInfo = await getActiveTabInfo();
let res: any;
if (step.type === 'dblclick') {
// Use precise CDP-based double click for robustness
res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'double_click', ref: located?.ref || (step as any).target?.ref },
});
} else {
res = await handleCallTool({
name: TOOL_NAMES.BROWSER.CLICK,
args: {
ref: located?.ref || (step as any).target?.ref,
selector: !located?.ref
? (step as any).target?.candidates?.find(
(c: any) => c.type === 'css' || c.type === 'attr',
)?.value
: undefined,
waitForNavigation: false, // we handle navigation explicitly below
timeout: Math.max(1000, Math.min(step.timeoutMs || 10000, 30000)),
},
});
}
if ((res as any).isError) throw new Error('click failed');
// If navigation requested, wait explicitly with retries handled by outer loop
if ((step as any).after?.waitForNavigation) {
await waitForNavigation(prevInfo.url, Math.max(step.timeoutMs || 15000, 3000));
}
if (fallbackUsed) {
logs.push({
stepId: step.id,
@@ -521,16 +736,32 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
case 'wait': {
const s = step as StepWait;
if ('text' in s.condition) {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: {
action: 'wait',
text: s.condition.text,
appear: s.condition.appear !== false,
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
},
});
if ((res as any).isError) throw new Error('wait text failed');
// Use wait-helper for text appearance/disappearance for more robustness
try {
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: { type: 'ISOLATED', jsScript: '' },
});
} catch (e) {
// noop
void 0;
}
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
// Ensure wait-helper is present
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
} as any);
const resp = await chrome.tabs.sendMessage(tabId, {
action: 'waitForText',
text: s.condition.text,
appear: s.condition.appear !== false,
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
} as any);
if (!resp || resp.success !== true) throw new Error('wait text failed');
} else if ('networkIdle' in s.condition) {
const total = Math.min(Math.max(1000, step.timeoutMs || 5000), 120000);
const idle = Math.min(1500, Math.max(500, Math.floor(total / 3)));
@@ -540,17 +771,22 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
const delay = Math.min(step.timeoutMs || 5000, 20000);
await new Promise((r) => setTimeout(r, delay));
} else if ('selector' in s.condition) {
// best-effort: simple text wait with selector string as text
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: {
action: 'wait',
text: s.condition.selector,
appear: s.condition.visible !== false,
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
},
});
if ((res as any).isError) throw new Error('wait selector failed');
// Use wait-helper to wait for selector visibility
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
} as any);
const resp = await chrome.tabs.sendMessage(tabId, {
action: 'waitForSelector',
selector: (s.condition as any).selector,
visible: (s.condition as any).visible !== false,
timeout: Math.max(0, Math.min(step.timeoutMs || 10000, 120000)),
} as any);
if (!resp || resp.success !== true) throw new Error('wait selector failed');
}
break;
}
@@ -709,15 +945,28 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
break;
}
case 'script': {
const world = (step as any).world || 'ISOLATED';
const code = String((step as any).code || '');
const s = step as any;
const world = s.world || 'ISOLATED';
const code = String(s.code || '');
if (!code.trim()) break;
const wrapped = `(() => { try { ${code} } catch (e) { console.error('flow script error:', e); } })();`;
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: { type: world, jsScript: wrapped },
});
if ((res as any).isError) throw new Error('script execution failed');
// Prefer executeScript to capture return value for saveAs/assign
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch (e) {
return null;
}
},
args: [code],
world: world as any,
} as any);
if (s.saveAs) vars[s.saveAs] = result;
if (s.assign && typeof s.assign === 'object') applyAssign(vars, result, s.assign);
break;
}
case 'navigate': {
@@ -757,12 +1006,24 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
const world = (s as any).world || 'ISOLATED';
const code = String((s as any).code || '');
if (code.trim()) {
const wrapped = `(() => { try { ${code} } catch (e) { console.error('flow script error:', e); } })();`;
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: { type: world, jsScript: wrapped },
});
if ((res as any).isError) throw new Error('script(after) execution failed');
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch {
return null;
}
},
args: [code],
world: world as any,
} as any);
if ((s as any).saveAs) vars[(s as any).saveAs] = result;
if ((s as any).assign && typeof (s as any).assign === 'object')
applyAssign(vars, result, (s as any).assign);
}
logs.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
}
@@ -859,8 +1120,8 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
runId,
success: failed === 0,
summary: {
total: flow.steps.length,
success: flow.steps.length - failed,
total: steps.length,
success: steps.length - failed,
failed,
tookMs,
},
@@ -870,3 +1131,73 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
screenshots: { onFailure: logs.find((l) => l.status === 'failed')?.screenshotBase64 },
};
}
// --- DAG helpers (M1: default-edge serial) ---
function topoOrder(nodes: DagNode[], edges: DagEdge[]): DagNode[] {
const id2n = new Map(nodes.map((n) => [n.id, n] as const));
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
const nexts = new Map<string, string[]>(nodes.map((n) => [n.id, [] as string[]] as const));
for (const e of edges) nexts.get(e.from)!.push(e.to);
const q: string[] = nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);
const out: DagNode[] = [];
while (q.length) {
const id = q.shift()!;
const n = id2n.get(id);
if (!n) continue;
out.push(n);
for (const v of nexts.get(id)!) {
indeg.set(v, (indeg.get(v) || 0) - 1);
if ((indeg.get(v) || 0) === 0) q.push(v);
}
}
return out.length === nodes.length ? out : nodes.slice();
}
function mapDagNodeToStep(n: DagNode): Step {
const c: any = n.config || {};
const base = { id: n.id } as any;
if (n.type === 'click' || n.type === 'dblclick')
return {
...base,
type: n.type,
target: c.target || { candidates: [] },
before: c.before,
after: c.after,
} as any;
if (n.type === 'fill')
return {
...base,
type: 'fill',
target: c.target || { candidates: [] },
value: c.value || '',
} as any;
if (n.type === 'key') return { ...base, type: 'key', keys: c.keys || '' } as any;
if (n.type === 'wait')
return { ...base, type: 'wait', condition: c.condition || { text: '', appear: true } } as any;
if (n.type === 'assert')
return {
...base,
type: 'assert',
assert: c.assert || { exists: '' },
failStrategy: c.failStrategy,
} as any;
if (n.type === 'navigate') return { ...base, type: 'navigate', url: c.url || '' } as any;
if (n.type === 'script')
return {
...base,
type: 'script',
world: c.world || 'ISOLATED',
code: c.code || '',
when: c.when,
} as any;
if (n.type === 'delay')
return {
...base,
type: 'wait',
timeoutMs: Math.max(0, Number(c.ms ?? 1000)),
condition: { navigation: true },
} as any;
// Fallback: no-op script
return { ...base, type: 'script', world: 'ISOLATED', code: '' } as any;
}
@@ -108,3 +108,35 @@ export async function importFlowFromJson(json: string): Promise<Flow[]> {
}
return flowsToImport;
}
// Scheduling support
export type ScheduleType = 'once' | 'interval' | 'daily';
export interface FlowSchedule {
id: string; // schedule id
flowId: string;
type: ScheduleType;
enabled: boolean;
// when: ISO string for 'once'; HH:mm for 'daily'; minutes for 'interval'
when: string;
// optional variables to pass when running
args?: Record<string, any>;
}
export async function listSchedules(): Promise<FlowSchedule[]> {
const res = await chrome.storage.local.get([STORAGE_KEYS.RR_SCHEDULES]);
return (res[STORAGE_KEYS.RR_SCHEDULES] as FlowSchedule[]) || [];
}
export async function saveSchedule(s: FlowSchedule): Promise<void> {
const list = await listSchedules();
const idx = list.findIndex((x) => x.id === s.id);
if (idx >= 0) list[idx] = s;
else list.push(s);
await chrome.storage.local.set({ [STORAGE_KEYS.RR_SCHEDULES]: list });
}
export async function removeSchedule(scheduleId: string): Promise<void> {
const list = await listSchedules();
const filtered = list.filter((s) => s.id !== scheduleId);
await chrome.storage.local.set({ [STORAGE_KEYS.RR_SCHEDULES]: filtered });
}
@@ -10,6 +10,10 @@ import {
exportFlow,
exportAllFlows,
importFlowFromJson,
listSchedules,
saveSchedule,
removeSchedule,
type FlowSchedule,
} from './flow-store';
import { runFlow } from './flow-runner';
@@ -21,6 +25,39 @@ let lastClickIdx: number | null = null;
let lastClickTime = 0;
let lastNavTaggedAt = 0;
// Alarm helpers for schedules
async function rescheduleAlarms() {
const schedules = await listSchedules();
// Clear existing rr_schedule_* alarms
const alarms = await chrome.alarms.getAll();
await Promise.all(
alarms
.filter((a) => a.name && a.name.startsWith('rr_schedule_'))
.map((a) => chrome.alarms.clear(a.name)),
);
for (const s of schedules) {
if (!s.enabled) continue;
const name = `rr_schedule_${s.id}`;
if (s.type === 'interval') {
const minutes = Math.max(1, Math.floor(Number(s.when) || 0));
await chrome.alarms.create(name, { periodInMinutes: minutes });
} else if (s.type === 'once') {
const whenMs = Date.parse(s.when);
if (Number.isFinite(whenMs)) await chrome.alarms.create(name, { when: whenMs });
} else if (s.type === 'daily') {
// daily HH:mm local time
const [hh, mm] = String(s.when || '00:00')
.split(':')
.map((x) => Number(x));
const now = new Date();
const next = new Date();
next.setHours(hh || 0, mm || 0, 0, 0);
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
await chrome.alarms.create(name, { when: next.getTime(), periodInMinutes: 24 * 60 });
}
}
}
async function ensureRecorderInjected(tabId: number): Promise<void> {
// Inject helper and recorder scripts
await chrome.scripting.executeScript({
@@ -96,6 +133,9 @@ async function stopRecording(): Promise<{ success: boolean; flow?: Flow; error?:
}
export function initRecordReplayListeners() {
// On startup, re-schedule alarms
rescheduleAlarms().catch(() => {});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
try {
if (message && message.type === 'rr_recorder_event') {
@@ -231,6 +271,40 @@ export function initRecordReplayListeners() {
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES: {
listSchedules()
.then((s) => sendResponse({ success: true, schedules: s }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW: {
const s = message.schedule as FlowSchedule;
if (!s || !s.id || !s.flowId) {
sendResponse({ success: false, error: 'invalid schedule' });
return true;
}
saveSchedule(s)
.then(async () => {
await rescheduleAlarms();
sendResponse({ success: true });
})
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW: {
const scheduleId = String(message.scheduleId || '');
if (!scheduleId) {
sendResponse({ success: false, error: 'invalid scheduleId' });
return true;
}
removeSchedule(scheduleId)
.then(async () => {
await rescheduleAlarms();
sendResponse({ success: true });
})
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
}
} catch (err) {
sendResponse({ success: false, error: (err as any)?.message || String(err) });
@@ -365,3 +439,19 @@ export function initRecordReplayListeners() {
}
});
}
// Alarm listener executes scheduled flows
chrome.alarms.onAlarm.addListener(async (alarm) => {
try {
if (!alarm?.name || !alarm.name.startsWith('rr_schedule_')) return;
const id = alarm.name.slice('rr_schedule_'.length);
const schedules = await listSchedules();
const s = schedules.find((x) => x.id === id && x.enabled);
if (!s) return;
const flow = await getFlow(s.flowId);
if (!flow) return;
await runFlow(flow, { args: s.args || {}, returnLogs: false });
} catch (e) {
// swallow to not spam logs
}
});
@@ -52,13 +52,43 @@ export async function locateElement(
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
}
} else if (c.type === 'aria') {
// Best-effort: try as CSS first, otherwise ignore in M2
const ensured = await chrome.tabs.sendMessage(tabId, {
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
selector: c.value,
});
if (ensured && ensured.success && ensured.ref && ensured.center) {
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
// Minimal ARIA role+name parser like: "button[name=提交]" or "textbox[name=用户名]"
const v = String(c.value || '').trim();
const m = v.match(/^(\w+)\s*\[\s*name\s*=\s*([^\]]+)\]$/);
const role = m ? m[1] : '';
const name = m ? m[2] : '';
const cleanName = name.replace(/^['"]|['"]$/g, '');
const ariaSelectors: string[] = [];
if (role === 'textbox') {
ariaSelectors.push(
`[role="textbox"][aria-label=${JSON.stringify(cleanName)}]`,
`input[aria-label=${JSON.stringify(cleanName)}]`,
`textarea[aria-label=${JSON.stringify(cleanName)}]`,
);
} else if (role === 'button') {
ariaSelectors.push(
`[role="button"][aria-label=${JSON.stringify(cleanName)}]`,
`button[aria-label=${JSON.stringify(cleanName)}]`,
);
} else if (role === 'link') {
ariaSelectors.push(
`[role="link"][aria-label=${JSON.stringify(cleanName)}]`,
`a[aria-label=${JSON.stringify(cleanName)}]`,
);
}
if (!ariaSelectors.length && role) {
ariaSelectors.push(
`[role=${JSON.stringify(role)}][aria-label=${JSON.stringify(cleanName)}]`,
);
}
for (const sel of ariaSelectors) {
const ensured = await chrome.tabs.sendMessage(tabId, {
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
selector: sel,
});
if (ensured && ensured.success && ensured.ref && ensured.center) {
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
}
}
} else if (c.type === 'xpath') {
// Minimal xpath support via document.evaluate through injected helper
@@ -23,7 +23,12 @@ export type StepType =
| 'wait'
| 'assert'
| 'script'
| 'navigate';
| 'navigate'
| 'http'
| 'extract'
| 'openTab'
| 'switchTab'
| 'closeTab';
export interface StepBase {
id: string;
@@ -102,7 +107,49 @@ export type Step =
| StepWait
| StepAssert
| StepScript
| (StepBase & { type: 'navigate'; url: string });
| (StepBase & { type: 'navigate'; url: string })
| StepHttp
| StepExtract
| StepOpenTab
| StepSwitchTab
| StepCloseTab;
export interface StepHttp extends StepBase {
type: 'http';
method?: string;
url: string;
headers?: Record<string, string>;
body?: any;
saveAs?: string;
assign?: Record<string, string>;
}
export interface StepExtract extends StepBase {
type: 'extract';
selector?: string;
attr?: string; // 'text'|'textContent' to read text
js?: string; // custom JS that returns value
saveAs: string;
}
export interface StepOpenTab extends StepBase {
type: 'openTab';
url?: string;
newWindow?: boolean;
}
export interface StepSwitchTab extends StepBase {
type: 'switchTab';
tabId?: number;
urlContains?: string;
titleContains?: string;
}
export interface StepCloseTab extends StepBase {
type: 'closeTab';
tabIds?: number[];
url?: string;
}
export interface VariableDef {
key: string;
@@ -112,6 +159,38 @@ export interface VariableDef {
rules?: { required?: boolean; pattern?: string };
}
export type NodeType =
| 'click'
| 'dblclick'
| 'fill'
| 'key'
| 'wait'
| 'assert'
| 'script'
| 'navigate'
| 'openTab'
| 'switchTab'
| 'closeTab'
| 'http'
| 'extract'
| 'delay';
export interface NodeBase {
id: string;
type: NodeType;
name?: string;
disabled?: boolean;
config?: any;
ui?: { x: number; y: number };
}
export interface Edge {
id: string;
from: string;
to: string;
label?: 'default' | 'true' | 'false' | 'onError';
}
export interface Flow {
id: string;
name: string;
@@ -128,6 +207,10 @@ export interface Flow {
};
variables?: VariableDef[];
steps: Step[];
// Flow V2(可选):画布编排
nodes?: NodeBase[];
edges?: Edge[];
subflows?: Record<string, { nodes: NodeBase[]; edges: Edge[] }>;
}
export interface RunLogEntry {
@@ -252,7 +252,10 @@
</div>
<div class="rr-actions">
<button class="semantic-engine-button" @click="runFlow(f.id)">回放</button>
<button class="semantic-engine-button" @click="editFlow(f)">表单编辑</button>
<button class="semantic-engine-button" @click="openBuilder(f)">画布编辑</button>
<button class="semantic-engine-button" @click="publishFlow(f.id)">发布</button>
<button class="semantic-engine-button" @click="openSchedule(f.id)">定时</button>
<button class="danger-button" @click="deleteFlow(f.id)">删除</button>
</div>
</div>
@@ -291,6 +294,27 @@
@confirm="confirmClearAllData"
@cancel="hideClearDataConfirmation"
/>
<FlowEditor
:visible="showFlowEditor"
:flow="editingFlow"
@close="showFlowEditor = false"
@save="saveEditedFlow"
/>
<BuilderEditor
:visible="showBuilderEditor"
:flow="editingFlowBuilder"
@close="showBuilderEditor = false"
@save="saveEditedFlowFromBuilder"
/>
<ScheduleDialog
:visible="showSchedule"
:flow-id="schedulingFlowId"
:schedules="schedules.filter((s) => s.flowId === schedulingFlowId)"
@close="showSchedule = false"
@save="saveSchedule"
@remove="removeSchedule"
/>
</div>
</template>
@@ -310,6 +334,9 @@ import { getMessage } from '@/utils/i18n';
import ConfirmDialog from './components/ConfirmDialog.vue';
import ProgressIndicator from './components/ProgressIndicator.vue';
import ModelCacheManagement from './components/ModelCacheManagement.vue';
import FlowEditor from './components/FlowEditor.vue';
import BuilderEditor from './components/BuilderEditor.vue';
import ScheduleDialog from './components/ScheduleDialog.vue';
import {
DocumentIcon,
DatabaseIcon,
@@ -329,6 +356,15 @@ const filteredRrFlows = computed(() =>
rrOnlyBound.value ? rrFlows.value.filter(isFlowBoundToCurrent) : rrFlows.value,
);
// Flow editor state
const showFlowEditor = ref(false);
const editingFlow = ref<any | null>(null);
const showBuilderEditor = ref(false);
const editingFlowBuilder = ref<any | null>(null);
const showSchedule = ref(false);
const schedulingFlowId = ref<string | null>(null);
const schedules = ref<any[]>([]);
const loadFlows = async () => {
try {
const res = await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS });
@@ -420,6 +456,93 @@ const deleteFlow = async (flowId: string) => {
}
};
function editFlow(flow: any) {
editingFlow.value = flow;
showFlowEditor.value = true;
}
function openBuilder(flow: any) {
editingFlowBuilder.value = flow;
showBuilderEditor.value = true;
}
async function saveEditedFlow(f: any) {
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW,
flow: f,
});
if (res && res.success) {
showFlowEditor.value = false;
editingFlow.value = null;
await loadFlows();
}
} catch (e) {
console.error('保存失败:', e);
}
}
async function saveEditedFlowFromBuilder(f: any) {
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW,
flow: f,
});
if (res && res.success) {
showBuilderEditor.value = false;
editingFlowBuilder.value = null;
await loadFlows();
}
} catch (e) {
console.error('保存失败:', e);
}
}
async function openSchedule(flowId: string) {
schedulingFlowId.value = flowId;
await loadSchedules();
showSchedule.value = true;
}
async function loadSchedules() {
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES,
});
if (res && res.success) schedules.value = res.schedules || [];
} catch (e) {
console.error('加载定时失败:', e);
}
}
async function saveSchedule(schedule: any) {
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW,
schedule,
});
if (res && res.success) {
await loadSchedules();
showSchedule.value = false;
schedulingFlowId.value = null;
}
} catch (e) {
console.error('保存定时失败:', e);
}
}
async function removeSchedule(id: string) {
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW,
scheduleId: id,
});
if (res && res.success) await loadSchedules();
} catch (e) {
console.error('删除计划失败:', e);
}
}
const nativeConnectionStatus = ref<'unknown' | 'connected' | 'disconnected'>('unknown');
const isConnecting = ref(false);
const nativeServerPort = ref<number>(12306);
@@ -0,0 +1,416 @@
<template>
<div v-if="visible" class="builder-modal">
<div class="builder">
<div class="topbar">
<div class="left">
<strong>编排画布</strong>
<span class="tip">基于 VueFlowM1串行 DAG</span>
</div>
<div class="right">
<input
class="search"
v-model="search"
placeholder="搜索节点名或选择器... 回车定位"
@keyup.enter="focusSearch"
/>
<button class="btn" @click="store.undo" title="撤销 (⌘/Ctrl+Z)">撤销</button>
<button class="btn" @click="store.redo" title="重做 (⌘/Ctrl+Shift+Z)">重做</button>
<span class="status" :data-state="saveState">{{ saveLabel }}</span>
<span class="status" v-if="errorsCount > 0" title="存在校验错误">{{
`错误: ${errorsCount}`
}}</span>
<button class="btn" @click="importFromSteps" title="从线性步骤生成图">步骤</button>
<button class="btn" @click="exportToSteps" title="用当前图覆盖步骤">步骤</button>
<button class="btn" @click="store.layoutAuto" title="自动排版(简单拓扑布局)"
>自动排版</button
>
<button class="btn" @click="fitAll" title="自适应视图">自适应</button>
<button class="btn" @click="exportFlow" title="导出 JSON">导出</button>
<label class="btn import">
导入
<input type="file" accept="application/json" @change="onImport" />
</label>
<button
class="btn"
:disabled="!selectedId"
@click="runFromSelected"
title="从选中节点回放"
>从选中回放</button
>
<button v-if="errorsCount > 0" class="btn" @click="toggleErrors">错误列表</button>
<button class="btn primary" @click="save">保存</button>
<button class="btn" @click="$emit('close')">关闭</button>
</div>
</div>
<div class="main">
<Sidebar
:flow="store.flowLocal"
:palette-types="store.paletteTypes"
@add-node="store.addNode"
/>
<Canvas
:nodes="store.nodes"
:edges="store.edges"
:focus-node-id="focusNodeId"
:fit-seq="fitSeq"
@select-node="store.selectNode"
@duplicate-node="store.duplicateNode"
@remove-node="store.removeNode"
@connect-from="store.connectFrom"
@connect="store.onConnect"
@node-dragged="store.setNodePosition"
/>
<PropertyPanel :node="activeNode" :highlight-field="highlightField" />
<div v-if="showErrors && errorsCount > 0" class="error-panel">
<div class="err-title">校验错误点击定位</div>
<div class="err-list">
<div
v-for="(errs, nid) in validation.nodeErrors"
:key="nid"
class="err-item"
@click="focusError(String(nid), errs[0])"
>
<div class="nid">{{ String(nid) }}</div>
<div class="elist">
<div v-for="e in errs" :key="e" class="e"> {{ e }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, watch, onMounted, onUnmounted, ref } from 'vue';
import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';
import { useBuilderStore } from './builder/store/useBuilderStore';
import { nodesToSteps } from './builder/model/transforms';
import { validateFlow } from './builder/model/validation';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import Canvas from './builder/components/Canvas.vue';
import Sidebar from './builder/components/Sidebar.vue';
import PropertyPanel from './builder/components/PropertyPanel.vue';
const props = defineProps<{ visible: boolean; flow: FlowV2 | null }>();
const emit = defineEmits(['close', 'save']);
const store = useBuilderStore();
watch(
() => props.flow,
(f) => {
if (f) store.initFromFlow(f);
},
{ immediate: true },
);
// 由于 store.activeNodeId 是一个 Ref,这里统一取其值避免 TS 比较报错
const selectedId = computed<string | null>(() => (store.activeNodeId as any)?.value ?? null);
const activeNode = computed(() => store.nodes.find((n) => n.id === selectedId.value) || null);
const validation = computed(() => validateFlow(store.nodes));
const errorsCount = computed(() => validation.value.totalErrors);
const showErrors = ref(false);
function toggleErrors() {
showErrors.value = !showErrors.value;
}
// 搜索与聚焦
const search = ref('');
const focusNodeId = ref<string | null>(null);
const highlightField = ref<string | null>(null);
const fitSeq = ref(0);
function focusSearch() {
const q = search.value.trim().toLowerCase();
if (!q) return;
const hit = store.nodes.find(
(n) =>
(n.name || '').toLowerCase().includes(q) ||
(n.config?.target?.candidates?.[0]?.value || '').toLowerCase().includes(q),
);
if (hit) {
store.selectNode(hit.id);
focusNodeId.value = hit.id;
setTimeout(() => (focusNodeId.value = null), 300);
}
}
function importFromSteps() {
store.importFromSteps();
}
function exportToSteps() {
store.flowLocal.steps = nodesToSteps(store.nodes, store.edges);
}
function save() {
store.flowLocal.steps = nodesToSteps(store.nodes, store.edges);
const result = JSON.parse(
JSON.stringify({ ...store.flowLocal, nodes: store.nodes, edges: store.edges }),
);
emit('save', result);
}
async function runFromSelected() {
if (!selectedId.value || !store.flowLocal?.id) return;
try {
await save();
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,
flowId: store.flowLocal.id,
options: { returnLogs: true, startNodeId: selectedId.value },
});
if (!(res && res.success)) console.warn('从选中节点回放失败');
} catch (e) {
console.error('从选中节点回放失败:', e);
}
}
function focusNode(id: string) {
store.selectNode(id);
focusNodeId.value = id;
setTimeout(() => (focusNodeId.value = null), 300);
}
function focusError(nid: string, msg: string) {
const node = store.nodes.find((n) => n.id === nid);
if (!node) return focusNode(nid);
focusNode(nid);
const t = node.type;
let field: string | null = null;
if (t === 'http') field = 'http.url';
else if (t === 'extract')
field = msg.includes('保存变量名') ? 'extract.saveAs' : 'extract.selector';
else if (t === 'switchTab') field = 'switchTab.match';
else if (t === 'navigate') field = 'navigate.url';
else if (t === 'fill') field = msg.includes('输入值') ? 'fill.value' : 'target.candidates';
else if (t === 'click' || t === 'dblclick') field = 'target.candidates';
else if (t === 'script') field = msg.includes('缺少代码') ? 'script.code' : 'script.assign';
else field = null;
highlightField.value = field;
setTimeout(() => (highlightField.value = null), 1500);
}
function fitAll() {
fitSeq.value++;
}
async function exportFlow() {
try {
await save();
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_EXPORT_FLOW,
flowId: store.flowLocal.id,
});
if (res && res.success) {
const blob = new Blob([res.json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
await chrome.downloads.download({
url,
filename: `${store.flowLocal.name || 'flow'}.json`,
saveAs: true,
} as any);
URL.revokeObjectURL(url);
}
} catch (e) {
console.error('导出失败:', e);
}
}
async function onImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const txt = await file.text();
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_IMPORT_FLOW,
json: txt,
});
if (res && res.success) {
if (Array.isArray(res.flows) && res.flows.length) {
store.initFromFlow(res.flows[0]);
}
}
} catch (err) {
console.error('导入失败:', err);
} finally {
input.value = '';
}
}
// 快捷键:Delete/Backspace 删除选中,Cmd/Ctrl+D 复制,Cmd/Ctrl+S 保存
function onKey(e: KeyboardEvent) {
const id = selectedId.value;
const isMeta = e.metaKey || e.ctrlKey;
if ((e.key === 'Delete' || e.key === 'Backspace') && id) {
e.preventDefault();
store.removeNode(id);
} else if (isMeta && e.key.toLowerCase?.() === 'd') {
if (id) {
e.preventDefault();
store.duplicateNode(id);
}
} else if (isMeta && e.key.toLowerCase?.() === 'z') {
e.preventDefault();
if (e.shiftKey) store.redo();
else store.undo();
} else if (isMeta && e.key.toLowerCase?.() === 's') {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener('keydown', onKey));
onUnmounted(() => document.removeEventListener('keydown', onKey));
// 自动保存(去抖)
const saveState = ref<'idle' | 'saving' | 'saved'>('idle');
const saveLabel = computed(() =>
saveState.value === 'saving' ? '保存中…' : saveState.value === 'saved' ? '已保存' : '',
);
let saveTimer: any = null;
let statusTimer: any = null;
function scheduleAutoSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
saveState.value = 'saving';
await new Promise((r) => setTimeout(r, 0));
save();
saveState.value = 'saved';
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(() => (saveState.value = 'idle'), 1200);
} catch {
saveState.value = 'idle';
}
}, 800);
}
watch(
() => [store.nodes, store.edges, store.flowLocal.name, (store.flowLocal as any).description],
scheduleAutoSave,
{ deep: true },
);
</script>
<style scoped>
.builder-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
}
.builder {
width: 96vw;
height: 90vh;
background: #fff;
border-radius: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid #e5e7eb;
}
.topbar .left {
display: flex;
gap: 8px;
align-items: center;
}
.topbar .tip {
color: #6b7280;
font-size: 12px;
}
.topbar .right {
display: flex;
gap: 8px;
align-items: center;
}
.btn {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
.btn.primary {
background: #111;
color: #fff;
border-color: #111;
}
.main {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 360px;
}
.topbar .search {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px 10px;
margin-right: 8px;
min-width: 240px;
}
.topbar .status {
color: #6b7280;
font-size: 12px;
margin-right: 8px;
min-width: 48px;
display: inline-block;
}
.error-panel {
position: absolute;
right: 12px;
top: 56px;
width: 420px;
max-height: 50vh;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
padding: 10px;
overflow: auto;
}
.err-title {
font-weight: 600;
margin-bottom: 6px;
}
.err-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: 6px;
padding: 6px;
border: 1px solid #f3f4f6;
border-radius: 6px;
cursor: pointer;
margin-bottom: 6px;
}
.err-item:hover {
background: #f9fafb;
}
.err-item .nid {
font-size: 12px;
color: #374151;
}
.err-item .e {
font-size: 12px;
color: #ef4444;
}
.btn.import {
position: relative;
overflow: hidden;
}
.btn.import input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
</style>
@@ -0,0 +1,363 @@
<template>
<div v-if="visible" class="rr-modal">
<div class="rr-dialog">
<div class="rr-header">
<div class="title">编辑录制流</div>
<button class="close" @click="$emit('close')"></button>
</div>
<div class="rr-body">
<div class="row">
<label>名称</label>
<input v-model="local.name" />
</div>
<div class="row">
<label>描述</label>
<input v-model="local.description" />
</div>
<div class="section">
<div class="section-title">绑定</div>
<div class="bindings">
<div class="binding-row" v-for="(b, i) in local.meta.bindings" :key="i">
<select v-model="b.type">
<option value="domain">domain</option>
<option value="path">path</option>
<option value="url">url</option>
</select>
<input
v-model="b.value"
placeholder="例如 example.com 或 /login 或 https://example.com/login"
/>
<button class="small danger" @click="local.meta.bindings.splice(i, 1)">删除</button>
</div>
<button class="small" @click="local.meta.bindings.push({ type: 'domain', value: '' })"
>添加绑定</button
>
</div>
</div>
<div class="section">
<div class="section-title">变量</div>
<div class="vars">
<div class="var-row" v-for="(v, i) in local.variables" :key="v.key + i">
<input v-model="v.key" placeholder="key" class="narrow" />
<input v-model="v.label" placeholder="标签" class="narrow" />
<label class="chk"><input type="checkbox" v-model="v.sensitive" />敏感</label>
<input v-model="v.default" placeholder="默认值" />
<button class="small danger" @click="local.variables.splice(i, 1)">删除</button>
</div>
<button
class="small"
@click="local.variables.push({ key: '', label: '', sensitive: false, default: '' })"
>添加变量</button
>
</div>
</div>
<div class="section">
<div class="section-title">步骤</div>
<div class="steps">
<div class="step" v-for="(s, i) in local.steps" :key="s.id">
<div class="step-head">
<div class="meta">
<span class="badge">{{ s.type }}</span>
<span class="id">{{ s.id }}</span>
</div>
<div class="actions">
<button class="small" @click="moveStep(i, -1)" :disabled="i === 0">上移</button>
<button
class="small"
@click="moveStep(i, 1)"
:disabled="i === local.steps.length - 1"
>下移</button
>
<button class="small danger" @click="removeStep(i)">删除</button>
</div>
</div>
<div class="step-body">
<div class="row">
<label>超时(ms)</label>
<input type="number" v-model.number="s.timeoutMs" />
</div>
<div
class="row"
v-if="s.type === 'click' || s.type === 'dblclick' || s.type === 'fill'"
>
<label>选择器候选顺序即优先级</label>
<div class="cands">
<div class="cand" v-for="(c, j) in s.target.candidates" :key="j">
<select v-model="c.type">
<option value="css">css</option>
<option value="attr">attr</option>
<option value="aria">aria</option>
<option value="text">text</option>
<option value="xpath">xpath</option>
</select>
<input v-model="c.value" placeholder="选择器或表达式" />
<button
class="small"
@click="swapCand(s.target.candidates, j, j - 1)"
:disabled="j === 0"
></button
>
<button
class="small"
@click="swapCand(s.target.candidates, j, j + 1)"
:disabled="j === s.target.candidates.length - 1"
></button
>
<button class="small danger" @click="s.target.candidates.splice(j, 1)"
></button
>
</div>
<button
class="small"
@click="s.target.candidates.push({ type: 'css', value: '' })"
>添加候选</button
>
</div>
</div>
<div class="row" v-if="s.type === 'fill'">
<label>输入值</label>
<input v-model="s.value" placeholder="支持 {var} 格式" />
</div>
<div class="row" v-if="s.type === 'click' || s.type === 'dblclick'">
<label>点击后等待导航</label>
<label class="chk"
><input
type="checkbox"
v-model="(s as any).after.waitForNavigation"
/>启用</label
>
</div>
<div class="row" v-if="s.type === 'wait'">
<label>等待条件(JSON)</label>
<textarea v-model="jsonCondition[i]"></textarea>
</div>
<div class="row" v-if="s.type === 'assert'">
<label>断言(JSON)</label>
<textarea v-model="jsonAssert[i]"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="rr-footer">
<button class="primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, watch, computed } from 'vue';
const props = defineProps<{ visible: boolean; flow: any | null }>();
const emit = defineEmits(['close', 'save']);
const local = reactive<any>({
id: '',
name: '',
description: '',
version: 1,
meta: { bindings: [] },
variables: [],
steps: [],
});
const jsonCondition = reactive<string[]>([]);
const jsonAssert = reactive<string[]>([]);
watch(
() => props.flow,
(f) => {
if (!f) return;
const clone = JSON.parse(JSON.stringify(f));
Object.assign(local, clone);
if (!local.meta) local.meta = {};
if (!Array.isArray(local.meta.bindings)) local.meta.bindings = [];
jsonCondition.length = 0;
jsonAssert.length = 0;
for (const s of local.steps) {
jsonCondition.push(s.type === 'wait' ? JSON.stringify(s.condition || {}, null, 2) : '');
jsonAssert.push(s.type === 'assert' ? JSON.stringify(s.assert || {}, null, 2) : '');
if ((s.type === 'click' || s.type === 'dblclick' || s.type === 'fill') && !s.target)
s.target = { candidates: [] };
if ((s.type === 'click' || s.type === 'dblclick') && !s.after) (s as any).after = {};
}
},
{ immediate: true },
);
function swapCand(arr: any[], i: number, j: number) {
if (j < 0 || j >= arr.length) return;
const t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
function moveStep(i: number, delta: number) {
const j = i + delta;
if (j < 0 || j >= local.steps.length) return;
const t = local.steps[i];
local.steps[i] = local.steps[j];
local.steps[j] = t;
}
function removeStep(i: number) {
local.steps.splice(i, 1);
}
function save() {
// parse json fields
for (let i = 0; i < local.steps.length; i++) {
const s = local.steps[i];
if (s.type === 'wait' && jsonCondition[i]) {
try {
s.condition = JSON.parse(jsonCondition[i]);
} catch {}
}
if (s.type === 'assert' && jsonAssert[i]) {
try {
s.assert = JSON.parse(jsonAssert[i]);
} catch {}
}
}
emit('save', JSON.parse(JSON.stringify(local)));
}
</script>
<style scoped>
.rr-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
}
.rr-dialog {
background: #fff;
border-radius: 8px;
max-width: 960px;
width: 96vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.rr-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
.rr-header .title {
font-weight: 600;
}
.rr-header .close {
border: none;
background: #f3f4f6;
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
}
.rr-body {
padding: 12px 16px;
overflow: auto;
}
.row {
display: flex;
gap: 8px;
align-items: center;
margin: 6px 0;
}
.row > label {
width: 120px;
color: #374151;
}
.row > input,
.row > textarea,
.row > select {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px 8px;
}
.row > textarea {
min-height: 64px;
}
.chk {
display: inline-flex;
gap: 6px;
align-items: center;
}
.section {
margin: 12px 0;
}
.section-title {
font-weight: 600;
margin-bottom: 6px;
}
.vars .var-row input.narrow {
width: 160px;
}
.steps .step {
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 10px;
}
.steps .step-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.steps .step-head .badge {
background: #eef2ff;
color: #3730a3;
padding: 2px 6px;
border-radius: 6px;
margin-right: 8px;
}
.steps .step-head .id {
color: #6b7280;
font-size: 12px;
}
.steps .step-body {
padding: 8px 10px;
}
.small {
font-size: 12px;
padding: 4px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
}
.danger {
background: #fee2e2;
border-color: #fecaca;
}
.primary {
background: #111;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
}
.rr-footer {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
}
.cands .cand {
display: flex;
gap: 6px;
align-items: center;
margin: 4px 0;
}
</style>
@@ -0,0 +1,233 @@
<template>
<div v-if="visible" class="rr-modal">
<div class="rr-dialog">
<div class="rr-header">
<div class="title">定时执行</div>
<button class="close" @click="$emit('close')"></button>
</div>
<div class="rr-body">
<div class="row">
<label>启用</label>
<label class="chk"><input type="checkbox" v-model="enabled" />启用定时</label>
</div>
<div class="row">
<label>类型</label>
<select v-model="type">
<option value="interval">每隔 N 分钟</option>
<option value="daily">每天固定时间</option>
<option value="once">只执行一次</option>
</select>
</div>
<div class="row" v-if="type === 'interval'">
<label>间隔(分钟)</label>
<input type="number" v-model.number="intervalMinutes" />
</div>
<div class="row" v-if="type === 'daily'">
<label>时间(HH:mm)</label>
<input v-model="dailyTime" placeholder="例如 09:30" />
</div>
<div class="row" v-if="type === 'once'">
<label>时间(ISO)</label>
<input v-model="onceAt" placeholder="例如 2025-10-05T10:00:00" />
</div>
<div class="row">
<label>参数(JSON)</label>
<textarea v-model="argsJson" placeholder='{ "username": "xx" }'></textarea>
</div>
<div class="section">
<div class="section-title">已有计划</div>
<div class="sched-list">
<div class="sched-row" v-for="s in schedules" :key="s.id">
<div class="meta">
<span class="badge" :class="{ on: s.enabled, off: !s.enabled }">{{ s.type }}</span>
<span class="desc">{{ describe(s) }}</span>
</div>
<div class="actions">
<button class="small danger" @click="$emit('remove', s.id)">删除</button>
</div>
</div>
</div>
</div>
</div>
<div class="rr-footer">
<button class="primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
const props = defineProps<{ visible: boolean; flowId: string | null; schedules: any[] }>();
const emit = defineEmits(['close', 'save', 'remove']);
const enabled = ref(true);
const type = ref<'interval' | 'daily' | 'once'>('interval');
const intervalMinutes = ref(30);
const dailyTime = ref('09:00');
const onceAt = ref('');
const argsJson = ref('');
watch(
() => props.visible,
(v) => {
if (v) {
enabled.value = true;
type.value = 'interval';
intervalMinutes.value = 30;
dailyTime.value = '09:00';
onceAt.value = '';
argsJson.value = '';
}
},
);
function save() {
if (!props.flowId) return;
const schedule = {
id: `sch_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
flowId: props.flowId,
type: type.value,
enabled: enabled.value,
when:
type.value === 'interval'
? String(intervalMinutes.value)
: type.value === 'daily'
? dailyTime.value
: onceAt.value,
args: safeParse(argsJson.value),
} as any;
emit('save', schedule);
}
function safeParse(s: string) {
if (!s || !s.trim()) return {};
try {
return JSON.parse(s);
} catch {
return {};
}
}
function describe(s: any) {
if (s.type === 'interval') return `${s.when} 分钟`;
if (s.type === 'daily') return `每天 ${s.when}`;
if (s.type === 'once') return `一次 ${s.when}`;
return '';
}
</script>
<style scoped>
.rr-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
}
.rr-dialog {
background: #fff;
border-radius: 8px;
max-width: 720px;
width: 96vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.rr-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
.rr-header .title {
font-weight: 600;
}
.rr-header .close {
border: none;
background: #f3f4f6;
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
}
.rr-body {
padding: 12px 16px;
overflow: auto;
}
.row {
display: flex;
gap: 8px;
align-items: center;
margin: 6px 0;
}
.row > label {
width: 120px;
color: #374151;
}
.row > input,
.row > textarea,
.row > select {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px 8px;
}
.row > textarea {
min-height: 64px;
}
.chk {
display: inline-flex;
gap: 6px;
align-items: center;
}
.sched-list .sched-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin: 4px 0;
}
.badge {
padding: 2px 6px;
border-radius: 6px;
background: #e5e7eb;
}
.badge.on {
background: #dcfce7;
}
.badge.off {
background: #fee2e2;
}
.small {
font-size: 12px;
padding: 4px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
}
.danger {
background: #fee2e2;
border-color: #fecaca;
}
.primary {
background: #111;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
}
.rr-footer {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,202 @@
<template>
<section class="canvas">
<VueFlow
v-model:nodes="vfNodes"
v-model:edges="vfEdges"
:min-zoom="0.2"
:max-zoom="1.5"
:fit-view-on-init="true"
snap-to-grid
:snap-grid="[15, 15]"
@connect="onConnectInternal"
@node-drag-stop="onNodeDragStopInternal"
>
<Background patternColor="#f3f4f6" :gap="20" />
<Controls position="top-left" />
<MiniMap :pannable="true" :zoomable="true" />
<template #node-default="{ id, selected }">
<div
:class="['vf-node-card', selected ? 'selected' : '']"
@click.stop="emit('selectNode', id)"
>
<div class="node-head">
<span class="type">{{ findNodeBase(id)?.type }}</span>
<span class="name">{{ findNodeBase(id)?.name || id }}</span>
</div>
<div class="node-body">{{ summarize(findNodeBase(id)) }}</div>
<div class="node-actions">
<button class="mini" @click.stop="emit('connectFrom', id)">连接</button>
<button class="mini" @click.stop="emit('duplicateNode', id)">复制</button>
<button class="mini danger" @click.stop="emit('removeNode', id)">删除</button>
</div>
<Handle type="target" :position="Position.Left" />
<Handle type="source" :position="Position.Right" />
</div>
</template>
</VueFlow>
</section>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import {
VueFlow,
type Node as VFNode,
type Edge as VFEdge,
type Connection,
Handle,
useVueFlow,
Position,
} from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { MiniMap } from '@vue-flow/minimap';
import { Controls } from '@vue-flow/controls';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
import '@vue-flow/background/dist/style.css';
import type { NodeBase, Edge as EdgeV2 } from '@/entrypoints/background/record-replay/types';
import { summarizeNode as summarize } from '../model/transforms';
const props = defineProps<{
nodes: NodeBase[];
edges: EdgeV2[];
focusNodeId?: string | null;
fitSeq?: number;
}>();
const emit = defineEmits<{
(e: 'selectNode', id: string): void;
(e: 'duplicateNode', id: string): void;
(e: 'removeNode', id: string): void;
(e: 'connectFrom', id: string): void;
(e: 'connect', src: string, dst: string): void;
(e: 'nodeDragged', id: string, x: number, y: number): void;
}>();
const vfNodes = ref<VFNode[]>([]);
const vfEdges = ref<VFEdge[]>([]);
defineOptions({ name: 'BuilderCanvas' });
const { fitView, getNodes } = useVueFlow();
watch(
() => props.nodes,
(list) => {
vfNodes.value = list.map((n) => ({
id: n.id,
position: { x: n.ui?.x || 0, y: n.ui?.y || 0 },
type: 'default',
data: {},
}));
},
{ immediate: true, deep: true },
);
watch(
() => props.edges,
(list) => {
vfEdges.value = list.map((e) => ({ id: e.id, source: e.from, target: e.to }));
},
{ immediate: true, deep: true },
);
watch(
() => props.focusNodeId,
(id) => {
if (!id) return;
const nd = getNodes.value.find((n) => n.id === id);
if (!nd) return;
try {
fitView({ nodes: [nd.id], duration: 300, padding: 0.2 });
} catch {}
},
);
watch(
() => props.fitSeq,
() => {
try {
fitView({ duration: 300, padding: 0.2 });
} catch {}
},
);
function findNodeBase(id: string) {
return props.nodes.find((n) => n.id === id) || null;
}
function onNodeDragStopInternal(evt: any) {
const node = evt?.node as VFNode | undefined;
if (!node) return;
emit('nodeDragged', node.id, Math.round(node.position.x), Math.round(node.position.y));
}
function onConnectInternal(conn: Connection) {
if (!conn.source || !conn.target) return;
emit('connect', conn.source, conn.target);
// 边更新由上层状态驱动,这里无需直接修改本地 vfEdges
}
</script>
<style scoped>
.canvas {
position: relative;
overflow: hidden;
}
.vf-node-card {
width: 240px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.vf-node-card.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.node-head {
display: flex;
gap: 6px;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid #f3f4f6;
background: #f9fafb;
border-radius: 8px 8px 0 0;
}
.node-head .type {
background: #eef2ff;
color: #3730a3;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
}
.node-head .name {
color: #374151;
font-size: 12px;
}
.node-body {
padding: 8px;
color: #6b7280;
font-size: 12px;
min-height: 20px;
}
.node-actions {
display: flex;
gap: 6px;
padding: 8px;
border-top: 1px solid #f3f4f6;
}
.mini {
font-size: 12px;
padding: 4px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
}
.mini.danger {
background: #fee2e2;
border-color: #fecaca;
}
</style>
@@ -0,0 +1,80 @@
<template>
<div class="kve">
<div v-for="(item, i) in rows" :key="i" class="kve-row">
<input class="kve-key" v-model="item.k" placeholder="变量名" />
<input class="kve-val" v-model="item.v" placeholder="结果路径(如 data.items[0].id" />
<button class="mini" @click="move(i, -1)" :disabled="i === 0"></button>
<button class="mini" @click="move(i, 1)" :disabled="i === rows.length - 1"></button>
<button class="mini danger" @click="remove(i)"></button>
</div>
<button class="mini" @click="add">添加映射</button>
</div>
</template>
<script lang="ts" setup>
import { watch, reactive } from 'vue';
const props = defineProps<{ modelValue: Record<string, string> | undefined }>();
const emit = defineEmits(['update:modelValue']);
const rows = reactive<Array<{ k: string; v: string }>>([]);
function syncFromModel() {
rows.splice(0, rows.length);
const obj = props.modelValue || {};
for (const [k, v] of Object.entries(obj)) rows.push({ k, v: String(v) });
}
function syncToModel() {
const out: Record<string, string> = {};
for (const r of rows) if (r.k) out[r.k] = r.v || '';
emit('update:modelValue', out);
}
watch(() => props.modelValue, syncFromModel, { immediate: true, deep: true });
watch(rows, syncToModel, { deep: true });
function add() {
rows.push({ k: '', v: '' });
}
function remove(i: number) {
rows.splice(i, 1);
}
function move(i: number, d: number) {
const j = i + d;
if (j < 0 || j >= rows.length) return;
const t = rows[i];
rows[i] = rows[j];
rows[j] = t;
}
</script>
<style scoped>
.kve {
display: flex;
flex-direction: column;
gap: 6px;
}
.kve-row {
display: grid;
grid-template-columns: 160px 1fr auto auto auto;
gap: 6px;
align-items: center;
}
.kve-key,
.kve-val {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px;
}
.mini {
font-size: 12px;
padding: 4px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
}
.mini.danger {
background: #fee2e2;
border-color: #fecaca;
}
</style>
@@ -0,0 +1,442 @@
<template>
<aside class="panel">
<div v-if="node">
<div class="panel-title">属性面板</div>
<div class="row">
<label>节点名</label>
<input v-model="node.name" />
</div>
<div class="row">
<label>禁用</label>
<input type="checkbox" v-model="node.disabled" />
</div>
<hr />
<template v-if="node.type === 'click' || node.type === 'fill'">
<div class="sub-title">选择器候选按优先级</div>
<div class="cands" data-field="target.candidates">
<div class="cand" v-for="(c, i) in node.config.target.candidates" :key="i">
<select v-model="c.type">
<option value="css">css</option>
<option value="attr">attr</option>
<option value="aria">aria</option>
<option value="text">text</option>
<option value="xpath">xpath</option>
</select>
<input v-model="c.value" />
<button
class="mini"
@click="swapCand(node.config.target.candidates, i, i - 1)"
:disabled="i === 0"
></button
>
<button
class="mini"
@click="swapCand(node.config.target.candidates, i, i + 1)"
:disabled="i === node.config.target.candidates.length - 1"
></button
>
<button class="mini danger" @click="node.config.target.candidates.splice(i, 1)"
></button
>
</div>
<button
class="mini"
@click="node.config.target.candidates.push({ type: 'css', value: '' })"
>添加候选</button
>
</div>
</template>
<template v-if="node.type === 'fill'">
<div class="row" data-field="fill.value">
<label>输入值</label>
<input v-model="node.config.value" placeholder="支持 {var} 格式" />
</div>
</template>
<template v-if="node.type === 'key'">
<div class="row">
<label>按键序列</label>
<input v-model="node.config.keys" placeholder="例如: Backspace Enter 或 cmd+a" />
</div>
</template>
<template v-if="node.type === 'delay'">
<div class="row">
<label>延迟(ms)</label>
<input type="number" v-model.number="node.config.ms" min="0" />
</div>
</template>
<template v-if="node.type === 'http'">
<div class="row">
<label>Method</label>
<select v-model="node.config.method">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>PATCH</option>
<option>DELETE</option>
</select>
</div>
<div class="row" :class="{ invalid: !node.config.url }" data-field="http.url">
<label>URL</label>
<input v-model="node.config.url" />
</div>
<div class="row">
<label>Headers(JSON)</label>
<textarea v-model="headersJson"></textarea>
</div>
<div class="row">
<label>Body(JSON)</label>
<textarea v-model="bodyJson"></textarea>
</div>
<div class="row" data-field="http.saveAs">
<label>保存为</label>
<input v-model="node.config.saveAs" placeholder="变量名" />
</div>
<div class="section" data-field="http.assign">
<div class="sub-title">映射结果字段到变量</div>
<KeyValueEditor v-model="node.config.assign" />
</div>
</template>
<template v-if="node.type === 'extract'">
<div class="row" data-field="extract.selector">
<label>选择器</label>
<input v-model="node.config.selector" placeholder="CSS 选择器" />
</div>
<div class="row">
<label>属性/文本</label>
<input v-model="node.config.attr" placeholder="attr 名称 或 text/textContent" />
</div>
<div class="row">
<label>自定义JS</label>
<textarea v-model="node.config.js" placeholder="返回提取值的 JS 代码" rows="4"></textarea>
</div>
<div class="row" :class="{ invalid: !node.config.saveAs }" data-field="extract.saveAs">
<label>保存为</label>
<input v-model="node.config.saveAs" placeholder="变量名" />
</div>
<div v-if="extractErrors.length" class="errors">
<div v-for="e in extractErrors" :key="e" class="error"> {{ e }}</div>
</div>
</template>
<template v-if="node.type === 'openTab'">
<div class="row" data-field="openTab.url">
<label>URL</label>
<input v-model="node.config.url" />
</div>
<div class="row">
<label class="chk"><input type="checkbox" v-model="node.config.newWindow" />新窗口</label>
</div>
</template>
<template v-if="node.type === 'switchTab'">
<div class="row">
<label>TabId</label>
<input type="number" v-model.number="node.config.tabId" />
</div>
<div class="row">
<label>URL包含</label>
<input v-model="node.config.urlContains" />
</div>
<div class="row">
<label>标题包含</label>
<input v-model="node.config.titleContains" />
</div>
<div v-if="switchTabError" class="errors"
><div class="error"> 需填写 tabId URL/标题包含</div></div
>
</template>
<template v-if="node.type === 'closeTab'">
<div class="row">
<label>URL</label>
<input v-model="node.config.url" placeholder="可留空以关闭当前标签页" />
</div>
</template>
<template v-if="node.type === 'wait'">
<div class="sub-title">等待条件</div>
<div class="row">
<label>JSON</label>
<textarea v-model="waitJson" rows="5"></textarea>
</div>
</template>
<template v-if="node.type === 'assert'">
<div class="sub-title">断言</div>
<div class="row">
<label>JSON</label>
<textarea v-model="assertJson" rows="5"></textarea>
</div>
</template>
<template v-if="node.type === 'navigate'">
<div class="row" data-field="navigate.url">
<label>URL</label>
<input v-model="node.config.url" />
</div>
</template>
<template v-if="node.type === 'script'">
<div class="row">
<label>world</label>
<select v-model="node.config.world">
<option value="ISOLATED">ISOLATED</option>
<option value="MAIN">MAIN</option>
</select>
</div>
<div class="row" data-field="script.code">
<label>代码</label>
<textarea v-model="node.config.code" rows="6"></textarea>
</div>
<div class="row" data-field="script.saveAs">
<label>保存为</label>
<input v-model="node.config.saveAs" placeholder="变量名" />
</div>
<div class="row" data-field="script.assign">
<label>映射(JSON)</label>
<textarea
v-model="scriptAssignJson"
rows="4"
placeholder='{"var":"path.in.result"}'
></textarea>
</div>
</template>
</div>
<div v-else class="empty">选择一个节点以编辑属性</div>
<div v-if="node" class="errors" style="margin-top: 8px">
<div v-for="e in nodeErrors" :key="e" class="error"> {{ e }}</div>
</div>
</aside>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
import { validateNode } from '../model/validation';
import KeyValueEditor from './KeyValueEditor.vue';
const props = defineProps<{ node: NodeBase | null; highlightField?: string | null }>();
const waitJson = computed({
get() {
const n = props.node;
if (!n || n.type !== 'wait') return '';
try {
return JSON.stringify(n.config?.condition || {}, null, 2);
} catch {
return '';
}
},
set(v: string) {
const n = props.node;
if (!n || n.type !== 'wait') return;
try {
n.config = { ...(n.config || {}), condition: JSON.parse(v || '{}') };
} catch {}
},
});
const assertJson = computed({
get() {
const n = props.node;
if (!n || n.type !== 'assert') return '';
try {
return JSON.stringify(n.config?.assert || {}, null, 2);
} catch {
return '';
}
},
set(v: string) {
const n = props.node;
if (!n || n.type !== 'assert') return;
try {
n.config = { ...(n.config || {}), assert: JSON.parse(v || '{}') };
} catch {}
},
});
const nodeErrors = computed(() => (props.node ? validateNode(props.node) : []));
const extractErrors = computed(() => {
const n = props.node;
if (!n || n.type !== 'extract') return [] as string[];
const errs: string[] = [];
if (!n.config?.saveAs) errs.push('需填写保存变量名');
if (!n.config?.selector && !n.config?.js) errs.push('需提供 selector 或 js');
return errs;
});
const switchTabError = computed(() => {
const n = props.node;
if (!n || n.type !== 'switchTab') return false;
return !(n.config?.tabId || n.config?.urlContains || n.config?.titleContains);
});
function swapCand(arr: any[], i: number, j: number) {
if (j < 0 || j >= arr.length) return;
const t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
// http json helpers
const headersJson = computed({
get() {
const n = props.node;
if (!n || n.type !== 'http') return '';
try {
return JSON.stringify(n.config?.headers || {}, null, 2);
} catch {
return '';
}
},
set(v: string) {
const n = props.node;
if (!n || n.type !== 'http') return;
try {
n.config.headers = JSON.parse(v || '{}');
} catch {}
},
});
const bodyJson = computed({
get() {
const n = props.node;
if (!n || n.type !== 'http') return '';
try {
return JSON.stringify(n.config?.body ?? null, null, 2);
} catch {
return '';
}
},
set(v: string) {
const n = props.node;
if (!n || n.type !== 'http') return;
try {
n.config.body = v ? JSON.parse(v) : null;
} catch {}
},
});
// script assign json helper
const scriptAssignJson = computed({
get() {
const n = props.node;
if (!n || n.type !== 'script') return '';
try {
return JSON.stringify(n.config?.assign || {}, null, 2);
} catch {
return '';
}
},
set(v: string) {
const n = props.node;
if (!n || n.type !== 'script') return;
try {
n.config.assign = JSON.parse(v || '{}');
} catch {}
},
});
// 高亮并滚动到指定字段
watch(
() => props.highlightField,
(field) => {
if (!field) return;
try {
const root = (document?.querySelector?.('.panel') as HTMLElement) || null;
const esc =
(globalThis as any).CSS && typeof (globalThis as any).CSS.escape === 'function'
? (globalThis as any).CSS.escape(field)
: String(field).replace(/["\\]/g, '\\$&');
const el = (root || document).querySelector(`[data-field="${esc}"]`) as HTMLElement | null;
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
if (el) {
el.classList.add('hl');
setTimeout(() => el.classList.remove('hl'), 1200);
}
} catch {}
},
);
</script>
<style scoped>
.panel {
border-left: 1px solid #e5e7eb;
padding: 10px;
overflow: auto;
}
.panel-title {
font-weight: 600;
margin-bottom: 8px;
}
.sub-title {
font-weight: 600;
margin: 6px 0;
}
.row {
display: flex;
gap: 8px;
align-items: center;
margin: 6px 0;
}
.row > label {
width: 72px;
color: #374151;
}
.row > input,
.row > textarea,
.row > select {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px;
}
.row.invalid > input,
.row.invalid > textarea,
.row.invalid > select {
border-color: #ef4444;
background: #fef2f2;
}
.cands {
display: flex;
flex-direction: column;
gap: 6px;
}
.cand {
display: grid;
grid-template-columns: 120px 1fr auto auto auto;
gap: 6px;
align-items: center;
}
.mini {
font-size: 12px;
padding: 4px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
}
.mini.danger {
background: #fee2e2;
border-color: #fecaca;
}
.empty {
color: #6b7280;
padding: 12px;
}
.errors {
color: #ef4444;
font-size: 12px;
}
.errors .error {
margin: 2px 0;
}
.panel :where([data-field].hl) {
outline: 2px solid #f59e0b;
background: #fffbeb;
transition: outline-color 0.3s ease;
}
</style>
@@ -0,0 +1,70 @@
<template>
<aside class="sidebar">
<div class="palette">
<div class="palette-title">节点库</div>
<div class="palette-list">
<button v-for="t in paletteTypes" :key="t" class="node-btn" @click="$emit('addNode', t)">
{{ t }}
</button>
</div>
</div>
<div class="meta">
<div class="row">
<label>名称</label>
<input v-model="flow.name" />
</div>
<div class="row">
<label>描述</label>
<input v-model="flow.description" />
</div>
</div>
</aside>
</template>
<script lang="ts" setup>
import type { Flow as FlowV2, NodeBase } from '@/entrypoints/background/record-replay/types';
defineProps<{ flow: FlowV2; paletteTypes: NodeBase['type'][] }>();
defineEmits<{ (e: 'addNode', t: NodeBase['type']): void }>();
</script>
<style scoped>
.sidebar {
border-right: 1px solid #e5e7eb;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.palette-title {
font-weight: 600;
margin-bottom: 6px;
}
.palette-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.node-btn {
border: 1px dashed #d1d5db;
background: #f9fafb;
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
}
.meta .row {
display: flex;
gap: 6px;
align-items: center;
}
.meta .row > label {
width: 72px;
color: #374151;
}
.meta input {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px;
}
</style>
@@ -0,0 +1,182 @@
import type {
Flow as FlowV2,
NodeBase,
Edge as EdgeV2,
} from '@/entrypoints/background/record-replay/types';
export function newId(prefix: string) {
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`;
}
export type NodeType = NodeBase['type'];
export function defaultConfigFor(t: NodeType): any {
if (t === 'click' || t === 'fill')
return { target: { candidates: [] }, value: t === 'fill' ? '' : undefined };
if (t === 'navigate') return { url: '' };
if (t === 'wait') return { condition: { text: '', appear: true } };
if (t === 'assert') return { assert: { exists: '' } };
if (t === 'key') return { keys: '' };
if (t === 'delay') return { ms: 1000 };
if (t === 'http') return { method: 'GET', url: '', headers: {}, body: null, saveAs: '' };
if (t === 'extract') return { selector: '', attr: 'text', js: '', saveAs: '' };
if (t === 'openTab') return { url: '', newWindow: false };
if (t === 'switchTab') return { tabId: null, urlContains: '', titleContains: '' };
if (t === 'closeTab') return { tabIds: [], url: '' };
if (t === 'script') return { world: 'ISOLATED', code: '', saveAs: '', assign: {} };
return {};
}
export function stepsToNodes(steps: any[]): NodeBase[] {
const arr: NodeBase[] = [];
steps.forEach((s, i) => {
const id = s.id || newId(String(s.type || 'step'));
const node: NodeBase = {
id,
type: (s.type || 'script') as NodeType,
name: '',
disabled: false,
ui: { x: 200, y: 120 + i * 120 },
config: mapStepToConfig(s),
};
arr.push(node);
});
return arr;
}
export function mapStepToConfig(s: any) {
const t = s.type;
if (t === 'click' || t === 'dblclick')
return { target: s.target || { candidates: [] }, after: s.after, before: s.before };
if (t === 'fill') return { target: s.target || { candidates: [] }, value: s.value || '' };
if (t === 'wait') return { condition: s.condition || { text: '', appear: true } };
if (t === 'assert') return { assert: s.assert || { exists: '' }, failStrategy: s.failStrategy };
if (t === 'navigate') return { url: s.url || '' };
if (t === 'script') return { world: s.world || 'ISOLATED', code: s.code || '' };
return { ...s };
}
export function mapConfigToStep(n: NodeBase) {
const base = { id: n.id, type: n.type } as any;
const c = n.config || {};
if (n.type === 'click' || n.type === 'dblclick')
return { ...base, target: c.target || { candidates: [] }, after: c.after, before: c.before };
if (n.type === 'fill')
return { ...base, target: c.target || { candidates: [] }, value: c.value || '' };
if (n.type === 'key') return { ...base, keys: c.keys || '' };
if (n.type === 'wait') return { ...base, condition: c.condition || { text: '', appear: true } };
if (n.type === 'assert')
return { ...base, assert: c.assert || { exists: '' }, failStrategy: c.failStrategy };
if (n.type === 'navigate') return { ...base, url: c.url || '' };
if (n.type === 'delay')
return {
...base,
type: 'wait',
timeoutMs: Math.max(0, Number(c.ms ?? 1000)),
condition: { navigation: true },
};
if (n.type === 'http')
return {
...base,
type: 'http',
method: c.method || 'GET',
url: c.url || '',
headers: c.headers || {},
body: c.body,
saveAs: c.saveAs || '',
} as any;
if (n.type === 'extract')
return {
...base,
type: 'extract',
selector: c.selector || '',
attr: c.attr || 'text',
js: c.js || '',
saveAs: c.saveAs || '',
} as any;
if (n.type === 'openTab')
return { ...base, type: 'openTab', url: c.url || '', newWindow: !!c.newWindow } as any;
if (n.type === 'switchTab')
return {
...base,
type: 'switchTab',
tabId: c.tabId || undefined,
urlContains: c.urlContains || '',
titleContains: c.titleContains || '',
} as any;
if (n.type === 'closeTab')
return {
...base,
type: 'closeTab',
tabIds: Array.isArray(c.tabIds) ? c.tabIds : undefined,
url: c.url || '',
} as any;
if (n.type === 'script')
return {
...base,
world: c.world || 'ISOLATED',
code: c.code || '',
when: c.when,
saveAs: c.saveAs || '',
assign: c.assign || {},
} as any;
return { ...base };
}
export function topoOrder(nodes: NodeBase[], edges: EdgeV2[]): NodeBase[] {
const id2n = new Map(nodes.map((n) => [n.id, n] as const));
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
for (const e of edges)
if (!e.label || e.label === 'default') indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
const q: string[] = nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);
const out: NodeBase[] = [];
const nexts = new Map<string, string[]>(nodes.map((n) => [n.id, [] as string[]] as const));
for (const e of edges) if (!e.label || e.label === 'default') nexts.get(e.from)!.push(e.to);
while (q.length) {
const id = q.shift()!;
const n = id2n.get(id);
if (!n) continue;
out.push(n);
for (const v of nexts.get(id)!) {
indeg.set(v, (indeg.get(v) || 0) - 1);
if ((indeg.get(v) || 0) === 0) q.push(v);
}
}
if (out.length === nodes.length) return out;
return nodes.slice();
}
export function nodesToSteps(nodes: NodeBase[], edges: EdgeV2[]): any[] {
const order = edges.length ? topoOrder(nodes, edges) : nodes.slice();
return order.map((n) => mapConfigToStep(n));
}
export function autoChainEdges(nodes: NodeBase[]): EdgeV2[] {
const arr: EdgeV2[] = [];
for (let i = 0; i < nodes.length - 1; i++)
arr.push({ id: newId('e'), from: nodes[i].id, to: nodes[i + 1].id, label: 'default' });
return arr;
}
export function summarizeNode(n?: NodeBase | null): string {
if (!n) return '';
if (n.type === 'click' || n.type === 'fill')
return n.config?.target?.candidates?.[0]?.value || '未配置选择器';
if (n.type === 'navigate') return n.config?.url || '';
if (n.type === 'key') return n.config?.keys || '';
if (n.type === 'delay') return `${Number(n.config?.ms || 0)}ms`;
if (n.type === 'http') return `${n.config?.method || 'GET'} ${n.config?.url || ''}`;
if (n.type === 'extract') return `${n.config?.selector || ''} -> ${n.config?.saveAs || ''}`;
if (n.type === 'openTab') return `open ${n.config?.url || ''}`;
if (n.type === 'switchTab')
return `switch ${n.config?.tabId || n.config?.urlContains || n.config?.titleContains || ''}`;
if (n.type === 'closeTab') return `close ${n.config?.url || ''}`;
if (n.type === 'wait') return JSON.stringify(n.config?.condition || {});
if (n.type === 'assert') return JSON.stringify(n.config?.assert || {});
if (n.type === 'script') return (n.config?.code || '').slice(0, 30);
return '';
}
export function cloneFlow(flow: FlowV2): FlowV2 {
return JSON.parse(JSON.stringify(flow));
}
@@ -0,0 +1,86 @@
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
export function validateNode(n: NodeBase): string[] {
const errs: string[] = [];
if (n.disabled) return errs; // 忽略禁用节点
const c: any = n.config || {};
switch (n.type) {
case 'click':
case 'dblclick':
case 'fill': {
const hasCandidate = !!c?.target?.candidates?.length;
if (!hasCandidate) errs.push('缺少目标选择器候选');
if (n.type === 'fill' && (!('value' in c) || c.value === undefined)) errs.push('缺少输入值');
break;
}
case 'wait': {
if (!c?.condition) errs.push('缺少等待条件');
break;
}
case 'assert': {
if (!c?.assert) errs.push('缺少断言条件');
break;
}
case 'navigate': {
if (!c?.url) errs.push('缺少 URL');
break;
}
case 'http': {
if (!c?.url) errs.push('HTTP: 缺少 URL');
if (c?.assign && typeof c.assign === 'object') {
const pathRe = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*$/;
for (const v of Object.values(c.assign)) {
const s = String(v);
if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`);
}
}
break;
}
case 'extract': {
if (!c?.saveAs) errs.push('Extract: 需填写保存变量名');
if (!c?.selector && !c?.js) errs.push('Extract: 需提供 selector 或 js');
break;
}
case 'switchTab': {
if (!c?.tabId && !c?.urlContains && !c?.titleContains)
errs.push('SwitchTab: 需提供 tabId 或 URL/标题包含');
break;
}
case 'closeTab': {
// 允许空(关闭当前标签页),不强制
break;
}
case 'script': {
// 若配置了 saveAs/assign,应提供 code
const hasAssign = c?.assign && Object.keys(c.assign).length > 0;
if ((c?.saveAs || hasAssign) && !String(c?.code || '').trim())
errs.push('Script: 配置了保存/映射但缺少代码');
if (hasAssign) {
const pathRe = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+|\[\d+\])*$/;
for (const v of Object.values(c.assign || {})) {
const s = String(v);
if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`);
}
}
break;
}
}
return errs;
}
export function validateFlow(nodes: NodeBase[]): {
totalErrors: number;
nodeErrors: Record<string, string[]>;
} {
const nodeErrors: Record<string, string[]> = {};
let totalErrors = 0;
for (const n of nodes) {
const e = validateNode(n);
if (e.length) {
nodeErrors[n.id] = e;
totalErrors += e.length;
}
}
return { totalErrors, nodeErrors };
}
@@ -0,0 +1,251 @@
import { reactive, ref } from 'vue';
import type {
Flow as FlowV2,
NodeBase,
Edge as EdgeV2,
} from '@/entrypoints/background/record-replay/types';
import {
autoChainEdges,
cloneFlow,
defaultConfigFor,
newId,
nodesToSteps,
stepsToNodes,
summarizeNode,
topoOrder,
} from '../model/transforms';
export function useBuilderStore(initial?: FlowV2 | null) {
const flowLocal = reactive<FlowV2>({ id: '', name: '', version: 1, steps: [], variables: [] });
const nodes = reactive<NodeBase[]>([]);
const edges = reactive<EdgeV2[]>([]);
const activeNodeId = ref<string | null>(null);
const pendingFrom = ref<string | null>(null);
const paletteTypes = [
'click',
'fill',
'key',
'wait',
'assert',
'navigate',
'script',
'delay',
'http',
'extract',
'openTab',
'switchTab',
'closeTab',
] as NodeBase['type'][];
// --- history (undo/redo) ---
type Snapshot = {
flow: Pick<FlowV2, 'name' | 'description'>;
nodes: NodeBase[];
edges: EdgeV2[];
};
const HISTORY_MAX = 50;
const past: Snapshot[] = [];
const future: Snapshot[] = [];
function takeSnapshot(): Snapshot {
return {
flow: { name: flowLocal.name, description: flowLocal.description } as any,
nodes: JSON.parse(JSON.stringify(nodes)),
edges: JSON.parse(JSON.stringify(edges)),
};
}
function applySnapshot(s: Snapshot) {
flowLocal.name = (s.flow as any).name || '';
(flowLocal as any).description = (s.flow as any).description || '';
nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify(s.nodes)));
edges.splice(0, edges.length, ...JSON.parse(JSON.stringify(s.edges)));
}
function recordChange() {
past.push(takeSnapshot());
// clear redo stack on new change
future.length = 0;
if (past.length > HISTORY_MAX) past.splice(0, past.length - HISTORY_MAX);
}
function undo() {
if (past.length === 0) return;
const current = takeSnapshot();
const prev = past.pop()!;
future.push(current);
applySnapshot(prev);
}
function redo() {
if (future.length === 0) return;
const current = takeSnapshot();
const next = future.pop()!;
past.push(current);
applySnapshot(next);
}
function layoutIfNeeded() {
const startX = 120,
startY = 80,
gapY = 120;
nodes.forEach((n, i) => {
if (!n.ui || isNaN(n.ui.x) || isNaN(n.ui.y)) n.ui = { x: startX, y: startY + i * gapY };
});
}
function initFromFlow(flow: FlowV2) {
const deep = cloneFlow(flow);
Object.assign(flowLocal, deep);
nodes.splice(
0,
nodes.length,
...(Array.isArray(deep.nodes) && deep.nodes.length
? deep.nodes
: stepsToNodes(deep.steps || [])),
);
edges.splice(
0,
edges.length,
...(Array.isArray(deep.edges) && deep.edges.length ? deep.edges : autoChainEdges(nodes)),
);
layoutIfNeeded();
activeNodeId.value = nodes[0]?.id || null;
// reset history
past.length = 0;
future.length = 0;
past.push(takeSnapshot());
}
function selectNode(id: string) {
if (pendingFrom.value && pendingFrom.value !== id) {
onConnect(pendingFrom.value, id);
pendingFrom.value = null;
}
activeNodeId.value = id;
}
function addNode(t: NodeBase['type']) {
const id = newId(t);
const n: NodeBase = {
id,
type: t,
name: '',
disabled: false,
config: defaultConfigFor(t),
ui: { x: 200 + nodes.length * 24, y: 120 + nodes.length * 96 },
};
nodes.push(n);
if (nodes.length > 1) {
const prev = nodes[nodes.length - 2];
edges.push({ id: newId('e'), from: prev.id, to: id, label: 'default' });
}
activeNodeId.value = id;
recordChange();
}
function duplicateNode(id: string) {
const src = nodes.find((n) => n.id === id);
if (!src) return;
const cp: NodeBase = JSON.parse(JSON.stringify(src));
cp.id = newId(src.type);
cp.name = src.name ? `${src.name} Copy` : '';
const baseX = cp.ui && typeof cp.ui.x === 'number' ? cp.ui.x : 200;
const baseY = cp.ui && typeof cp.ui.y === 'number' ? cp.ui.y : 120;
cp.ui = { x: baseX + 40, y: baseY + 40 };
nodes.push(cp);
activeNodeId.value = cp.id;
recordChange();
}
function removeNode(id: string) {
const idx = nodes.findIndex((n) => n.id === id);
if (idx < 0) return;
nodes.splice(idx, 1);
for (let i = edges.length - 1; i >= 0; i--) {
const e = edges[i];
if (e.from === id || e.to === id) edges.splice(i, 1);
}
activeNodeId.value = nodes[Math.min(idx, nodes.length - 1)]?.id || null;
recordChange();
}
function setNodePosition(id: string, x: number, y: number) {
const n = nodes.find((n) => n.id === id);
if (!n) return;
n.ui = { x: Math.round(x), y: Math.round(y) };
// 不计入历史栈,避免频繁记录;由用户触发操作(连接/新增/删除等)记录。
}
function connectFrom(id: string) {
pendingFrom.value = id;
}
function onConnect(sourceId: string, targetId: string) {
// 单一默认出边:删除同源 default 出边
for (let i = edges.length - 1; i >= 0; i--) {
const e = edges[i];
if (e.from === sourceId && (!e.label || e.label === 'default')) edges.splice(i, 1);
}
edges.push({ id: newId('e'), from: sourceId, to: targetId, label: 'default' });
recordChange();
}
function importFromSteps() {
const arr = stepsToNodes(flowLocal.steps || []);
nodes.splice(0, nodes.length, ...arr);
edges.splice(0, edges.length, ...autoChainEdges(arr));
layoutIfNeeded();
recordChange();
}
function exportSteps() {
return nodesToSteps(nodes, edges);
}
function summarize(id?: string) {
const n = nodes.find((x) => x.id === id);
return summarizeNode(n || null);
}
// 自动排版:根据拓扑顺序纵向排列,列宽 300、行高 120;若存在分叉,简单按顺序换行
function layoutAuto() {
const order = topoOrder(nodes, edges);
const startX = 120,
startY = 80,
stepY = 120,
stepX = 300,
maxPerCol = Math.max(6, Math.ceil(order.length / 3));
let col = 0,
row = 0;
for (const n of order) {
n.ui = { x: startX + col * stepX, y: startY + row * stepY } as any;
row++;
if (row >= maxPerCol) {
row = 0;
col++;
}
}
recordChange();
}
if (initial) initFromFlow(initial);
return {
flowLocal,
nodes,
edges,
activeNodeId,
pendingFrom,
paletteTypes,
undo,
redo,
initFromFlow,
selectNode,
addNode,
duplicateNode,
removeNode,
setNodePosition,
connectFrom,
onConnect,
importFromSteps,
exportSteps,
summarize,
layoutAuto,
};
}
@@ -537,23 +537,61 @@
const sel = String(request.selector || '').trim();
let el = null;
if (useText && textQuery) {
const all = Array.from(document.querySelectorAll('body *'));
for (const node of all) {
const normalize = (s) =>
String(s || '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const query = normalize(textQuery);
const bigrams = (s) => {
const arr = [];
for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2));
return arr;
};
const dice = (a, b) => {
if (!a || !b) return 0;
const A = bigrams(a);
const B = bigrams(b);
if (A.length === 0 || B.length === 0) return 0;
let inter = 0;
const map = new Map();
for (const t of A) map.set(t, (map.get(t) || 0) + 1);
for (const t of B) {
const c = map.get(t) || 0;
if (c > 0) {
inter++;
map.set(t, c - 1);
}
}
return (2 * inter) / (A.length + B.length);
};
let best = { el: null, score: 0 };
const walker = document.createTreeWalker(
document.body || document.documentElement,
NodeFilter.SHOW_ELEMENT,
);
let visited = 0;
while (walker.nextNode()) {
const node = /** @type {Element} */ (walker.currentNode);
try {
const cs = window.getComputedStyle(node);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0')
continue;
const rect = /** @type {HTMLElement} */ (node).getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const txt = (node.textContent || '').trim();
if (txt && txt.includes(textQuery)) {
const txt = normalize(node.textContent || '');
if (!txt) continue;
// quick path: substring contains
if (txt.includes(query)) {
el = node;
break;
}
} catch (_) {
/* ignore */
}
const sc = dice(txt, query);
if (sc > best.score) best = { el: node, score: sc };
} catch {}
if (++visited > 5000) break;
}
if (!el && best.el && best.score >= 0.6) el = best.el;
} else {
if (!sel) {
sendResponse({ success: false, error: 'selector is required' });
@@ -625,21 +663,136 @@
}
if (request && request.action === 'collectVariables') {
try {
const vars = Array.isArray(request.variables) ? request.variables : [];
const values = {};
for (const v of vars) {
const key = String(v && v.key ? v.key : '');
if (!key) continue;
const label = v.label || key;
const def = v.default || '';
const promptText = `请输入参数 ${label} (${key})`;
// Note: prompt in page context; in some sites may be blocked by CSP
let val = window.prompt(promptText, def);
if (typeof val !== 'string') val = def;
values[key] = val;
let vars = Array.isArray(request.variables) ? request.variables : [];
if ((!vars || vars.length === 0) && request.payload) {
try {
const p = JSON.parse(String(request.payload || '{}'));
if (Array.isArray(p.variables)) vars = p.variables;
} catch {}
}
sendResponse({ success: true, values });
return true;
const useOverlay = request.useOverlay !== false; // default true
const values = {};
if (!useOverlay) {
for (const v of vars) {
const key = String(v && v.key ? v.key : '');
if (!key) continue;
const label = v.label || key;
const def = v.default || '';
const promptText = `请输入参数 ${label} (${key})`;
let val = window.prompt(promptText, def);
if (typeof val !== 'string') val = def;
values[key] = val;
}
sendResponse({ success: true, values });
return true;
}
// Build overlay form
const hostId = '__rr_var_overlay__';
let host = document.getElementById(hostId);
if (host) host.remove();
host = document.createElement('div');
host.id = hostId;
Object.assign(host.style, {
position: 'fixed',
inset: '0',
background: 'rgba(0,0,0,0.35)',
zIndex: 2147483646,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#fff',
borderRadius: '8px',
width: 'min(520px, 96vw)',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: '0 8px 24px rgba(0,0,0,0.2)',
padding: '16px',
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
});
const title = document.createElement('div');
title.textContent = '请输入回放参数';
Object.assign(title.style, { fontSize: '16px', fontWeight: '600', marginBottom: '12px' });
const form = document.createElement('form');
for (const v of vars) {
const row = document.createElement('div');
Object.assign(row.style, { marginBottom: '10px' });
const label = document.createElement('label');
label.textContent = `${v.label || v.key}${v.sensitive ? ' (敏感)' : ''}`;
Object.assign(label.style, {
display: 'block',
marginBottom: '6px',
fontWeight: '500',
});
const input = document.createElement('input');
input.type = v.sensitive ? 'password' : 'text';
input.name = String(v.key);
input.value = String(v.default || '');
Object.assign(input.style, {
width: '100%',
boxSizing: 'border-box',
padding: '8px 10px',
border: '1px solid #d0d7de',
borderRadius: '6px',
outline: 'none',
});
row.appendChild(label);
row.appendChild(input);
form.appendChild(row);
}
const actions = document.createElement('div');
Object.assign(actions.style, { display: 'flex', gap: '8px', marginTop: '12px' });
const ok = document.createElement('button');
ok.type = 'submit';
ok.textContent = '确定';
Object.assign(ok.style, {
background: '#0969da',
color: '#fff',
border: 'none',
padding: '8px 16px',
borderRadius: '6px',
cursor: 'pointer',
});
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = '取消';
Object.assign(cancel.style, {
background: '#f3f4f6',
color: '#111',
border: '1px solid #d0d7de',
padding: '8px 16px',
borderRadius: '6px',
cursor: 'pointer',
});
actions.appendChild(ok);
actions.appendChild(cancel);
panel.appendChild(title);
panel.appendChild(form);
panel.appendChild(actions);
host.appendChild(panel);
document.documentElement.appendChild(host);
const cleanup = () => {
try {
host.remove();
} catch {}
};
cancel.onclick = () => {
cleanup();
sendResponse({ success: false, cancelled: true });
};
form.onsubmit = (e) => {
e.preventDefault();
for (const v of vars) {
const el = form.querySelector(`input[name="${CSS.escape(String(v.key))}"]`);
if (el) values[v.key] = /** @type {HTMLInputElement} */ (el).value;
}
cleanup();
sendResponse({ success: true, values });
};
return true; // async
} catch (e) {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
return true;
+117 -24
View File
@@ -11,6 +11,9 @@
const sampledDrag = [];
let isRecording = false;
let isPaused = false;
let hideInputValues = false;
let highlightBox = null;
let pendingFlow = {
id: `flow_${Date.now()}`,
name: '未命名录制',
@@ -104,7 +107,7 @@
}
function onClick(e) {
if (!isRecording) return;
if (!isRecording || isPaused) return;
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
const target = buildTarget(el);
@@ -112,14 +115,15 @@
}
function onInput(e) {
if (!isRecording) return;
if (!isRecording || isPaused) return;
const el =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
? e.target
: null;
if (!el) return;
const target = buildTarget(el);
const isSensitive = SENSITIVE_INPUT_TYPES.has((el.getAttribute('type') || '').toLowerCase());
const isSensitive =
hideInputValues || SENSITIVE_INPUT_TYPES.has((el.getAttribute('type') || '').toLowerCase());
let value = el.value || '';
if (isSensitive) {
const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`;
@@ -130,7 +134,7 @@
}
function onKeydown(e) {
if (!isRecording) return;
if (!isRecording || isPaused) return;
// modifier+key or Enter/Backspace etc
const mods = [];
if (e.ctrlKey) mods.push('ctrl');
@@ -144,22 +148,11 @@
pushStep({ type: 'key', keys, screenshotOnFail: false });
}
function onKeyup(e) {
if (!isRecording) return;
const mods = [];
if (e.ctrlKey) mods.push('ctrl');
if (e.metaKey) mods.push('cmd');
if (e.altKey) mods.push('alt');
if (e.shiftKey) mods.push('shift');
let keyToken = e.key || '';
keyToken = keyToken.length === 1 ? keyToken.toLowerCase() : keyToken.toLowerCase();
const keys = mods.length ? `${mods.join('+')}+${keyToken}` : keyToken;
pushStep({ type: 'key', keys, screenshotOnFail: false });
}
// keyup 不再记录,避免重复噪声
// Composition IME events (record markers for analysis; playback is no-op via script step)
function onCompositionStart() {
if (!isRecording) return;
if (!isRecording || isPaused) return;
pushStep({
type: 'script',
world: 'ISOLATED',
@@ -169,7 +162,7 @@
});
}
function onCompositionEnd() {
if (!isRecording) return;
if (!isRecording || isPaused) return;
pushStep({
type: 'script',
world: 'ISOLATED',
@@ -181,7 +174,7 @@
let lastScrollAt = 0;
function onScroll(e) {
if (!isRecording) return;
if (!isRecording || isPaused) return;
const nowTs = now();
if (nowTs - lastScrollAt < THROTTLE_SCROLL_MS) return;
lastScrollAt = nowTs;
@@ -227,7 +220,7 @@
document.addEventListener('change', onInput, true);
document.addEventListener('input', onInput, true);
document.addEventListener('keydown', onKeydown, true);
document.addEventListener('keyup', onKeyup, true);
// document.addEventListener('keyup', onKeyup, true);
document.addEventListener('compositionstart', onCompositionStart, true);
document.addEventListener('compositionend', onCompositionEnd, true);
window.addEventListener('scroll', onScroll, { passive: true });
@@ -241,7 +234,7 @@
document.removeEventListener('change', onInput, true);
document.removeEventListener('input', onInput, true);
document.removeEventListener('keydown', onKeydown, true);
document.removeEventListener('keyup', onKeyup, true);
// document.removeEventListener('keyup', onKeyup, true);
document.removeEventListener('compositionstart', onCompositionStart, true);
document.removeEventListener('compositionend', onCompositionEnd, true);
window.removeEventListener('scroll', onScroll, { passive: true });
@@ -269,7 +262,9 @@
function start(flowMeta) {
reset(flowMeta || {});
isRecording = true;
isPaused = false;
attach();
ensureOverlay();
chrome.runtime.sendMessage({
type: 'rr_recorder_event',
payload: { kind: 'start', flow: pendingFlow },
@@ -279,6 +274,7 @@
function stop() {
isRecording = false;
detach();
removeOverlay();
chrome.runtime.sendMessage({
type: 'rr_recorder_event',
payload: { kind: 'stop', flow: pendingFlow },
@@ -286,6 +282,101 @@
return pendingFlow;
}
function pause() {
isPaused = true;
updateOverlayStatus();
}
function resume() {
isRecording = true;
isPaused = false;
attach();
ensureOverlay();
updateOverlayStatus();
}
function ensureOverlay() {
let root = document.getElementById('__rr_rec_overlay');
if (root) return;
root = document.createElement('div');
root.id = '__rr_rec_overlay';
Object.assign(root.style, {
position: 'fixed',
top: '10px',
right: '10px',
zIndex: 2147483646,
fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
});
root.innerHTML = `
<div id="__rr_rec_panel" style="background: rgba(220,38,38,0.95); color: #fff; padding:8px 10px; border-radius:8px; display:flex; align-items:center; gap:8px; box-shadow:0 4px 16px rgba(0,0,0,0.2);">
<span id="__rr_badge" style="font-weight:600;">录制中</span>
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
<input id="__rr_hide_values" type="checkbox" style="vertical-align:middle;" />隐藏输入值
</label>
<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);
const btnPause = root.querySelector('#__rr_pause');
const btnStop = root.querySelector('#__rr_stop');
const hideChk = root.querySelector('#__rr_hide_values');
hideChk.checked = hideInputValues;
hideChk.addEventListener('change', () => (hideInputValues = hideChk.checked));
btnPause.addEventListener('click', () => {
if (!isPaused) pause();
else resume();
});
btnStop.addEventListener('click', () => {
stop();
});
updateOverlayStatus();
// element highlight box
highlightBox = document.createElement('div');
Object.assign(highlightBox.style, {
position: 'fixed',
border: '2px solid rgba(59,130,246,0.9)',
borderRadius: '4px',
background: 'rgba(59,130,246,0.15)',
pointerEvents: 'none',
zIndex: 2147483645,
});
document.documentElement.appendChild(highlightBox);
document.addEventListener('mousemove', onHoverMove, true);
}
function removeOverlay() {
try {
const root = document.getElementById('__rr_rec_overlay');
if (root) root.remove();
if (highlightBox) highlightBox.remove();
document.removeEventListener('mousemove', onHoverMove, true);
} catch {}
}
function updateOverlayStatus() {
const badge = document.getElementById('__rr_badge');
const pauseBtn = document.getElementById('__rr_pause');
if (badge) badge.textContent = isPaused ? '已暂停' : '录制中';
if (pauseBtn) pauseBtn.textContent = isPaused ? '继续' : '暂停';
}
function onHoverMove(e) {
if (!highlightBox || !isRecording || isPaused) return;
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
try {
const r = el.getBoundingClientRect();
Object.assign(highlightBox.style, {
left: `${Math.round(r.left)}px`,
top: `${Math.round(r.top)}px`,
width: `${Math.round(Math.max(0, r.width))}px`,
height: `${Math.round(Math.max(0, r.height))}px`,
display: r.width > 0 && r.height > 0 ? 'block' : 'none',
});
} catch {}
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (request && request.action === 'rr_recorder_control') {
@@ -294,10 +385,12 @@
start(request.meta || {});
sendResponse({ success: true });
return true;
} else if (cmd === 'pause') {
pause();
sendResponse({ success: true });
return true;
} else if (cmd === 'resume') {
// Attach without resetting flow or sending start event
isRecording = true;
attach();
resume();
sendResponse({ success: true });
return true;
} else if (cmd === 'stop') {
@@ -145,6 +145,58 @@
});
}
function waitForSelector({ selector, visible = true, timeout = 5000 }) {
return new Promise((resolve) => {
const start = Date.now();
let resolved = false;
const isMatch = () => {
try {
const el = document.querySelector(selector);
if (!el) return null;
if (!visible) return el;
return isVisible(el) ? el : null;
} catch {
return null;
}
};
const done = (result) => {
if (resolved) return;
resolved = true;
obs && obs.disconnect();
clearTimeout(timer);
resolve(result);
};
const check = () => {
const el = isMatch();
if (el) {
const ref = ensureRefForElement(el);
const center = centerOf(el);
done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });
}
};
const obs = new MutationObserver(check);
try {
obs.observe(document.documentElement || document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
} catch {}
// initial check
check();
const timer = setTimeout(
() => done({ success: false, reason: 'timeout', tookMs: Date.now() - start }),
Math.max(0, timeout),
);
});
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (request && request.action === 'wait_helper_ping') {
@@ -162,6 +214,17 @@
waitFor({ text, appear, timeout }).then((res) => sendResponse(res));
return true; // async
}
if (request && request.action === 'waitForSelector') {
const selector = String(request.selector || '').trim();
const visible = request.visible !== false; // default true
const timeout = Number(request.timeout || 5000);
if (!selector) {
sendResponse({ success: false, error: 'selector is required' });
return true;
}
waitForSelector({ selector, visible, timeout }).then((res) => sendResponse(res));
return true; // async
}
} catch (e) {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
return true;
+4
View File
@@ -21,6 +21,10 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.47.0",
"@vue-flow/minimap": "^1.5.4",
"@xenova/transformers": "^2.17.2",
"chrome-mcp-shared": "workspace:*",
"date-fns": "^4.1.0",
+199
View File
@@ -0,0 +1,199 @@
# 模块 C — 编排画布(Builder)技术设计(builder.design.md
版本:v1.0(基于 builder.prd.md
目标读者:前端架构/扩展工程/MCP 工具维护者
## 1. 架构概览
- 编辑端(Popup 内)
- 画布 UIVue + 画布库(建议 VueFlow),包含节点库、画布、迷你地图、属性面板、搜索/对齐、撤销/重做、自动保存。
- 状态管理:本地 store(可用组合式 API/Pinia),维护 `nodes/edges/variables/meta` 与 UI 状态。
- 序列化:与 `FlowV2` 对齐,保存至 `chrome.storage.local`,并确保与 `Flow` 线性模式互转。
- 执行端(Background
- Runner 增强:在存在 `nodes/edges` 时按 DAG 模式执行;缺省时沿用 steps[] 线性执行。
- Node Registry:节点注册表(type→validate/run 映射),各节点内部复用 MCP 工具(chrome\_\*)。
- 消息/发布
- 导入/导出/发布工具:沿用现有 message types 与 native host 动态工具注册流程。
## 2. 数据模型
### 2.1 Flow V2 接口(与 design.md 保持一致)
```ts
export type NodeType =
| 'click'
| 'fill'
| 'key'
| 'wait'
| 'assert'
| 'script'
| 'navigate'
| 'openTab'
| 'switchTab'
| 'closeTab'
| 'http'
| 'extract'
| 'delay';
export interface NodeBase {
id: string;
type: NodeType;
name?: string;
disabled?: boolean;
config?: any; // 每个节点的专有配置(见 2.2)
ui?: { x: number; y: number };
}
export interface Edge {
id: string;
from: string;
to: string;
label?: 'default' | 'true' | 'false' | 'onError'; // M1 仅 default
}
export interface FlowV2 extends Flow {
// 兼容 V1
nodes?: NodeBase[];
edges?: Edge[];
subflows?: Record<string, { nodes: NodeBase[]; edges: Edge[] }>;
}
```
### 2.2 节点配置(config)约定
- 通用:`timeoutMs?``retry? { count; intervalMs; backoff? }``screenshotOnFail?: boolean``saveAs?: string`extract/http/script 可用)。
- click/fill`target: TargetLocator`(含 ref/candidates),fill 另有 `value: string`(支持 `{var}`)。
- key`keys: string`e.g. `Backspace Enter` / `cmd+a`)。
- wait`condition: { text | selector | navigation | networkIdle }`
- assert`assert: { exists | visible | textPresent | attribute{ selector; name; equals? | matches? } }`
- script`world?: 'MAIN'|'ISOLATED'``code: string``assign?: Record<string,string>`(把返回对象字段映射到 vars)。
- http`method; url; headers?; body?; assign?: Record<string,string>`JSONPath → vars)。
- extract`selector/attr/text/regex/js`(至少一种),`saveAs: string`
- navigate/openTab/switchTab/closeTab:与现有浏览器工具参数一致(tabTarget/startUrl/refresh 保持在运行选项层)。
## 3. 画布 UI 设计
- 组件构成
- 左侧:节点库(基础动作分组)、搜索。
- 中间:画布区(VueFlow),支持缩放/平移/网格吸附/多选/框选/快捷键(Del/⌘C/⌘V/⌘Z/⌘Shift+Z)。
- 右侧:属性面板(分组:基本、目标/选择器、等待/断言、变量/映射、重试/超时、备注)。
- 底部:日志区(运行时使用,可隐藏)。
- 交互细节
- 新建:拖入节点自动放置,连接时高亮可落点;禁止自环;断开连线回收端点。
- 校验:必填项红框/提示;不合法连线阻止;保存前校验。
- 兼容:从 steps[] 打开时自动串联节点;保存时可选择“覆盖 steps[]”(线性模式)或“仅保存 nodes/edges”。
- 性能与体验
- 大图优化:虚拟化/分层渲染;节点模板缓存;平移/缩放节流;100~300 节点流畅。
## 4. RunnerDAG 模式)
### 4.1 执行语义(M1
-`nodes/edges` 存在且不为空:按拓扑排序获得可执行序列;仅处理 label=default 的边。
- 逐节点执行:
1. 变量展开:把 config 中字符串字段里的 `{var}` 替换为 ctx.vars 的值;
2. validate:节点注册表校验 config
3. run:映射到 MCP 工具(见 5),拿到结果;
4. saveAs/assign:把产出写入 ctx.vars(或 ctx.outputs[节点ID])。
5. 日志:推送节点级 RunLogEntry;失败按 `failStrategy`/`retry` 处理。
- 退出条件:到达尾部或遇到不可恢复错误。
### 4.2 伪代码
```ts
async function runDag(flow: FlowV2, options): Promise<RunResult> {
const ctx = { vars: resolveVars(flow.variables, options.args), outputs: {}, runId };
const order = topoSort(flow.nodes, flow.edges); // label=default only
for (const nodeId of order) {
const node = getNode(nodeId);
if (node.disabled) continue;
const runtime = registry[node.type];
const conf = expandTemplates(node.config, ctx.vars);
try {
runtime.validate(conf);
const out = await runtime.run({ tabId, ctx, logger }, conf);
if (conf.saveAs) ctx.vars[conf.saveAs] = out?.value ?? out;
if (conf.assign) applyAssign(ctx.vars, out, conf.assign); // JSONPath 支持留到 M2
logSuccess(nodeId);
} catch (e) {
const retryOk = await maybeRetry(runtime, conf, e);
if (!retryOk) handleFail(nodeId, e); // stop/continue
}
}
return summarize(ctx);
}
```
## 5. 节点注册表与工具映射
- 注册表结构
```ts
export interface NodeRuntime<T = any> {
validate(config: T): { ok: boolean; errors?: string[] };
run(ctx: NodeContext, config: T): Promise<any>;
}
export interface NodeContext {
tabId: number;
vars: Record<string, any>;
outputs: Record<string, any>;
runId: string;
logger: (e: RunLogEntry) => void;
}
```
- 工具映射(与现有一致):
- click → `chrome_click_element`(双击用 `chrome_computer.double_click`)。
- fill → `chrome_fill_or_select`key → `chrome_keyboard`
- wait/assert → `wait-helper` + `chrome_read_page`navigate/open/switch/close → `chrome_navigate`/窗口工具。
- script → `chrome_inject_script`MAIN/ISOLATED)。
- http → `chrome_network_request`(已有则复用)。
- extract → `chrome_read_page` + 内容脚本聚合。
## 6. 存储/导入/导出/发布
- 存储:沿用 `chrome.storage.local`key 不变(rr_flows);在 Flow 实体上新增 `nodes/edges` 字段。
- 导入导出:与线性一致,JSON 中可同时含 steps/nodes/edges;导入时做版本迁移。
- 发布为 MCP 动态工具:工具名 `flow.<slug>`inputSchema 基于 variables + 运行选项生成;调用时走通用 `record_replay_flow_run`
## 7. 校验与错误处理
- 编辑期校验:必填/格式/选择器空值;运行期校验:validate + 容错(默认 stop)。
- 失败截图:统一由 runner 在 catch 路径触发 screenshot 工具;日志中标出节点 ID 与错误原因。
- 选择器回退:沿用 selector-engine 策略;fallback 用信息写入日志,并在编辑器弹出更新提示。
## 8. 安全与隐私
- 敏感变量:不落盘,不导出;运行时仅从 args 注入;Overlay 表单/提示敏感字段。
- 注入世界:ISOLATED 为默认;MAIN 仅在用户明确选择时使用。
- 权限最小化:不新增超出现有扩展范围的权限。
## 9. 性能策略
- 编辑端:虚拟化/节流;避免频繁全图重绘;自动保存去抖(≥500ms)。
- 执行端:节点超时/重试/退避;M2 后引入并发与限流(全局/域级)。
## 10. 迁移与兼容
- 打开旧 Flow(仅 steps[])→ 自动生成链式 DAGnodes/edges)并允许切换“线性/画布”视图;
- 只要 nodes/edges 存在,优先 DAG 执行;否则走 steps[];导出时兼容两者。
## 11. 开发分期(落地建议)
- M12~3 周):
- 选型并接入 VueFlow;完成画布基础、节点库、连线、属性面板、撤销/重做、自动保存;
- 支持基础节点与 DAG 串行执行;线性兼容;导入导出/发布;
- 日志/失败截图在节点上联动高亮(可先在列表展示)。
- M2
- If/Else/While/ForEachOnError 分支;从节点开始调试;open/switch/close 完善;分组/折叠。
- M3
- 并发/限流;表达式/JSONPath 映射器;数据集/凭据;高级调试。
## 12. 开放问题
- JSONPath 与表达式语言的边界与安全沙箱如何定义?
- 节点产出统一结构与 assign/saveAs 的歧义如何避免?
- 画布超大规模(500+ 节点)时的严重退化处理策略?
- 团队协作与冲突解决是否需要引入(ID 锁/合并策略)?
---
说明:本设计围绕“高度复用现有 MCP 工具、最小化新增复杂度”的原则,画布仅作为编排与可视化层;执行统一收口到背景 Runner 与工具层,便于稳定落地与运维。
+146
View File
@@ -0,0 +1,146 @@
# 模块 C — 编排画布(BuilderPRD v1.0
版本:v1.0(参考 Automa,兼容现有 Record & Replay
状态:Ready for Design
负责人:产品/前端架构
## 0. 现状与定位(与录制回放的关系)
- 现状:已具备“录制 → 线性步骤(steps[])→ 回放/发布”的闭环;回放统一复用 MCP 工具(chrome\_\*),并支持变量、失败截图、网络片段等。
- 定位:本编排画布是“录制回放模块的可视化编辑层”。
- 录制完成的工作流可“导入画布”进行二次编排与参数化;
- 也支持在画布里“从零拖拽节点”新建工作流;
- 保存后仍与已有回放/发布/定时/导入导出机制完全打通(同一存储与执行通道)。
- 目标用户路径(高频):录制草稿 → 打开画布调整/补空 → 保存 → 回放验证 → 发布为 MCP 动态工具/配置定时 → 运营。
## 1. 背景与目标
- 背景:现有线性 steps[] 能覆盖大多数串行场景,但难以表达条件、循环、并发、多标签切换等复杂流程;编辑体验也不利于可视化理解与协作。
- 目标:提供“节点 + 连线”的可视化编排画布(DAG),在保持与线性模式完全兼容的前提下,一步到位地支撑复杂业务流程的创建、调试与运行。
- 价值:
- 降低复杂自动化的心智负担(所见即所得)。
- 增强流程表达力(条件/循环/分支/子流程/并发)。
- 与 MCP 工具层打通,形成可沉淀、可分享、可重放的企业级资产。
## 2. 范围与非范围
- 范围(MVP/M1):
- 画布编辑:节点拖拽、连线、选择、移动、缩放、对齐、撤销/重做、自动保存。
- 节点类型(基础动作):click/fill/key/wait/assert/navigate/script/openTab/switchTab/closeTab/delay/extract/http。
- 属性面板:每个节点的配置编辑(选择器候选、变量/占位符、等待/断言、超时/重试、保存为变量等)。
- 变量系统:全局 variables 与节点产出保存(saveAs/assign),字符串字段支持占位符 `{var}`
- 执行:无条件边时按拓扑串行执行;步骤失败截图与日志;OnError 可选(stop/continue/retry)。
- 兼容:线性 steps[] 自动映射为链式 DAG;DAG 缺省时沿用线性模式。
- 导入/导出/发布为 MCP 动态工具(沿用现有机制,Schema 由变量推导)。
- 来源与入口:
- 来源:① 录制得到的 steps 一键转化为 DAG(链式);② 画布新建(拖拽节点);③ JSON 导入;
- 入口:Popup 的“录制与回放”列表进入“编辑”;录制完成弹出“前往画布编辑”。
- 非范围(M1 之外):
- 高级控制流:If/Else、While/Until、ForEach(并发度控制)、OnError 分支(M2)。
- 数据集(Dataset)/可视化 JSON 映射器/凭据库/表达式引擎(M2/M3)。
- 团队协作、多用户并发编辑与权限(后续版本)。
## 3. 用户与关键场景
- 用户:运营/测试/开发/数据标注人员。
- 关键场景:
1. 登录并进入后台 → 根据条件决定分支 → 下载报告(click/fill/wait/assert/navigate/http)。
2. 批量表单填充 → 提交失败重试 → 记录成功项(foreach/delay/assert/saveAs)。
3. 爬取分页数据 → 提取字段到变量/数据集 → 导出 JSON/CSVextract/http/脚本处理)。
## 4. 端到端流程(闭环)
- 从录制到编排:
1. 开始录制(Popup)→ 页面内浮层反馈与事件捕获 → 停止录制;
2. 生成 Flow 草稿(steps[])→ 提示“前往画布编辑”;
3. 画布自动将 steps 链式映射为 nodes/edges → 在属性面板完善:选择器候选优先级、等待/断言、变量占位、saveAs/assign
4. 保存 Flow(同一存储键),可导出 JSON。
- 从零拖拽到回放:
1. 画布中新建 → 从节点库拖入基础节点,连线构建链路;
2. 在属性面板配置选择器/变量/等待/断言/脚本等 → 保存;
3. 回放验证(可选择 “当前/新标签”、“起始 URL”)→ 查看日志与失败截图 → 调整后再次保存。
- 发布与定时:
1. Flow 发布为 MCP 动态工具(flow.<slug>),输入 Schema 由 variables + 运行选项生成;
2. 可配置定时执行(interval/daily/once),执行结果写入运行记录;
3. 运行失败时截图与错误节点高亮,支持导出/导入迁移。
## 5. 功能需求(FR
- 画布交互(FR-BLD-001~010
- FR-BLD-001 画布基础:缩放/平移、对齐网格、吸附对齐、迷你地图。
- FR-BLD-002 节点库:从侧边栏拖入节点、复制/粘贴、批量选择、删除。
- FR-BLD-003 连线:连接/断开、自动修复、避免自环;边标签(默认/true/false/onError 预留)。
- FR-BLD-004 属性面板:点选节点后右侧编辑配置;校验并提示错误。
- FR-BLD-005 撤销/重做/自动保存;版本与恢复点(简版)。
- FR-BLD-006 搜索与定位:按节点名/类型/变量引用查找并高亮。
- FR-BLD-007 从节点开始调试(M2);单步/断点(M3)。
- FR-BLD-008 兼容线性:steps↔DAG 双向转换(线性自动生成链式图)。
- FR-BLD-009 运行入口:整流回放/从选中开始回放;日志与失败截图联动高亮。
- FR-BLD-010 导入导出:JSON(带 nodes/edges/variables/meta)。
- 节点与属性(FR-BLD-011~030
- FR-BLD-011 click/fill/key/wait/assert/navigate/script/delay:与线性动作参数一致。
- FR-BLD-012 openTab/switchTab/closeTab:多标签编排基础动作。
- FR-BLD-013 httpGET/POST/...):可保存响应字段到变量(M1 可选若已有工具)。
- FR-BLD-014 extract:按 selector/属性/文本/正则/JS 抽取,saveAs 到变量。
- FR-BLD-015 重试策略:count/intervalMs/backoff。
- FR-BLD-016 超时/失败策略:stop/continue/retry;失败截图开关。
- FR-BLD-017 选择器候选与优先级:ref/css/attr/aria/text/xpath;回退记录与提示。
- FR-BLD-018 变量系统:全局 variables;字符串字段支持 `{var}`。脚本/HTTP 支持 assign。
- FR-BLD-019 运行选项:tabTarget/startUrl/refresh/captureNetwork/returnLogs/timeoutMs。
- FR-BLD-020 节点命名/备注/标签;节点分组与折叠(M2)。
- 执行与日志(FR-BLD-031~040
- FR-BLD-031 DAG 执行:无条件边时顺序拓扑执行;失败按策略处理。
- FR-BLD-032 日志:节点级耗时/状态/错误信息;失败截图;网络片段(可选)。
- FR-BLD-033 上下文:vars/outputssaveAs/assign 写入;回放后返回 summary/outputs/logs。
- FR-BLD-034 绑定校验:不匹配时拒绝或需 startUrl;与当前绑定规则一致。
## 6. 数据模型(导出/存储)
- 兼容 Flow V1`steps[]` 不变。
- 新增 Flow V2
- `nodes: NodeBase[]``edges: Edge[]``subflows?: Record<string,{nodes;edges}>`
- NodeBase`{ id,type,name?,disabled?,config?,ui? }`Edge`{ id,from,to,label? }`label 预留 default/true/false/onError)。
- 变量:与现有 `variables[]` 统一;字符串字段支持 `{var}`;脚本/HTTP 支持 assign 映射到 vars。
## 7. 验收标准
- 线性流程自动转 DAG,回放结果与线性一致(10 次成功率 ≥ 95%)。
- 画布可稳定编辑 200+ 节点;撤销/重做可靠;保存/导出/导入无损。
- 失败报告包含失败截图与错误节点高亮;用户 1 分钟内定位问题。
- 节点回退/选择器提示清晰,编辑器可直接调整优先级并保存。
## 8. 性能与非功能需求(NFR)
- 画布交互:常见 200 节点保持流畅(60fps 优先级次之,确保不卡顿)。
- 执行性能:单节点默认等待 ≤ 10s;并发/循环在 M2 引入;全局并发上限与限流可配置。
- 稳定性:10 步基准用例回放成功率 ≥ 95%。
- 安全与隐私:敏感变量不落盘;导出不含敏感值;CSP 兼容;权限最小化。
## 9. 里程碑
- M1(画布基础 + 串行 DAG
- 画布/节点库/连线/属性面板/撤销重做/自动保存;基础节点(click/fill/key/wait/assert/navigate/script/delay/extract/http\*);
- DAG 执行(无条件边,串行拓扑);线性兼容;日志/截图/变量;导入导出/发布。
- M2(控制流与多标签)
- If/Else/While/ForEachOnError 分支;openTab/switchTab/closeTab 完善;从节点开始调试;节点分组/折叠;数据集/凭据(可选)。
- M3(并发与限流)
- foreach 并发、全局并发上限/域级限流;表达式/JSONPath 映射器;高级调试与监控。
---
注:节点与工具的映射严格复用现有 MCP 工具(chrome\_\*),减少新增复杂度并保证稳定性。
## 10. 成功指标(KPI
- 20 分钟内:从录制到画布编排再到首次成功回放(≥ 80% 用户)。
- 稳定性:基准 10 步流程回放成功率 ≥ 95%,并在页面小改动(文案/结构微调)下仍 ≥ 90%。
- 工具化:发布为 MCP 工具后,十次调用成功率 ≥ 95%,参数缺失可被 Schema 友好提示。
- 编辑效率:200+ 节点画布交互不卡顿(≥ 30 fps);撤销/重做成功率 100%。
+186
View File
@@ -59,6 +59,18 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.11.0
version: 1.12.1
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))
'@vue-flow/controls':
specifier: ^1.1.3
version: 1.1.3(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))
'@vue-flow/core':
specifier: ^1.47.0
version: 1.47.0(vue@3.5.16(typescript@5.8.3))
'@vue-flow/minimap':
specifier: ^1.5.4
version: 1.5.4(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))
'@xenova/transformers':
specifier: ^2.17.2
version: 2.17.2
@@ -1083,6 +1095,9 @@ packages:
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -1167,6 +1182,29 @@ packages:
'@volar/typescript@2.4.14':
resolution: {integrity: sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==}
'@vue-flow/background@1.3.2':
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/controls@1.1.3':
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/core@1.47.0':
resolution: {integrity: sha512-w+qrm/xjQP5NUeKUOMIbQvpOeivTbGZtY2lGffK5kHiN3ZLyEazhESc8OeIV9NZkK2T5DIeyX/nhHxCC45HLiw==}
peerDependencies:
vue: ^3.3.0
'@vue-flow/minimap@1.5.4':
resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue/compiler-core@3.5.16':
resolution: {integrity: sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==}
@@ -1207,6 +1245,15 @@ packages:
'@vue/shared@3.5.16':
resolution: {integrity: sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==}
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@webext-core/fake-browser@1.3.2':
resolution: {integrity: sha512-jFyPWWz+VkHAC9DRIiIPOyu6X/KlC8dYqSKweHz6tsDb86QawtVgZSpYcM+GOQBlZc5DHFo92jJ7cIq4uBnU0A==}
@@ -1796,6 +1843,44 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
dargs@8.1.0:
resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==}
engines: {node: '>=12'}
@@ -4062,6 +4147,7 @@ packages:
supertest@7.1.1:
resolution: {integrity: sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==}
engines: {node: '>=14.18.0'}
deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
@@ -4406,6 +4492,17 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@10.1.3:
resolution: {integrity: sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5545,6 +5642,8 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
'@types/web-bluetooth@0.0.20': {}
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.33':
@@ -5665,6 +5764,34 @@ snapshots:
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue-flow/background@1.3.2(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))':
dependencies:
'@vue-flow/core': 1.47.0(vue@3.5.16(typescript@5.8.3))
vue: 3.5.16(typescript@5.8.3)
'@vue-flow/controls@1.1.3(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))':
dependencies:
'@vue-flow/core': 1.47.0(vue@3.5.16(typescript@5.8.3))
vue: 3.5.16(typescript@5.8.3)
'@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3))':
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.16(typescript@5.8.3))
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.16(typescript@5.8.3)
transitivePeerDependencies:
- '@vue/composition-api'
'@vue-flow/minimap@1.5.4(@vue-flow/core@1.47.0(vue@3.5.16(typescript@5.8.3)))(vue@3.5.16(typescript@5.8.3))':
dependencies:
'@vue-flow/core': 1.47.0(vue@3.5.16(typescript@5.8.3))
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.16(typescript@5.8.3)
'@vue/compiler-core@3.5.16':
dependencies:
'@babel/parser': 7.27.5
@@ -5737,6 +5864,25 @@ snapshots:
'@vue/shared@3.5.16': {}
'@vueuse/core@10.11.1(vue@3.5.16(typescript@5.8.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.16(typescript@5.8.3))
vue-demi: 0.14.10(vue@3.5.16(typescript@5.8.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.11.1': {}
'@vueuse/shared@10.11.1(vue@3.5.16(typescript@5.8.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.16(typescript@5.8.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@webext-core/fake-browser@1.3.2':
dependencies:
lodash.merge: 4.6.2
@@ -6372,6 +6518,42 @@ snapshots:
csstype@3.1.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
dargs@8.1.0: {}
date-fns@4.1.0: {}
@@ -9260,6 +9442,10 @@ snapshots:
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.16(typescript@5.8.3)):
dependencies:
vue: 3.5.16(typescript@5.8.3)
vue-eslint-parser@10.1.3(eslint@9.28.0(jiti@2.4.2)):
dependencies:
debug: 4.4.1(supports-color@5.5.0)
+67
View File
@@ -0,0 +1,67 @@
## 录制回放 · 编排画布(Builder)落地进度
更新时间:2025-10-10
### 已完成(本次)
- 数据模型:在 `record-replay/types.ts` 扩展 `Flow`,新增可选 `nodes`/`edges`Flow V2 结构),兼容线性 `steps[]`
- 画布编辑器(M1 骨架):新增 `popup/components/BuilderEditor.vue`,使用 VueFlow 渲染 DAG。
- 节点库:click/fill/key/wait/assert/navigate/script/delay。
- 画布:缩放/平移(VueFlow 内置)、网格吸附、拖拽、连线(默认单一出边)。
- 属性面板:按节点类型编辑(选择器候选、fill 值、wait/assert/navigate/script)。
- 互转:支持 steps→nodes(链式)与 nodes→steps(按 default 边拓扑),保存时同步覆盖 `steps[]` 以保证可立即回放。
- 集成入口:在 Popup 的“录制与回放”列表加入“画布编辑”按钮;保存沿用 `RR_SAVE_FLOW`
- Runner 增强:`flow-runner.ts` 在检测到 `nodes/edges` 时,运行期进行 DAG→steps 线性化(按 default 边拓扑),复用现有线性执行与日志/截图机制。
- Builder 小增强:
- 节点类型补充 key/delay 的默认配置、属性面板与摘要展示。
- 快捷键:Delete/Backspace 删除选中;Cmd/Ctrl+D 复制;Cmd/Ctrl+S 保存。
- 自动保存:节点/连线/名称变化 800ms 去抖自动保存,状态提示(保存中/已保存)。
- 搜索定位:顶栏输入命中节点名或首个选择器,回车自动聚焦到节点并选中。
- 新增节点类型与执行:
- httpmethod/url/headers/body/saveAs),Runner 调用 NETWORK_REQUEST 并可保存 JSON 响应到变量。
- extractselector/attr/js/saveAs),Runner 在页面执行提取并保存变量。
- openTab/switchTab/closeTabRunner 分别创建新标签/切换标签/关闭标签(支持按 url/title 匹配)。
- script 支持 saveAs/assign:执行返回值支持保存到变量;assign 支持点路径(a.b[0].c)映射多个变量。
- 校验与提示:
- 节点级校验(http/extract/switchTab/script 等必填/组合约束)与 UI 提示(字段红框+错误列表)。
- 顶栏显示错误计数,便于定位问题。
- 映射编辑器:KeyValueEditor 组件,用于 script/http 的 assign 键值映射编辑。
- 从选中节点回放:Builder 顶栏支持从当前选中节点启动回放(传入 startNodeId)。
- 错误列表面板:可展开全局错误列表,点击条目定位并聚焦到对应节点。
- 快捷键补充:⌘/Ctrl+Z 撤销;⌘/Ctrl+Shift+Z 重做。
- 自动排版与视图:一键自动排版(简单拓扑布局),自适应视图(fit view)。
- 导出:Builder 顶栏直接导出 Flow JSON(保持与后台导出一致)。
- 历史栈优化:限制最多 50 个快照,避免内存无限增长。
- 字段级高亮:点击错误项可高亮并滚动到对应属性字段(PropertyPanel)。
### 目录结构与模块拆分
- 遵循组件聚合原则,将编辑器完整聚合在一个目录下:`popup/components/builder/`
- `model/transforms.ts`DAG/steps 互转、ID 生成、默认配置、拓扑排序、摘要。
- `store/useBuilderStore.ts`:编辑器状态与操作(选择/新增/删除/连线/布局/导入导出)。
- `components/{Canvas,Sidebar,PropertyPanel}.vue`:画布/节点库/属性面板子组件。
### 待安装依赖
- 画布基于 VueFlow:需要安装
- `@vue-flow/core`
- `@vue-flow/controls`
- `@vue-flow/minimap`
### 下一步规划(短期)
1. DAG 执行路径进行更细粒度控制(后续支持 true/false/onError 边),并补齐 http/extract/openTab/switchTab/closeTab 映射。
2. 画布体验升级:撤销/重做历史上限与压缩策略、MiniMap/Controls 配置、节点模板与样式。
3. 属性面板补齐更多节点(http/extract/openTab/switchTab/closeTab),并完善字段校验/提示。
4. 自动保存(去抖 ≥500ms)与版本快照;画布搜索与定位。
### 里程碑对齐(builder.prd.md
- M1:画布基础 + 串行 DAG(当前已具备可用骨架,待 Runner DAG)
- M2:控制流(If/Else/While/ForEach)、OnError 分支、多标签完善(后续迭代)
- M3:并发与限流(后续迭代)
### 影响范围与兼容性
- 不破坏原有线性流程与回放;保存时同步 `steps[]`,可立即回放。
- 类型扩展仅新增可选字段,旧数据不受影响;导入导出将兼容两种结构。