mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
feat: [WIP]quick panel 优化
This commit is contained in:
@@ -39,7 +39,13 @@
|
||||
"Bash(grep:*)",
|
||||
"Bash(pnpm -F chrome-mcp-extension run compile:*)",
|
||||
"Bash(pnpm -F native-server run compile:*)",
|
||||
"Bash(pnpm vue-tsc:*)"
|
||||
"Bash(pnpm vue-tsc:*)",
|
||||
"Bash(pnpm -F chrome-mcp-shared run build:*)",
|
||||
"Bash(timeout 10 npm run dev:*)",
|
||||
"Bash(pnpm -F chrome-mcp-extension vue-tsc:*)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(npm pack:*)",
|
||||
"Bash(ls /Users/hang/code/ai/mcp-chrome/app/chrome-extension/entrypoints/*.content.ts)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -58,6 +58,9 @@ export const BACKGROUND_MESSAGE_TYPES = {
|
||||
ELEMENT_MARKER_DELETE: 'element_marker_delete',
|
||||
ELEMENT_MARKER_VALIDATE: 'element_marker_validate',
|
||||
ELEMENT_MARKER_START: 'element_marker_start_from_popup',
|
||||
// Element picker (human-in-the-loop element selection)
|
||||
ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event',
|
||||
ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event',
|
||||
// Web editor (in-page visual editing)
|
||||
WEB_EDITOR_TOGGLE: 'web_editor_toggle',
|
||||
WEB_EDITOR_APPLY: 'web_editor_apply',
|
||||
@@ -76,6 +79,8 @@ export const BACKGROUND_MESSAGE_TYPES = {
|
||||
WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution',
|
||||
// Web editor props (Phase 7.1.6 early injection)
|
||||
WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection',
|
||||
// Web editor props - open source file in VSCode
|
||||
WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source',
|
||||
// Quick Panel <-> AgentChat integration
|
||||
QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai',
|
||||
QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai',
|
||||
@@ -163,6 +168,14 @@ export const TOOL_MESSAGE_TYPES = {
|
||||
COLLECT_VARIABLES: 'collectVariables',
|
||||
// Element marker overlay control (content-side)
|
||||
ELEMENT_MARKER_START: 'element_marker_start',
|
||||
// Element picker (tool-driven, background <-> content scripts)
|
||||
ELEMENT_PICKER_START: 'elementPickerStart',
|
||||
ELEMENT_PICKER_STOP: 'elementPickerStop',
|
||||
ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest',
|
||||
ELEMENT_PICKER_UI_PING: 'elementPickerUiPing',
|
||||
ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow',
|
||||
ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate',
|
||||
ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide',
|
||||
} as const;
|
||||
|
||||
// Type unions for type safety
|
||||
|
||||
@@ -103,13 +103,18 @@ async function toggleQuickPanelInActiveTab(): Promise<void> {
|
||||
* Initialize Quick Panel keyboard command listener
|
||||
*/
|
||||
export function initQuickPanelCommands(): void {
|
||||
console.log('outside initQuickPanelCommands');
|
||||
console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
|
||||
chrome.commands.onCommand.addListener(async (command) => {
|
||||
console.log('initQuickPanelCommands===>');
|
||||
if (command !== COMMAND_KEY) return;
|
||||
console.log(`${LOG_PREFIX} onCommand received:`, command);
|
||||
if (command !== COMMAND_KEY) {
|
||||
console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
|
||||
|
||||
try {
|
||||
await toggleQuickPanelInActiveTab();
|
||||
console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
|
||||
} catch (err) {
|
||||
console.error(`${LOG_PREFIX} Command handler error:`, err);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { createCommandTriggerHandlerFactory } from './engine/triggers/command-tr
|
||||
import { createContextMenuTriggerHandlerFactory } from './engine/triggers/context-menu-trigger';
|
||||
import { createDomTriggerHandlerFactory } from './engine/triggers/dom-trigger';
|
||||
import { createCronTriggerHandlerFactory } from './engine/triggers/cron-trigger';
|
||||
import { createIntervalTriggerHandlerFactory } from './engine/triggers/interval-trigger';
|
||||
import { createOnceTriggerHandlerFactory } from './engine/triggers/once-trigger';
|
||||
import { createManualTriggerHandlerFactory } from './engine/triggers/manual-trigger';
|
||||
|
||||
import { createChromeArtifactService } from './engine/kernel/artifacts';
|
||||
@@ -356,6 +358,8 @@ export async function bootstrapV3(): Promise<V3Runtime> {
|
||||
contextMenu: createContextMenuTriggerHandlerFactory({ logger }),
|
||||
dom: createDomTriggerHandlerFactory({ logger }),
|
||||
cron: createCronTriggerHandlerFactory({ logger, now }),
|
||||
interval: createIntervalTriggerHandlerFactory({ logger }),
|
||||
once: createOnceTriggerHandlerFactory({ logger }),
|
||||
manual: createManualTriggerHandlerFactory({ logger }),
|
||||
},
|
||||
now,
|
||||
|
||||
@@ -7,7 +7,15 @@ import type { JsonObject, UnixMillis } from './json';
|
||||
import type { FlowId, TriggerId } from './ids';
|
||||
|
||||
/** 触发器类型 */
|
||||
export type TriggerKind = 'manual' | 'url' | 'cron' | 'command' | 'contextMenu' | 'dom';
|
||||
export type TriggerKind =
|
||||
| 'manual'
|
||||
| 'url'
|
||||
| 'cron'
|
||||
| 'interval'
|
||||
| 'once'
|
||||
| 'command'
|
||||
| 'contextMenu'
|
||||
| 'dom';
|
||||
|
||||
/**
|
||||
* 触发器基础接口
|
||||
@@ -53,6 +61,20 @@ export type TriggerSpec =
|
||||
timezone?: string;
|
||||
})
|
||||
|
||||
// Interval 定时触发(固定间隔重复)
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'interval';
|
||||
/** 间隔分钟数,最小为 1 */
|
||||
periodMinutes: number;
|
||||
})
|
||||
|
||||
// Once 定时触发(指定时间触发一次后自动禁用)
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'once';
|
||||
/** 触发时间戳 (Unix milliseconds) */
|
||||
whenMs: UnixMillis;
|
||||
})
|
||||
|
||||
// 快捷键触发
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'command';
|
||||
|
||||
+11
@@ -41,6 +41,8 @@ export interface EnqueueRunDeps {
|
||||
export interface EnqueueRunInput {
|
||||
/** Flow ID (必选) */
|
||||
flowId: FlowId;
|
||||
/** 起始节点 ID (可选,默认使用 Flow 的 entryNodeId) */
|
||||
startNodeId?: NodeId;
|
||||
/** 优先级 (默认 0) */
|
||||
priority?: number;
|
||||
/** 最大尝试次数 (默认 1) */
|
||||
@@ -158,6 +160,14 @@ export async function enqueueRun(
|
||||
throw new Error(`Flow "${flowId}" not found`);
|
||||
}
|
||||
|
||||
// 验证 startNodeId 存在于 Flow 中
|
||||
if (input.startNodeId) {
|
||||
const nodeExists = flow.nodes.some((n) => n.id === input.startNodeId);
|
||||
if (!nodeExists) {
|
||||
throw new Error(`startNodeId "${input.startNodeId}" not found in flow "${flowId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const ts = now();
|
||||
const runId = generateRunId();
|
||||
|
||||
@@ -174,6 +184,7 @@ export async function enqueueRun(
|
||||
args: input.args,
|
||||
trigger: input.trigger,
|
||||
debug: input.debug,
|
||||
startNodeId: input.startNodeId,
|
||||
nextSeq: 0,
|
||||
};
|
||||
await deps.storage.runs.save(runRecord);
|
||||
|
||||
+25
-1
@@ -195,6 +195,7 @@ export class RpcServer {
|
||||
},
|
||||
{
|
||||
flowId: params?.flowId as FlowId,
|
||||
startNodeId: params?.startNodeId as NodeId | undefined,
|
||||
priority: params?.priority as number | undefined,
|
||||
maxAttempts: params?.maxAttempts as number | undefined,
|
||||
args: params?.args as JsonObject | undefined,
|
||||
@@ -982,6 +983,29 @@ export class RpcServer {
|
||||
return { ...base, cron: raw.cron, timezone } as TriggerSpec;
|
||||
}
|
||||
|
||||
case 'interval': {
|
||||
if (raw.periodMinutes === undefined || raw.periodMinutes === null) {
|
||||
throw new Error('trigger.periodMinutes is required for interval triggers');
|
||||
}
|
||||
if (typeof raw.periodMinutes !== 'number' || !Number.isFinite(raw.periodMinutes)) {
|
||||
throw new Error('trigger.periodMinutes must be a finite number');
|
||||
}
|
||||
if (raw.periodMinutes < 1) {
|
||||
throw new Error('trigger.periodMinutes must be >= 1');
|
||||
}
|
||||
return { ...base, periodMinutes: raw.periodMinutes } as TriggerSpec;
|
||||
}
|
||||
|
||||
case 'once': {
|
||||
if (raw.whenMs === undefined || raw.whenMs === null) {
|
||||
throw new Error('trigger.whenMs is required for once triggers');
|
||||
}
|
||||
if (typeof raw.whenMs !== 'number' || !Number.isFinite(raw.whenMs)) {
|
||||
throw new Error('trigger.whenMs must be a finite number');
|
||||
}
|
||||
return { ...base, whenMs: Math.floor(raw.whenMs) } as TriggerSpec;
|
||||
}
|
||||
|
||||
case 'command': {
|
||||
if (!raw.commandKey || typeof raw.commandKey !== 'string') {
|
||||
throw new Error('trigger.commandKey is required for command triggers');
|
||||
@@ -1033,7 +1057,7 @@ export class RpcServer {
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`trigger.kind must be one of: manual, url, cron, command, contextMenu, dom`,
|
||||
`trigger.kind must be one of: manual, url, cron, interval, once, command, contextMenu, dom`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview Interval Trigger Handler (M3.1)
|
||||
* @description
|
||||
* 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。
|
||||
*
|
||||
* 策略:
|
||||
* - 每个触发器对应一个重复 alarm
|
||||
* - 使用 delayInMinutes 使首次触发在配置的间隔后
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type IntervalTriggerSpec = TriggerSpecByKind<'interval'>;
|
||||
|
||||
export interface IntervalTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
interface InstalledIntervalTrigger {
|
||||
spec: IntervalTriggerSpec;
|
||||
periodMinutes: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const ALARM_PREFIX = 'rr_v3_interval_';
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* 校验并规范化 periodMinutes
|
||||
*/
|
||||
function normalizePeriodMinutes(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('periodMinutes must be a finite number');
|
||||
}
|
||||
if (value < 1) {
|
||||
throw new Error('periodMinutes must be >= 1');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 alarm 名称
|
||||
*/
|
||||
function alarmNameForTrigger(triggerId: TriggerId): string {
|
||||
return `${ALARM_PREFIX}${triggerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 alarm 名称解析 triggerId
|
||||
*/
|
||||
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
|
||||
if (!name.startsWith(ALARM_PREFIX)) return null;
|
||||
const id = name.slice(ALARM_PREFIX.length);
|
||||
return id ? (id as TriggerId) : null;
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* 创建 interval 触发器处理器工厂
|
||||
*/
|
||||
export function createIntervalTriggerHandlerFactory(
|
||||
deps?: IntervalTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'interval'> {
|
||||
return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 interval 触发器处理器
|
||||
*/
|
||||
export function createIntervalTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: IntervalTriggerHandlerDeps,
|
||||
): TriggerHandler<'interval'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
const installed = new Map<TriggerId, InstalledIntervalTrigger>();
|
||||
const versions = new Map<TriggerId, number>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* 递增版本号以使挂起的操作失效
|
||||
*/
|
||||
function bumpVersion(triggerId: TriggerId): number {
|
||||
const next = (versions.get(triggerId) ?? 0) + 1;
|
||||
versions.set(triggerId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 alarm
|
||||
*/
|
||||
async function clearAlarmByName(name: string): Promise<void> {
|
||||
if (!chrome.alarms?.clear) return;
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.clear(name));
|
||||
} catch (e) {
|
||||
logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有 interval alarms
|
||||
*/
|
||||
async function clearAllIntervalAlarms(): Promise<void> {
|
||||
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
|
||||
try {
|
||||
const alarms = await Promise.resolve(chrome.alarms.getAll());
|
||||
const list = Array.isArray(alarms) ? alarms : [];
|
||||
await Promise.all(
|
||||
list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度 alarm
|
||||
*/
|
||||
async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
|
||||
if (!chrome.alarms?.create) {
|
||||
logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry || entry.version !== expectedVersion) return;
|
||||
|
||||
const name = alarmNameForTrigger(triggerId);
|
||||
const periodInMinutes = entry.periodMinutes;
|
||||
|
||||
try {
|
||||
// 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm
|
||||
// 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发
|
||||
await Promise.resolve(
|
||||
chrome.alarms.create(name, {
|
||||
delayInMinutes: periodInMinutes,
|
||||
periodInMinutes,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm 事件处理
|
||||
*/
|
||||
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
|
||||
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
|
||||
if (!triggerId) return;
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry) return;
|
||||
|
||||
// 触发回调
|
||||
Promise.resolve(
|
||||
fireCallback.onFire(triggerId, {
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
}),
|
||||
).catch((e) => {
|
||||
logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 确保正在监听 alarm 事件
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.alarms?.onAlarm?.addListener) {
|
||||
logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听 alarm 事件
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.alarms.onAlarm.removeListener(onAlarm);
|
||||
} catch (e) {
|
||||
logger.debug('[IntervalTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'interval',
|
||||
|
||||
async install(trigger: IntervalTriggerSpec): Promise<void> {
|
||||
const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);
|
||||
|
||||
const version = bumpVersion(trigger.id);
|
||||
installed.set(trigger.id, {
|
||||
spec: { ...trigger, periodMinutes },
|
||||
periodMinutes,
|
||||
version,
|
||||
});
|
||||
|
||||
ensureListening();
|
||||
await schedule(trigger.id, version);
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
const id = triggerId as TriggerId;
|
||||
bumpVersion(id);
|
||||
installed.delete(id);
|
||||
await clearAlarmByName(alarmNameForTrigger(id));
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
for (const id of installed.keys()) {
|
||||
bumpVersion(id);
|
||||
}
|
||||
installed.clear();
|
||||
await clearAllIntervalAlarms();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @fileoverview Once Trigger Handler (M3.1)
|
||||
* @description
|
||||
* 使用 chrome.alarms 的 when 参数实现一次性定时触发。
|
||||
*
|
||||
* 行为:
|
||||
* - 每个触发器对应一个一次性 alarm
|
||||
* - 触发后自动将触发器禁用 (enabled=false) 并卸载
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import { createTriggersStore } from '../../storage/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type OnceTriggerSpec = TriggerSpecByKind<'once'>;
|
||||
|
||||
export interface OnceTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
/**
|
||||
* 可选:自定义禁用触发器的方法
|
||||
* 如果未提供,将直接更新 TriggerStore
|
||||
*/
|
||||
disableTrigger?: (triggerId: TriggerId) => Promise<void>;
|
||||
}
|
||||
|
||||
interface InstalledOnceTrigger {
|
||||
spec: OnceTriggerSpec;
|
||||
whenMs: UnixMillis;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const ALARM_PREFIX = 'rr_v3_once_';
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* 校验并规范化 whenMs
|
||||
*/
|
||||
function normalizeWhenMs(value: unknown): UnixMillis {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('whenMs must be a finite number');
|
||||
}
|
||||
return Math.floor(value) as UnixMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 alarm 名称
|
||||
*/
|
||||
function alarmNameForTrigger(triggerId: TriggerId): string {
|
||||
return `${ALARM_PREFIX}${triggerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 alarm 名称解析 triggerId
|
||||
*/
|
||||
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
|
||||
if (!name.startsWith(ALARM_PREFIX)) return null;
|
||||
const id = name.slice(ALARM_PREFIX.length);
|
||||
return id ? (id as TriggerId) : null;
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* 创建 once 触发器处理器工厂
|
||||
*/
|
||||
export function createOnceTriggerHandlerFactory(
|
||||
deps?: OnceTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'once'> {
|
||||
return (fireCallback) => createOnceTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 once 触发器处理器
|
||||
*/
|
||||
export function createOnceTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: OnceTriggerHandlerDeps,
|
||||
): TriggerHandler<'once'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
// 延迟创建 store,避免在测试环境中出问题
|
||||
let triggersStore: ReturnType<typeof createTriggersStore> | null = null;
|
||||
const getTriggersStore = () => {
|
||||
if (!triggersStore) {
|
||||
triggersStore = createTriggersStore();
|
||||
}
|
||||
return triggersStore;
|
||||
};
|
||||
|
||||
const disableTrigger =
|
||||
deps?.disableTrigger ??
|
||||
(async (triggerId: TriggerId) => {
|
||||
const store = getTriggersStore();
|
||||
const existing = await store.get(triggerId);
|
||||
if (!existing) return;
|
||||
if (!existing.enabled) return;
|
||||
await store.save({ ...existing, enabled: false });
|
||||
});
|
||||
|
||||
const installed = new Map<TriggerId, InstalledOnceTrigger>();
|
||||
const versions = new Map<TriggerId, number>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* 递增版本号以使挂起的操作失效
|
||||
*/
|
||||
function bumpVersion(triggerId: TriggerId): number {
|
||||
const next = (versions.get(triggerId) ?? 0) + 1;
|
||||
versions.set(triggerId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 alarm
|
||||
*/
|
||||
async function clearAlarmByName(name: string): Promise<void> {
|
||||
if (!chrome.alarms?.clear) return;
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.clear(name));
|
||||
} catch (e) {
|
||||
logger.debug('[OnceTriggerHandler] alarms.clear failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有 once alarms
|
||||
*/
|
||||
async function clearAllOnceAlarms(): Promise<void> {
|
||||
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
|
||||
try {
|
||||
const alarms = await Promise.resolve(chrome.alarms.getAll());
|
||||
const list = Array.isArray(alarms) ? alarms : [];
|
||||
await Promise.all(
|
||||
list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('[OnceTriggerHandler] alarms.getAll failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度 alarm
|
||||
*/
|
||||
async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
|
||||
if (!chrome.alarms?.create) {
|
||||
logger.warn('[OnceTriggerHandler] chrome.alarms.create is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry || entry.version !== expectedVersion) return;
|
||||
|
||||
const name = alarmNameForTrigger(triggerId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.create(name, { when: entry.whenMs }));
|
||||
} catch (e) {
|
||||
logger.error(`[OnceTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部卸载逻辑(不触发外部 uninstall)
|
||||
*/
|
||||
async function uninstallInternal(triggerId: TriggerId): Promise<void> {
|
||||
bumpVersion(triggerId);
|
||||
installed.delete(triggerId);
|
||||
await clearAlarmByName(alarmNameForTrigger(triggerId));
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm 事件处理
|
||||
*/
|
||||
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
|
||||
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
|
||||
if (!triggerId) return;
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry) return;
|
||||
|
||||
const expectedVersion = entry.version;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await fireCallback.onFire(triggerId, {
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`[OnceTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
} finally {
|
||||
// 检查版本是否仍然有效
|
||||
if (installed.get(triggerId)?.version === expectedVersion) {
|
||||
// 禁用触发器
|
||||
try {
|
||||
await disableTrigger(triggerId);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[OnceTriggerHandler] Failed to disable trigger "${triggerId}" after fire:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
// 卸载触发器
|
||||
try {
|
||||
await uninstallInternal(triggerId);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[OnceTriggerHandler] Failed to uninstall trigger "${triggerId}" after fire:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
/**
|
||||
* 确保正在监听 alarm 事件
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.alarms?.onAlarm?.addListener) {
|
||||
logger.warn('[OnceTriggerHandler] chrome.alarms.onAlarm is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听 alarm 事件
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.alarms.onAlarm.removeListener(onAlarm);
|
||||
} catch (e) {
|
||||
logger.debug('[OnceTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'once',
|
||||
|
||||
async install(trigger: OnceTriggerSpec): Promise<void> {
|
||||
const whenMs = normalizeWhenMs(trigger.whenMs);
|
||||
|
||||
const version = bumpVersion(trigger.id);
|
||||
installed.set(trigger.id, {
|
||||
spec: { ...trigger, whenMs },
|
||||
whenMs,
|
||||
version,
|
||||
});
|
||||
|
||||
ensureListening();
|
||||
await schedule(trigger.id, version);
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
await uninstallInternal(triggerId as TriggerId);
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
for (const id of installed.keys()) {
|
||||
bumpVersion(id);
|
||||
}
|
||||
installed.clear();
|
||||
await clearAllOnceAlarms();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+100
-24
@@ -145,11 +145,13 @@ export function convertFlowV2ToV3(v2Flow: V2Flow): ConversionResult<FlowV3> {
|
||||
}
|
||||
|
||||
// 5. 计算 entryNodeId
|
||||
const entryNodeId = findEntryNodeId(nodes, edges);
|
||||
if (!entryNodeId) {
|
||||
const entryResult = findEntryNodeId(nodes, edges);
|
||||
warnings.push(...entryResult.warnings);
|
||||
if (!entryResult.nodeId) {
|
||||
errors.push('Could not determine entry node. No valid root node found.');
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
const entryNodeId = entryResult.nodeId;
|
||||
|
||||
// 6. 转换变量
|
||||
const variables = convertVariablesV2ToV3(v2Flow.variables || []);
|
||||
@@ -234,41 +236,118 @@ function convertEdgeV2ToV3(v2Edge: V2Edge): EdgeV3 | null {
|
||||
return edge;
|
||||
}
|
||||
|
||||
/** entryNodeId 计算结果 */
|
||||
interface EntryNodeResult {
|
||||
nodeId: NodeId | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 找到入口节点 ID
|
||||
*
|
||||
* 规则:
|
||||
* 1. 排除 trigger 类型节点(这些是 UI 节点)
|
||||
* 2. 找到入度为 0 的节点(没有边指向它)
|
||||
* 3. 如果有多个,选择第一个(按 ID 排序)
|
||||
* 1. 排除 trigger 类型节点(这些是 UI 节点,不参与执行)
|
||||
* 2. 只统计「可执行节点 -> 可执行节点」的边来计算入度(忽略 trigger 指出的边)
|
||||
* 3. 找到入度为 0 的节点作为候选
|
||||
* 4. 如果有多个候选,使用稳定选择规则:
|
||||
* - 优先选择 UI 坐标最靠左上的节点(按 x 升序,x 相同按 y 升序)
|
||||
* - 如果无 UI 坐标,按 ID 字典序取第一个
|
||||
*/
|
||||
function findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): NodeId | null {
|
||||
// 排除 trigger 节点
|
||||
function findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): EntryNodeResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. 排除 trigger 节点,获取可执行节点
|
||||
const executableNodes = nodes.filter((n) => n.kind !== 'trigger');
|
||||
if (executableNodes.length === 0) {
|
||||
return null;
|
||||
warnings.push('No executable nodes found; cannot determine entry node');
|
||||
return { nodeId: null, warnings };
|
||||
}
|
||||
|
||||
// 计算每个节点的入度
|
||||
const inDegree = new Map<string, number>();
|
||||
const executableNodeIds = new Set<NodeId>(executableNodes.map((n) => n.id));
|
||||
|
||||
// 2. 计算入度(只统计可执行节点之间的边)
|
||||
const inDegree = new Map<NodeId, number>();
|
||||
for (const node of executableNodes) {
|
||||
inDegree.set(node.id, 0);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
if (inDegree.has(edge.to)) {
|
||||
inDegree.set(edge.to, (inDegree.get(edge.to) || 0) + 1);
|
||||
// 忽略从非可执行节点(如 trigger)指出的边
|
||||
if (!executableNodeIds.has(edge.from)) {
|
||||
continue;
|
||||
}
|
||||
// 忽略指向非可执行节点的边
|
||||
if (!executableNodeIds.has(edge.to)) {
|
||||
continue;
|
||||
}
|
||||
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// 找入度为 0 的节点
|
||||
// 3. 找入度为 0 的节点
|
||||
const rootNodes = executableNodes.filter((n) => inDegree.get(n.id) === 0);
|
||||
|
||||
if (rootNodes.length === 0) {
|
||||
// 如果没有入度为 0 的节点,说明可能有环,取第一个可执行节点
|
||||
return executableNodes[0].id;
|
||||
// 没有入度为 0 的节点,说明图中存在环,使用稳定选择器选择 fallback
|
||||
const fallbackResult = selectStableRootNode(executableNodes);
|
||||
warnings.push(
|
||||
`No inDegree=0 executable node found (graph may contain cycles); ` +
|
||||
`falling back to "${fallbackResult.node.id}" by ${fallbackResult.rule}`,
|
||||
);
|
||||
return { nodeId: fallbackResult.node.id, warnings };
|
||||
}
|
||||
|
||||
// 按 ID 排序,取第一个
|
||||
rootNodes.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return rootNodes[0].id;
|
||||
// 4. 单个根节点,直接返回
|
||||
if (rootNodes.length === 1) {
|
||||
return { nodeId: rootNodes[0].id, warnings };
|
||||
}
|
||||
|
||||
// 5. 多个根节点,使用稳定选择规则
|
||||
const selectedResult = selectStableRootNode(rootNodes);
|
||||
const candidateIds = rootNodes
|
||||
.map((n) => n.id)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(', ');
|
||||
warnings.push(
|
||||
`Multiple inDegree=0 executable nodes (${candidateIds}); ` +
|
||||
`selected "${selectedResult.node.id}" by ${selectedResult.rule}`,
|
||||
);
|
||||
|
||||
return { nodeId: selectedResult.node.id, warnings };
|
||||
}
|
||||
|
||||
/** 稳定选择结果 */
|
||||
interface StableSelectionResult {
|
||||
node: NodeV3;
|
||||
rule: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个根节点中选择一个稳定的入口节点
|
||||
* 优先按 UI 坐标(左上角优先),其次按 ID 字典序
|
||||
*/
|
||||
function selectStableRootNode(nodes: NodeV3[]): StableSelectionResult {
|
||||
// 检查节点是否有有效的 UI 坐标
|
||||
const hasValidUi = (n: NodeV3): n is NodeV3 & { ui: { x: number; y: number } } =>
|
||||
!!n.ui && Number.isFinite(n.ui.x) && Number.isFinite(n.ui.y);
|
||||
|
||||
const nodesWithUi = nodes.filter(hasValidUi);
|
||||
|
||||
if (nodesWithUi.length > 0) {
|
||||
// 按 UI 坐标排序:x 升序 -> y 升序 -> id 字典序(作为 tie-breaker)
|
||||
nodesWithUi.sort((a, b) => {
|
||||
if (a.ui.x !== b.ui.x) return a.ui.x - b.ui.x;
|
||||
if (a.ui.y !== b.ui.y) return a.ui.y - b.ui.y;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const selected = nodesWithUi[0];
|
||||
return {
|
||||
node: selected,
|
||||
rule: `ui(x=${selected.ui.x}, y=${selected.ui.y})`,
|
||||
};
|
||||
}
|
||||
|
||||
// 无 UI 坐标,按 ID 字典序
|
||||
const sortedById = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { node: sortedById[0], rule: 'id' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,8 +554,7 @@ export function convertTriggerV2ToV3(v2Trigger: V2Trigger): ConversionResult<Tri
|
||||
};
|
||||
break;
|
||||
|
||||
case 'schedule': // 将 V2 schedule 转换为 cron 表达式
|
||||
{
|
||||
case 'schedule': { // 将 V2 schedule 转换为 cron 表达式
|
||||
const cron = convertScheduleToCron(v2Trigger.schedule);
|
||||
if (!cron) {
|
||||
errors.push('Could not convert V2 schedule to cron expression');
|
||||
@@ -517,8 +595,7 @@ function convertScheduleToCron(schedule: V2Trigger['schedule']): string | null {
|
||||
if (!schedule) return null;
|
||||
|
||||
switch (schedule.type) {
|
||||
case 'interval': // 将间隔转换为近似 cron(每 N 分钟)
|
||||
{
|
||||
case 'interval': { // 将间隔转换为近似 cron(每 N 分钟)
|
||||
const intervalMinutes = Math.max(1, Math.round((schedule.intervalMs || 60000) / 60000));
|
||||
if (intervalMinutes < 60) {
|
||||
return `*/${intervalMinutes} * * * *`;
|
||||
@@ -536,8 +613,7 @@ function convertScheduleToCron(schedule: V2Trigger['schedule']): string | null {
|
||||
}
|
||||
return '0 0 * * *'; // 默认每天 0:00
|
||||
|
||||
case 'weekly': // 每周指定天数和时间
|
||||
{
|
||||
case 'weekly': { // 每周指定天数和时间
|
||||
const days = (schedule.days || [0]).join(',');
|
||||
if (schedule.time) {
|
||||
const [hour, minute] = schedule.time.split(':').map(Number);
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* DOM Tools Action Handlers
|
||||
*
|
||||
* Handles DOM manipulation actions:
|
||||
* - triggerEvent: Dispatch a custom DOM Event on an element
|
||||
* - setAttribute: Set or remove an attribute on an element
|
||||
*
|
||||
* Design notes:
|
||||
* - Both handlers follow the same pattern as click.ts
|
||||
* - Element location uses selectorLocator from shared code
|
||||
* - CSS selector resolution supports ref fallback
|
||||
*/
|
||||
|
||||
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import { handleCallTool } from '@/entrypoints/background/tools';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { failed, invalid, ok, tryResolveJson } from '../registry';
|
||||
import type {
|
||||
ActionExecutionResult,
|
||||
ActionHandler,
|
||||
ElementTarget,
|
||||
JsonValue,
|
||||
VariableStore,
|
||||
} from '../types';
|
||||
import {
|
||||
interpolateBraces,
|
||||
logSelectorFallback,
|
||||
resolveString,
|
||||
selectorLocator,
|
||||
sendMessageToTab,
|
||||
toSelectorTarget,
|
||||
} from './common';
|
||||
|
||||
// ================================
|
||||
// Type Definitions
|
||||
// ================================
|
||||
|
||||
interface ResolveRefResponse {
|
||||
success?: boolean;
|
||||
selector?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DomScriptResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ResolvedTarget {
|
||||
selector: string;
|
||||
frameId: number | undefined;
|
||||
firstCandidateType?: string;
|
||||
resolvedBy?: string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Shared Utilities
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Check if target has valid ref or candidates
|
||||
* Accepts unknown to safely handle malformed input in validate()
|
||||
*/
|
||||
function hasValidTarget(target: unknown): boolean {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const t = target as { ref?: unknown; candidates?: unknown };
|
||||
const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
|
||||
const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
|
||||
return hasRef || hasCandidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip frame prefix from composite selector (e.g., "frame|>selector" -> "selector")
|
||||
*/
|
||||
function stripCompositePrefix(selector: string): string {
|
||||
const raw = String(selector || '').trim();
|
||||
if (!raw.includes('|>')) return raw;
|
||||
|
||||
const parts = raw
|
||||
.split('|>')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts[parts.length - 1] : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ElementTarget to a CSS selector string
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Try to locate element using selectorLocator
|
||||
* 2. If ref found, resolve it to CSS selector via content script
|
||||
* 3. Fall back to first CSS/attr candidate if no ref
|
||||
*/
|
||||
async function resolveTargetSelector(
|
||||
tabId: number,
|
||||
target: ElementTarget,
|
||||
vars: VariableStore,
|
||||
contextFrameId: number | undefined,
|
||||
): Promise<{ ok: true; value: ResolvedTarget } | { ok: false; error: string }> {
|
||||
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars);
|
||||
|
||||
// Locate element using shared selector locator
|
||||
const located = await selectorLocator.locate(tabId, selectorTarget, {
|
||||
frameId: contextFrameId,
|
||||
preferRef: false,
|
||||
});
|
||||
|
||||
const frameId = located?.frameId ?? contextFrameId;
|
||||
const refToUse = located?.ref ?? selectorTarget.ref;
|
||||
|
||||
// Must have either ref or CSS/attr candidate
|
||||
if (!refToUse && !firstCssOrAttr) {
|
||||
return { ok: false, error: 'Could not locate target element' };
|
||||
}
|
||||
|
||||
let selector: string | undefined;
|
||||
|
||||
// Try to resolve ref to CSS selector
|
||||
if (refToUse) {
|
||||
const resolved = await sendMessageToTab<ResolveRefResponse>(
|
||||
tabId,
|
||||
{ action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse },
|
||||
frameId,
|
||||
);
|
||||
|
||||
if (resolved.ok && resolved.value?.success !== false && resolved.value?.selector) {
|
||||
const sel = resolved.value.selector.trim();
|
||||
if (sel) selector = sel;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to CSS/attr candidate
|
||||
if (!selector && firstCssOrAttr) {
|
||||
const stripped = stripCompositePrefix(firstCssOrAttr);
|
||||
if (stripped) selector = stripped;
|
||||
}
|
||||
|
||||
if (!selector) {
|
||||
return { ok: false, error: 'Could not resolve a CSS selector for the target element' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
selector,
|
||||
frameId,
|
||||
firstCandidateType,
|
||||
// Only mark as 'ref' if locator actually resolved via ref
|
||||
resolvedBy: located?.resolvedBy || (located?.ref ? 'ref' : undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log selector fallback if a different selector type was used
|
||||
*/
|
||||
function maybeLogFallback(
|
||||
ctx: Parameters<typeof logSelectorFallback>[0],
|
||||
actionId: string,
|
||||
resolved: ResolvedTarget,
|
||||
): void {
|
||||
const { resolvedBy, firstCandidateType } = resolved;
|
||||
|
||||
const fallbackUsed =
|
||||
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
|
||||
|
||||
if (fallbackUsed) {
|
||||
logSelectorFallback(ctx, actionId, String(firstCandidateType), String(resolvedBy));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// triggerEvent Handler
|
||||
// ================================
|
||||
|
||||
export const triggerEventHandler: ActionHandler<'triggerEvent'> = {
|
||||
type: 'triggerEvent',
|
||||
|
||||
validate: (action) => {
|
||||
if (!hasValidTarget(action.params.target)) {
|
||||
return invalid('triggerEvent requires a target ref or selector candidates');
|
||||
}
|
||||
|
||||
const event = action.params.event;
|
||||
if (event === undefined || event === null) {
|
||||
return invalid('Missing event parameter');
|
||||
}
|
||||
if (typeof event === 'string' && event.trim().length === 0) {
|
||||
return invalid('event must be a non-empty string');
|
||||
}
|
||||
|
||||
return ok();
|
||||
},
|
||||
|
||||
describe: (action) => {
|
||||
const ev = typeof action.params.event === 'string' ? action.params.event : '(dynamic)';
|
||||
const display = ev.length > 30 ? ev.slice(0, 30) + '...' : ev;
|
||||
return `Trigger event "${display}"`;
|
||||
},
|
||||
|
||||
run: async (ctx, action): Promise<ActionExecutionResult<'triggerEvent'>> => {
|
||||
const { tabId, vars, frameId } = ctx;
|
||||
|
||||
if (typeof tabId !== 'number') {
|
||||
return failed('TAB_NOT_FOUND', 'No active tab found for triggerEvent action');
|
||||
}
|
||||
|
||||
// Resolve event type
|
||||
const eventResolved = resolveString(action.params.event, vars);
|
||||
if (!eventResolved.ok) {
|
||||
return failed('VALIDATION_ERROR', eventResolved.error);
|
||||
}
|
||||
|
||||
const eventType = eventResolved.value.trim();
|
||||
if (!eventType) {
|
||||
return failed('VALIDATION_ERROR', 'Event type is empty');
|
||||
}
|
||||
|
||||
// Event options
|
||||
const bubbles = action.params.bubbles !== false;
|
||||
const cancelable = action.params.cancelable === true;
|
||||
|
||||
// Ensure page is read for element location
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
|
||||
|
||||
// Resolve target selector
|
||||
const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);
|
||||
if (!targetResolved.ok) {
|
||||
return failed('TARGET_NOT_FOUND', targetResolved.error);
|
||||
}
|
||||
|
||||
const { selector, frameId: resolvedFrameId } = targetResolved.value;
|
||||
const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;
|
||||
|
||||
// Execute event dispatch in page context
|
||||
try {
|
||||
const injected = await chrome.scripting.executeScript({
|
||||
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
|
||||
world: 'MAIN',
|
||||
func: (
|
||||
sel: string,
|
||||
type: string,
|
||||
bubbles: boolean,
|
||||
cancelable: boolean,
|
||||
): DomScriptResult => {
|
||||
try {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) {
|
||||
// Use special error code to distinguish from script execution errors
|
||||
return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };
|
||||
}
|
||||
|
||||
const event = new Event(type, { bubbles, cancelable });
|
||||
el.dispatchEvent(event);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
args: [selector, eventType, bubbles, cancelable],
|
||||
});
|
||||
|
||||
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
|
||||
if (!result || typeof result !== 'object') {
|
||||
return failed('SCRIPT_FAILED', 'triggerEvent script returned invalid result');
|
||||
}
|
||||
|
||||
const typed = result as DomScriptResult;
|
||||
if (!typed.success) {
|
||||
// Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...")
|
||||
const errorMsg = typed.error || `Failed to dispatch "${eventType}"`;
|
||||
const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')
|
||||
? 'TARGET_NOT_FOUND'
|
||||
: 'SCRIPT_FAILED';
|
||||
return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, ''));
|
||||
}
|
||||
} catch (e) {
|
||||
return failed(
|
||||
'SCRIPT_FAILED',
|
||||
`Failed to trigger event "${eventType}": ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
maybeLogFallback(ctx, action.id, targetResolved.value);
|
||||
|
||||
return { status: 'success' };
|
||||
},
|
||||
};
|
||||
|
||||
// ================================
|
||||
// setAttribute Handler
|
||||
// ================================
|
||||
|
||||
export const setAttributeHandler: ActionHandler<'setAttribute'> = {
|
||||
type: 'setAttribute',
|
||||
|
||||
validate: (action) => {
|
||||
if (!hasValidTarget(action.params.target)) {
|
||||
return invalid('setAttribute requires a target ref or selector candidates');
|
||||
}
|
||||
|
||||
const name = action.params.name;
|
||||
if (name === undefined || name === null) {
|
||||
return invalid('Missing name parameter');
|
||||
}
|
||||
if (typeof name === 'string' && name.trim().length === 0) {
|
||||
return invalid('name must be a non-empty string');
|
||||
}
|
||||
|
||||
return ok();
|
||||
},
|
||||
|
||||
describe: (action) => {
|
||||
const name = typeof action.params.name === 'string' ? action.params.name : '(dynamic)';
|
||||
const display = name.length > 30 ? name.slice(0, 30) + '...' : name;
|
||||
return action.params.remove ? `Remove attribute "${display}"` : `Set attribute "${display}"`;
|
||||
},
|
||||
|
||||
run: async (ctx, action): Promise<ActionExecutionResult<'setAttribute'>> => {
|
||||
const { tabId, vars, frameId } = ctx;
|
||||
|
||||
if (typeof tabId !== 'number') {
|
||||
return failed('TAB_NOT_FOUND', 'No active tab found for setAttribute action');
|
||||
}
|
||||
|
||||
// Resolve attribute name
|
||||
const nameResolved = resolveString(action.params.name, vars);
|
||||
if (!nameResolved.ok) {
|
||||
return failed('VALIDATION_ERROR', nameResolved.error);
|
||||
}
|
||||
|
||||
const attrName = nameResolved.value.trim();
|
||||
if (!attrName) {
|
||||
return failed('VALIDATION_ERROR', 'Attribute name is empty');
|
||||
}
|
||||
|
||||
const remove = action.params.remove === true;
|
||||
|
||||
// Resolve attribute value (only if not removing)
|
||||
let attrValue: JsonValue = null;
|
||||
if (!remove && action.params.value !== undefined) {
|
||||
const valueResolved = tryResolveJson(action.params.value, vars);
|
||||
if (!valueResolved.ok) {
|
||||
return failed('VALIDATION_ERROR', valueResolved.error);
|
||||
}
|
||||
|
||||
// Apply template interpolation for string values
|
||||
attrValue =
|
||||
typeof valueResolved.value === 'string'
|
||||
? interpolateBraces(valueResolved.value, vars)
|
||||
: valueResolved.value;
|
||||
}
|
||||
|
||||
// Ensure page is read for element location
|
||||
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
|
||||
|
||||
// Resolve target selector
|
||||
const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);
|
||||
if (!targetResolved.ok) {
|
||||
return failed('TARGET_NOT_FOUND', targetResolved.error);
|
||||
}
|
||||
|
||||
const { selector, frameId: resolvedFrameId } = targetResolved.value;
|
||||
const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;
|
||||
|
||||
// Execute attribute modification in page context
|
||||
try {
|
||||
const injected = await chrome.scripting.executeScript({
|
||||
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
|
||||
world: 'MAIN',
|
||||
func: (sel: string, name: string, value: JsonValue, remove: boolean): DomScriptResult => {
|
||||
try {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) {
|
||||
// Use special error code to distinguish from script execution errors
|
||||
return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };
|
||||
}
|
||||
|
||||
if (remove) {
|
||||
el.removeAttribute(name);
|
||||
} else {
|
||||
// Convert value to string for setAttribute
|
||||
const strValue =
|
||||
value === null || value === undefined
|
||||
? ''
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: String(value);
|
||||
el.setAttribute(name, strValue);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
args: [selector, attrName, attrValue, remove],
|
||||
});
|
||||
|
||||
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
|
||||
if (!result || typeof result !== 'object') {
|
||||
return failed('SCRIPT_FAILED', 'setAttribute script returned invalid result');
|
||||
}
|
||||
|
||||
const typed = result as DomScriptResult;
|
||||
if (!typed.success) {
|
||||
const actionDesc = remove ? 'remove' : 'set';
|
||||
// Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...")
|
||||
const errorMsg = typed.error || `Failed to ${actionDesc} attribute "${attrName}"`;
|
||||
const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')
|
||||
? 'TARGET_NOT_FOUND'
|
||||
: 'SCRIPT_FAILED';
|
||||
return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, ''));
|
||||
}
|
||||
} catch (e) {
|
||||
const actionDesc = remove ? 'remove' : 'set';
|
||||
return failed(
|
||||
'SCRIPT_FAILED',
|
||||
`Failed to ${actionDesc} attribute "${attrName}": ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
maybeLogFallback(ctx, action.id, targetResolved.value);
|
||||
|
||||
return { status: 'success' };
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { assertHandler } from './assert';
|
||||
import { clickHandler, dblclickHandler } from './click';
|
||||
import { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
|
||||
import { delayHandler } from './delay';
|
||||
import { setAttributeHandler, triggerEventHandler } from './dom';
|
||||
import { dragHandler } from './drag';
|
||||
import { extractHandler } from './extract';
|
||||
import { fillHandler } from './fill';
|
||||
@@ -28,6 +29,7 @@ export { assertHandler } from './assert';
|
||||
export { clickHandler, dblclickHandler } from './click';
|
||||
export { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
|
||||
export { delayHandler } from './delay';
|
||||
export { setAttributeHandler, triggerEventHandler } from './dom';
|
||||
export { dragHandler } from './drag';
|
||||
export { extractHandler } from './extract';
|
||||
export { fillHandler } from './fill';
|
||||
@@ -52,6 +54,7 @@ export * from './common';
|
||||
* - Timing: wait, delay
|
||||
* - Validation: assert
|
||||
* - Data: extract, script, http, screenshot
|
||||
* - DOM Tools: triggerEvent, setAttribute
|
||||
* - Tabs: openTab, switchTab, closeTab, handleDownload
|
||||
* - Control Flow: if, foreach, while, switchFrame
|
||||
*
|
||||
@@ -78,6 +81,9 @@ const ALL_HANDLERS = [
|
||||
scriptHandler,
|
||||
httpHandler,
|
||||
screenshotHandler,
|
||||
// DOM Tools
|
||||
triggerEventHandler,
|
||||
setAttributeHandler,
|
||||
// Tabs
|
||||
openTabHandler,
|
||||
switchTabHandler,
|
||||
@@ -109,6 +115,8 @@ export function registerReplayHandlers(registry: ActionRegistry): void {
|
||||
registry.register(scriptHandler, { override: true });
|
||||
registry.register(httpHandler, { override: true });
|
||||
registry.register(screenshotHandler, { override: true });
|
||||
registry.register(triggerEventHandler, { override: true });
|
||||
registry.register(setAttributeHandler, { override: true });
|
||||
registry.register(openTabHandler, { override: true });
|
||||
registry.register(switchTabHandler, { override: true });
|
||||
registry.register(closeTabHandler, { override: true });
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Element Picker Tool
|
||||
*
|
||||
* Implements chrome_request_element_selection - a human-in-the-loop tool that allows
|
||||
* users to manually select elements on the page when AI cannot reliably locate them.
|
||||
*/
|
||||
|
||||
import { createErrorResponse, type ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import { ERROR_MESSAGES } from '@/common/constants';
|
||||
import {
|
||||
TOOL_NAMES,
|
||||
type ElementPickerRequest,
|
||||
type ElementPickerResult,
|
||||
type ElementPickerResultItem,
|
||||
type PickedElement,
|
||||
} from 'chrome-mcp-shared';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
interface NormalizedRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ElementPickerToolParams {
|
||||
requests: ElementPickerRequest[];
|
||||
timeoutMs?: number;
|
||||
tabId?: number;
|
||||
windowId?: number;
|
||||
}
|
||||
|
||||
interface PickerUiEvent {
|
||||
type: string;
|
||||
sessionId: string;
|
||||
event: 'cancel' | 'confirm' | 'set_active_request' | 'clear_selection';
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface PickerFrameEvent {
|
||||
type: string;
|
||||
sessionId: string;
|
||||
event: 'selected' | 'cancel';
|
||||
requestId?: string;
|
||||
element?: Omit<PickedElement, 'frameId'>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
|
||||
const MAX_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const MIN_TIMEOUT_MS = 10 * 1000; // 10 seconds
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
|
||||
function toTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: unknown): number {
|
||||
if (value === undefined || value === null) return DEFAULT_TIMEOUT_MS;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
|
||||
return Math.min(Math.max(Math.floor(n), MIN_TIMEOUT_MS), MAX_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function normalizeRequests(requests: ElementPickerRequest[]): NormalizedRequest[] {
|
||||
const out: NormalizedRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < requests.length; i++) {
|
||||
const r = requests[i] || ({} as ElementPickerRequest);
|
||||
const name = toTrimmedString(r.name);
|
||||
if (!name) continue;
|
||||
|
||||
// Generate or use provided ID, ensuring uniqueness
|
||||
const baseId = toTrimmedString(r.id) || `req_${i + 1}`;
|
||||
let id = baseId;
|
||||
let suffix = 2;
|
||||
while (seen.has(id)) {
|
||||
id = `${baseId}_${suffix++}`;
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const description = toTrimmedString(r.description);
|
||||
out.push({ id, name, description: description || undefined });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildResultItems(
|
||||
requests: NormalizedRequest[],
|
||||
pickedById: Map<string, PickedElement>,
|
||||
): ElementPickerResultItem[] {
|
||||
return requests.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
element: pickedById.get(r.id) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function listMissingRequestIds(
|
||||
requests: NormalizedRequest[],
|
||||
pickedById: Map<string, PickedElement>,
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
for (const r of requests) {
|
||||
if (!pickedById.has(r.id)) missing.push(r.id);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Element Picker Tool
|
||||
// ============================================================
|
||||
|
||||
class ElementPickerTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION;
|
||||
|
||||
/**
|
||||
* Inject picker scripts into all frames of the tab.
|
||||
*/
|
||||
private async injectPickerScripts(tabId: number): Promise<void> {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
files: ['inject-scripts/element-picker.js'],
|
||||
world: 'ISOLATED',
|
||||
injectImmediately: false,
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the picker API in all frames via scripting.executeScript.
|
||||
*/
|
||||
private async callPickerApi(
|
||||
tabId: number,
|
||||
method: 'startSession' | 'stopSession' | 'setActiveRequest',
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
world: 'ISOLATED',
|
||||
injectImmediately: false,
|
||||
func: (methodName: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
const api = (
|
||||
globalThis as unknown as {
|
||||
__mcpElementPicker?: Record<string, (data: Record<string, unknown>) => void>;
|
||||
}
|
||||
).__mcpElementPicker;
|
||||
const fn = api && api[methodName];
|
||||
if (typeof fn === 'function') {
|
||||
fn(data);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
},
|
||||
args: [method, payload],
|
||||
} as any);
|
||||
}
|
||||
|
||||
async execute(args: ElementPickerToolParams): Promise<ToolResult> {
|
||||
// Validate requests
|
||||
const rawRequests = Array.isArray(args?.requests) ? args.requests : [];
|
||||
if (rawRequests.length === 0) {
|
||||
return createErrorResponse(`${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] is required`);
|
||||
}
|
||||
|
||||
const requests = normalizeRequests(rawRequests);
|
||||
if (requests.length === 0) {
|
||||
return createErrorResponse(
|
||||
`${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] must contain at least one non-empty name`,
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = normalizeTimeoutMs(args?.timeoutMs);
|
||||
const sessionId = `ep_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const deadlineTs = Date.now() + timeoutMs;
|
||||
|
||||
// Resolve tab
|
||||
let tab: chrome.tabs.Tab;
|
||||
try {
|
||||
const explicit = await this.tryGetTab(args?.tabId);
|
||||
tab = explicit || (await this.getActiveTabOrThrowInWindow(args?.windowId));
|
||||
} catch (error) {
|
||||
return createErrorResponse(
|
||||
`${ERROR_MESSAGES.TAB_NOT_FOUND}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (!tab.id) {
|
||||
return createErrorResponse(`${ERROR_MESSAGES.TAB_NOT_FOUND}: Active tab has no ID`);
|
||||
}
|
||||
const tabId = tab.id;
|
||||
|
||||
// Focus the tab/window for user interaction
|
||||
try {
|
||||
await this.ensureFocus(tab, { activate: true, focusWindow: true });
|
||||
} catch {
|
||||
// Best-effort: some environments disallow focusing
|
||||
}
|
||||
|
||||
// State tracking
|
||||
const pickedById = new Map<string, PickedElement>();
|
||||
let activeRequestId: string | null = requests[0]?.id || null;
|
||||
let uiErrorMessage: string | null = null;
|
||||
let uiAvailable = true;
|
||||
|
||||
let finished = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resolveResult: ((result: ElementPickerResult) => void) | null = null;
|
||||
|
||||
// Send UI update to content script
|
||||
const sendUiUpdate = async (): Promise<void> => {
|
||||
if (!uiAvailable) return;
|
||||
try {
|
||||
const selections: Record<string, PickedElement | null> = {};
|
||||
for (const r of requests) {
|
||||
selections[r.id] = pickedById.get(r.id) || null;
|
||||
}
|
||||
await this.sendMessageToTab(
|
||||
tabId,
|
||||
{
|
||||
action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE,
|
||||
sessionId,
|
||||
activeRequestId,
|
||||
selections,
|
||||
deadlineTs,
|
||||
errorMessage: uiErrorMessage,
|
||||
},
|
||||
0, // Top frame only for UI
|
||||
);
|
||||
} catch {
|
||||
uiAvailable = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Set the active request and notify all frames + UI
|
||||
const setActiveRequest = async (requestId: string | null): Promise<void> => {
|
||||
activeRequestId = requestId;
|
||||
await this.callPickerApi(tabId, 'setActiveRequest', {
|
||||
sessionId,
|
||||
activeRequestId: requestId,
|
||||
});
|
||||
await sendUiUpdate();
|
||||
};
|
||||
|
||||
// Finish the tool execution
|
||||
const finish = async (final: {
|
||||
success: boolean;
|
||||
cancelled?: boolean;
|
||||
timedOut?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.removeListener(onRuntimeMessage);
|
||||
|
||||
// Cleanup: stop picker in all frames and hide UI
|
||||
await Promise.allSettled([
|
||||
this.callPickerApi(tabId, 'stopSession', { sessionId }),
|
||||
uiAvailable
|
||||
? this.sendMessageToTab(
|
||||
tabId,
|
||||
{ action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId },
|
||||
0,
|
||||
)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
const missing = listMissingRequestIds(requests, pickedById);
|
||||
const result: ElementPickerResult = {
|
||||
success: final.success,
|
||||
sessionId,
|
||||
timeoutMs,
|
||||
cancelled: final.cancelled,
|
||||
timedOut: final.timedOut,
|
||||
missingRequestIds: missing.length > 0 ? missing : undefined,
|
||||
results: buildResultItems(requests, pickedById),
|
||||
};
|
||||
|
||||
resolveResult?.(result);
|
||||
};
|
||||
|
||||
// Handle messages from content scripts
|
||||
const onRuntimeMessage = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: unknown) => void,
|
||||
): boolean | void => {
|
||||
const senderTabId = sender?.tab?.id;
|
||||
if (senderTabId !== tabId) return;
|
||||
|
||||
const msg = message as Partial<PickerUiEvent & PickerFrameEvent> | undefined;
|
||||
if (!msg || msg.sessionId !== sessionId) return;
|
||||
|
||||
// Handle frame events (element selection)
|
||||
if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_FRAME_EVENT) {
|
||||
if (msg.event === 'cancel') {
|
||||
void finish({ success: false, cancelled: true });
|
||||
sendResponse?.({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.event === 'selected') {
|
||||
const requestId = toTrimmedString(msg.requestId);
|
||||
const frameId = typeof sender.frameId === 'number' ? sender.frameId : 0;
|
||||
|
||||
// Validate request ID
|
||||
const reqExists = requestId && requests.some((r) => r.id === requestId);
|
||||
if (!reqExists) {
|
||||
sendResponse?.({ success: false, error: 'Unknown requestId' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate element data
|
||||
const raw = (msg.element || {}) as Partial<Omit<PickedElement, 'frameId'>>;
|
||||
const ref = toTrimmedString(raw.ref);
|
||||
if (!ref) {
|
||||
sendResponse?.({ success: false, error: 'Missing element.ref' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build picked element with frameId
|
||||
const selector = toTrimmedString(raw.selector);
|
||||
const rect = raw.rect as PickedElement['rect'] | undefined;
|
||||
const center = raw.center as PickedElement['center'] | undefined;
|
||||
const picked: PickedElement = {
|
||||
ref,
|
||||
selector,
|
||||
selectorType: 'css',
|
||||
rect: rect && typeof rect === 'object' ? rect : { x: 0, y: 0, width: 0, height: 0 },
|
||||
center: center && typeof center === 'object' ? center : { x: 0, y: 0 },
|
||||
text: typeof raw.text === 'string' ? raw.text : undefined,
|
||||
tagName: typeof raw.tagName === 'string' ? raw.tagName : undefined,
|
||||
frameId,
|
||||
};
|
||||
|
||||
pickedById.set(requestId, picked);
|
||||
uiErrorMessage = null;
|
||||
|
||||
// Auto-advance to next missing request
|
||||
const missing = listMissingRequestIds(requests, pickedById);
|
||||
const next = missing.length > 0 ? missing[0] : null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (next) {
|
||||
await setActiveRequest(next);
|
||||
} else {
|
||||
// All selected: update UI (user still needs to confirm)
|
||||
await sendUiUpdate();
|
||||
// If UI is unavailable, auto-confirm
|
||||
if (!uiAvailable) {
|
||||
await finish({ success: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
})();
|
||||
|
||||
sendResponse?.({ success: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle UI events (cancel, confirm, etc.)
|
||||
if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT) {
|
||||
if (msg.event === 'cancel') {
|
||||
void finish({ success: false, cancelled: true });
|
||||
sendResponse?.({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.event === 'confirm') {
|
||||
const missing = listMissingRequestIds(requests, pickedById);
|
||||
if (missing.length > 0) {
|
||||
uiErrorMessage = `Please select all elements: missing ${missing.join(', ')}`;
|
||||
void sendUiUpdate();
|
||||
sendResponse?.({ success: false, error: 'missing_selections', missing });
|
||||
return true;
|
||||
}
|
||||
void finish({ success: true });
|
||||
sendResponse?.({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.event === 'set_active_request') {
|
||||
const requestId = toTrimmedString(msg.requestId);
|
||||
if (!requestId || !requests.some((r) => r.id === requestId)) {
|
||||
sendResponse?.({ success: false, error: 'Unknown requestId' });
|
||||
return true;
|
||||
}
|
||||
void setActiveRequest(requestId);
|
||||
sendResponse?.({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.event === 'clear_selection') {
|
||||
const requestId = toTrimmedString(msg.requestId);
|
||||
if (!requestId || !requests.some((r) => r.id === requestId)) {
|
||||
sendResponse?.({ success: false, error: 'Unknown requestId' });
|
||||
return true;
|
||||
}
|
||||
pickedById.delete(requestId);
|
||||
uiErrorMessage = null;
|
||||
void setActiveRequest(requestId);
|
||||
sendResponse?.({ success: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Ensure UI content script is ready (ping + inject fallback)
|
||||
const ensureUiReady = async (): Promise<boolean> => {
|
||||
// Try to ping UI content script with retries
|
||||
const pingWithTimeout = async (timeoutMs = 500): Promise<boolean> => {
|
||||
try {
|
||||
const resp = await Promise.race([
|
||||
this.sendMessageToTab(
|
||||
tabId,
|
||||
{ action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING },
|
||||
0,
|
||||
),
|
||||
new Promise<null>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Ping timeout')), timeoutMs),
|
||||
),
|
||||
]);
|
||||
return resp?.success === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// First ping attempt (content script may already be loaded)
|
||||
if (await pingWithTimeout()) return true;
|
||||
|
||||
// Try to inject UI content script as fallback
|
||||
// Try multiple possible paths (production vs dev builds)
|
||||
const possiblePaths = ['content-scripts/element-picker.js', 'element-picker.js'];
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, frameIds: [0] },
|
||||
files: [path],
|
||||
injectImmediately: true,
|
||||
} as any);
|
||||
// Wait a bit for script to initialize
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
// Check if injection worked
|
||||
if (await pingWithTimeout(300)) return true;
|
||||
} catch (e) {
|
||||
// Try next path
|
||||
console.debug(`[ElementPicker] Path ${path} failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt with longer timeout (in case of slow page)
|
||||
return pingWithTimeout(1000);
|
||||
};
|
||||
|
||||
const uiReady = await ensureUiReady();
|
||||
if (!uiReady) {
|
||||
console.error('[ElementPicker] UI not available after all attempts');
|
||||
return createErrorResponse(
|
||||
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Element Picker UI is not available. This may happen if: (1) The page blocks content scripts, (2) You're using dev mode - try restarting the dev server or use production build, (3) The page needs to be refreshed.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Show UI in top frame (must receive success:true)
|
||||
try {
|
||||
const showResp = await this.sendMessageToTab(
|
||||
tabId,
|
||||
{
|
||||
action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW,
|
||||
sessionId,
|
||||
requests,
|
||||
activeRequestId,
|
||||
deadlineTs,
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (showResp?.success !== true) {
|
||||
throw new Error('UI did not acknowledge show message');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ElementPicker] UI show failed:', e);
|
||||
return createErrorResponse(
|
||||
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to show Element Picker UI. Please refresh the page and try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Inject picker scripts and start selection engine in all frames
|
||||
await this.injectPickerScripts(tabId);
|
||||
await this.callPickerApi(tabId, 'startSession', { sessionId, activeRequestId });
|
||||
|
||||
// Register message listener
|
||||
chrome.runtime.onMessage.addListener(onRuntimeMessage);
|
||||
|
||||
// Create result promise
|
||||
const resultPromise = new Promise<ElementPickerResult>((resolve) => {
|
||||
resolveResult = resolve;
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
timer = setTimeout(() => {
|
||||
void finish({ success: false, timedOut: true });
|
||||
}, timeoutMs);
|
||||
|
||||
// Initial UI update
|
||||
void sendUiUpdate();
|
||||
|
||||
// Wait for result
|
||||
const result = await resultPromise;
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false };
|
||||
} catch (error) {
|
||||
console.error('Error in element picker tool:', error);
|
||||
// Cleanup on error
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
this.callPickerApi(tabId, 'stopSession', { sessionId }),
|
||||
this.sendMessageToTab(
|
||||
tabId,
|
||||
{ action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId },
|
||||
0,
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
return createErrorResponse(
|
||||
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const elementPickerTool = new ElementPickerTool();
|
||||
@@ -4,6 +4,7 @@ export { vectorSearchTabsContentTool as searchTabsContentTool } from './vector-s
|
||||
export { screenshotTool } from './screenshot';
|
||||
export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher';
|
||||
export { clickTool, fillTool } from './interaction';
|
||||
export { elementPickerTool } from './element-picker';
|
||||
export { networkRequestTool } from './network-request';
|
||||
export { networkCaptureTool } from './network-capture';
|
||||
// Legacy exports (for internal use by networkCaptureTool)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
|
||||
import {
|
||||
canvasToDataURL,
|
||||
createImageBitmapFromUrl,
|
||||
@@ -58,6 +57,51 @@ interface ScreenshotToolParams {
|
||||
maxHeight?: number; // Maximum height to capture in pixels (for infinite scroll pages)
|
||||
}
|
||||
|
||||
/** Page details returned by screenshot-helper content script */
|
||||
interface ScreenshotPageDetails {
|
||||
totalWidth: number;
|
||||
totalHeight: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
devicePixelRatio: number;
|
||||
currentScrollX: number;
|
||||
currentScrollY: number;
|
||||
}
|
||||
|
||||
const PAGE_DETAILS_REQUIRED_FIELDS: Array<keyof ScreenshotPageDetails> = [
|
||||
'totalWidth',
|
||||
'totalHeight',
|
||||
'viewportWidth',
|
||||
'viewportHeight',
|
||||
'devicePixelRatio',
|
||||
'currentScrollX',
|
||||
'currentScrollY',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates and asserts that the response from content script contains valid page details
|
||||
*/
|
||||
function assertValidPageDetails(details: unknown): ScreenshotPageDetails {
|
||||
if (!details || typeof details !== 'object') {
|
||||
throw new Error(
|
||||
'Screenshot helper did not respond. The content script may not be injected or cannot run on this page.',
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = details as Partial<ScreenshotPageDetails>;
|
||||
const invalidFields = PAGE_DETAILS_REQUIRED_FIELDS.filter(
|
||||
(field) => typeof candidate[field] !== 'number' || !Number.isFinite(candidate[field]),
|
||||
);
|
||||
|
||||
if (invalidFields.length > 0) {
|
||||
throw new Error(
|
||||
`Screenshot helper returned invalid page details (missing/invalid: ${invalidFields.join(', ')}).`,
|
||||
);
|
||||
}
|
||||
|
||||
return candidate as ScreenshotPageDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for capturing screenshots of web pages
|
||||
*/
|
||||
@@ -98,16 +142,18 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
|
||||
let finalImageWidthCss: number | undefined;
|
||||
let finalImageHeightCss: number | undefined;
|
||||
const results: any = { base64: null, fileSaved: false };
|
||||
let originalScroll = { x: 0, y: 0 };
|
||||
let originalScroll: { x: number; y: number } | null = null;
|
||||
let didPreparePage = false;
|
||||
let pageDetails: ScreenshotPageDetails | undefined;
|
||||
|
||||
try {
|
||||
const background = args.background === true;
|
||||
// If we need content-script assisted capture (element/fullPage), we may need the tab active in its window.
|
||||
// For simple viewport-only capture without selector/fullPage and background=true, prefer CDP capture.
|
||||
const needInjection = fullPage || !!selector;
|
||||
if (!needInjection && background) {
|
||||
// CDP path: background=true with simple viewport capture (no fullPage, no selector)
|
||||
const canUseCdpCapture = background && !fullPage && !selector;
|
||||
|
||||
// === Path 1: CDP viewport capture (no content script needed) ===
|
||||
if (canUseCdpCapture) {
|
||||
try {
|
||||
// CDP capture of current viewport without focusing/activating
|
||||
const tabId = tab.id!;
|
||||
const { cdpSessionManager } = await import('@/utils/cdp-session-manager');
|
||||
await cdpSessionManager.withSession(tabId, 'screenshot', async () => {
|
||||
@@ -126,73 +172,84 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
|
||||
const shot: any = await cdpSessionManager.sendCommand(tabId, 'Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
});
|
||||
finalImageDataUrl = `data:image/png;base64,${shot?.data || ''}`;
|
||||
const base64Data = typeof shot?.data === 'string' ? shot.data : '';
|
||||
if (!base64Data) {
|
||||
throw new Error('CDP Page.captureScreenshot returned empty data');
|
||||
}
|
||||
finalImageDataUrl = `data:image/png;base64,${base64Data}`;
|
||||
finalImageWidthCss = Math.round(viewport.clientWidth || 800);
|
||||
finalImageHeightCss = Math.round(viewport.clientHeight || 600);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('CDP viewport capture failed, falling back to captureVisibleTab path:', e);
|
||||
console.warn('CDP viewport capture failed, falling back to helper path:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalImageDataUrl && needInjection) {
|
||||
// === Path 2: Helper-assisted capture (requires content script) ===
|
||||
if (!finalImageDataUrl) {
|
||||
// Always inject helper when we need pageDetails
|
||||
await this.injectContentScript(tab.id!, ['inject-scripts/screenshot-helper.js']);
|
||||
}
|
||||
// Wait for script initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
|
||||
// 1. Prepare page (hide scrollbars, potentially fixed elements)
|
||||
await this.sendMessageToTab(tab.id!, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE,
|
||||
options: { fullPage },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
|
||||
|
||||
// Get initial page details, including original scroll position
|
||||
const pageDetails = await this.sendMessageToTab(tab.id!, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS,
|
||||
});
|
||||
originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY };
|
||||
|
||||
if (fullPage) {
|
||||
this.logInfo('Capturing full page...');
|
||||
const dataUrl = await this._captureFullPage(tab.id!, args, pageDetails);
|
||||
finalImageDataUrl = dataUrl;
|
||||
// For full page, compute final CSS size from provided width/height fallback to pageDetails
|
||||
if (args.width && args.height) {
|
||||
finalImageWidthCss = args.width;
|
||||
finalImageHeightCss = args.height;
|
||||
} else if (args.width && !args.height) {
|
||||
finalImageWidthCss = args.width;
|
||||
// height will be scaled by aspect ratio; approximate with page ratio
|
||||
const ratio = pageDetails.totalHeight / pageDetails.totalWidth;
|
||||
finalImageHeightCss = Math.round(args.width * ratio);
|
||||
} else if (!args.width && args.height) {
|
||||
finalImageHeightCss = args.height;
|
||||
const ratio = pageDetails.totalWidth / pageDetails.totalHeight;
|
||||
finalImageWidthCss = Math.round(args.height * ratio);
|
||||
} else {
|
||||
finalImageWidthCss = pageDetails.totalWidth;
|
||||
finalImageHeightCss = pageDetails.totalHeight;
|
||||
// Prepare page (hide scrollbars, handle fixed elements)
|
||||
const prepareResp = await this.sendMessageToTab(tab.id!, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE,
|
||||
options: { fullPage },
|
||||
});
|
||||
if (!prepareResp || prepareResp.success !== true) {
|
||||
throw new Error(
|
||||
'Screenshot helper did not acknowledge page preparation. The content script may not be injected or cannot run on this page.',
|
||||
);
|
||||
}
|
||||
} else if (selector) {
|
||||
this.logInfo(`Capturing element: ${selector}`);
|
||||
const cropped = await this._captureElement(tab.id!, args, pageDetails.devicePixelRatio);
|
||||
finalImageDataUrl = cropped;
|
||||
// For element capture, if target width/height provided, respect them; otherwise use element rect size
|
||||
if (args.width && args.height) {
|
||||
finalImageWidthCss = args.width;
|
||||
finalImageHeightCss = args.height;
|
||||
didPreparePage = true;
|
||||
|
||||
// Get page details with validation
|
||||
const rawPageDetails = await this.sendMessageToTab(tab.id!, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS,
|
||||
});
|
||||
pageDetails = assertValidPageDetails(rawPageDetails);
|
||||
originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY };
|
||||
|
||||
if (fullPage) {
|
||||
this.logInfo('Capturing full page...');
|
||||
finalImageDataUrl = await this._captureFullPage(tab.id!, args, pageDetails);
|
||||
// Compute final CSS size
|
||||
if (args.width && args.height) {
|
||||
finalImageWidthCss = args.width;
|
||||
finalImageHeightCss = args.height;
|
||||
} else if (args.width && !args.height) {
|
||||
finalImageWidthCss = args.width;
|
||||
const ratio = pageDetails.totalHeight / pageDetails.totalWidth;
|
||||
finalImageHeightCss = Math.round(args.width * ratio);
|
||||
} else if (!args.width && args.height) {
|
||||
finalImageHeightCss = args.height;
|
||||
const ratio = pageDetails.totalWidth / pageDetails.totalHeight;
|
||||
finalImageWidthCss = Math.round(args.height * ratio);
|
||||
} else {
|
||||
finalImageWidthCss = pageDetails.totalWidth;
|
||||
finalImageHeightCss = pageDetails.totalHeight;
|
||||
}
|
||||
} else if (selector) {
|
||||
this.logInfo(`Capturing element: ${selector}`);
|
||||
finalImageDataUrl = await this._captureElement(
|
||||
tab.id!,
|
||||
args,
|
||||
pageDetails.devicePixelRatio,
|
||||
);
|
||||
if (args.width && args.height) {
|
||||
finalImageWidthCss = args.width;
|
||||
finalImageHeightCss = args.height;
|
||||
} else {
|
||||
finalImageWidthCss = pageDetails.viewportWidth;
|
||||
finalImageHeightCss = pageDetails.viewportHeight;
|
||||
}
|
||||
} else {
|
||||
// Fallback to visible element rect (already used for crop)
|
||||
// We do not have easy access to rect here; use viewport as conservative default
|
||||
// Visible area only
|
||||
this.logInfo('Capturing visible area...');
|
||||
finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
|
||||
finalImageWidthCss = pageDetails.viewportWidth;
|
||||
finalImageHeightCss = pageDetails.viewportHeight;
|
||||
}
|
||||
} else if (!finalImageDataUrl) {
|
||||
// Visible area only
|
||||
this.logInfo('Capturing visible area...');
|
||||
finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
|
||||
finalImageWidthCss = pageDetails.viewportWidth;
|
||||
finalImageHeightCss = pageDetails.viewportHeight;
|
||||
}
|
||||
|
||||
if (!finalImageDataUrl) {
|
||||
@@ -209,12 +266,15 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Use pageDetails if available, otherwise fall back to final image dimensions
|
||||
const viewportWidth = pageDetails?.viewportWidth ?? finalImageWidthCss;
|
||||
const viewportHeight = pageDetails?.viewportHeight ?? finalImageHeightCss;
|
||||
screenshotContextManager.setContext(tab.id!, {
|
||||
screenshotWidth: finalImageWidthCss,
|
||||
screenshotHeight: finalImageHeightCss,
|
||||
viewportWidth: pageDetails.viewportWidth,
|
||||
viewportHeight: pageDetails.viewportHeight,
|
||||
devicePixelRatio: pageDetails.devicePixelRatio,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
devicePixelRatio: pageDetails?.devicePixelRatio,
|
||||
hostname,
|
||||
});
|
||||
}
|
||||
@@ -287,15 +347,21 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
|
||||
`Screenshot error: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
||||
);
|
||||
} finally {
|
||||
// 3. Reset page
|
||||
try {
|
||||
await this.sendMessageToTab(tab.id!, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE,
|
||||
scrollX: originalScroll.x,
|
||||
scrollY: originalScroll.y,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to reset page, tab might have closed:', err);
|
||||
// 3. Reset page only if we prepared it
|
||||
if (didPreparePage) {
|
||||
try {
|
||||
// Only include scroll position if we successfully captured it
|
||||
const resetMessage: Record<string, unknown> = {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE,
|
||||
};
|
||||
if (originalScroll) {
|
||||
resetMessage.scrollX = originalScroll.x;
|
||||
resetMessage.scrollY = originalScroll.y;
|
||||
}
|
||||
await this.sendMessageToTab(tab.id!, resetMessage);
|
||||
} catch (err) {
|
||||
console.warn('Failed to reset page, tab might have closed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -987,6 +987,86 @@ export function initWebEditorListeners(): void {
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// WEB_EDITOR_OPEN_SOURCE: Open component source file in VSCode
|
||||
// =====================================================================
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE) {
|
||||
(async () => {
|
||||
try {
|
||||
const payload = message.payload as { debugSource?: unknown } | undefined;
|
||||
const debugSource = payload?.debugSource;
|
||||
|
||||
if (!debugSource || typeof debugSource !== 'object') {
|
||||
return sendResponse({ success: false, error: 'debugSource is required' });
|
||||
}
|
||||
|
||||
const rec = debugSource as Record<string, unknown>;
|
||||
const file = typeof rec.file === 'string' ? rec.file.trim() : '';
|
||||
if (!file) {
|
||||
return sendResponse({ success: false, error: 'debugSource.file is required' });
|
||||
}
|
||||
|
||||
// Read server port and selected project
|
||||
const stored = await chrome.storage.local.get([
|
||||
'nativeServerPort',
|
||||
'agent-selected-project-id',
|
||||
]);
|
||||
const portRaw = stored.nativeServerPort;
|
||||
const port = Number.isFinite(Number(portRaw))
|
||||
? Number(portRaw)
|
||||
: DEFAULT_NATIVE_SERVER_PORT;
|
||||
const projectId = stored['agent-selected-project-id'];
|
||||
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return sendResponse({
|
||||
success: false,
|
||||
error: 'No project selected. Please select a project in AgentChat first.',
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare line/column
|
||||
const lineRaw = Number(rec.line);
|
||||
const columnRaw = Number(rec.column);
|
||||
const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined;
|
||||
const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined;
|
||||
|
||||
// Call native-server to open file (server will validate project and path)
|
||||
const openResp = await fetch(
|
||||
`http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open-file`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filePath: file,
|
||||
line,
|
||||
column,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Try to parse JSON response for detailed error
|
||||
let result: { success: boolean; error?: string };
|
||||
try {
|
||||
result = await openResp.json();
|
||||
} catch {
|
||||
const text = await openResp.text().catch(() => '');
|
||||
result = {
|
||||
success: false,
|
||||
error: text || `HTTP ${openResp.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
sendResponse(result);
|
||||
} catch (err) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE) {
|
||||
getActiveTabId()
|
||||
.then(async (tabId) => {
|
||||
|
||||
@@ -70,6 +70,24 @@
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="top-btn"
|
||||
:class="{ active: triggerPanelVisible }"
|
||||
@click="triggerPanelVisible = !triggerPanelVisible"
|
||||
title="管理触发器"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
Triggers
|
||||
</button>
|
||||
<span class="divider-vert" />
|
||||
<button
|
||||
class="top-btn"
|
||||
@@ -155,6 +173,13 @@
|
||||
@remove-edge="store.removeEdge"
|
||||
/>
|
||||
|
||||
<TriggerPanel
|
||||
v-if="triggerPanelVisible && store.flowLocal?.id"
|
||||
class="floating-trigger"
|
||||
:flow-id="store.flowLocal.id"
|
||||
@close="triggerPanelVisible = false"
|
||||
/>
|
||||
|
||||
<div class="bottom-toolbar">
|
||||
<button class="toolbar-btn" @click="store.undo" title="撤销 (⌘/Ctrl+Z)">
|
||||
<svg
|
||||
@@ -246,8 +271,22 @@
|
||||
<script lang="ts" setup>
|
||||
// Dedicated full-page builder using the same inner components as popup modal
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';
|
||||
import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';
|
||||
import type {
|
||||
FlowId,
|
||||
NodeId,
|
||||
TriggerId,
|
||||
} from '@/entrypoints/background/record-replay-v3/domain/ids';
|
||||
import type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';
|
||||
import type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';
|
||||
import { useRRV3Rpc } from '@/entrypoints/shared/composables';
|
||||
import {
|
||||
flowV2ToV3ForRpc,
|
||||
flowV3ToV2ForBuilder,
|
||||
isFlowV3,
|
||||
extractFlowCandidates,
|
||||
} from '@/entrypoints/shared/utils';
|
||||
|
||||
import { useBuilderStore } from '@/entrypoints/popup/components/builder/store/useBuilderStore';
|
||||
import { validateFlow } from '@/entrypoints/popup/components/builder/model/validation';
|
||||
@@ -255,6 +294,7 @@ import Canvas from '@/entrypoints/popup/components/builder/components/Canvas.vue
|
||||
import Sidebar from '@/entrypoints/popup/components/builder/components/Sidebar.vue';
|
||||
import PropertyPanel from '@/entrypoints/popup/components/builder/components/PropertyPanel.vue';
|
||||
import EdgePropertyPanel from '@/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue';
|
||||
import TriggerPanel from '@/entrypoints/popup/components/builder/components/TriggerPanel.vue';
|
||||
|
||||
const title = ref('工作流编辑器');
|
||||
// theme state: persisted in localStorage and default to system preference
|
||||
@@ -270,6 +310,12 @@ function toggleTheme() {
|
||||
}
|
||||
const store = useBuilderStore();
|
||||
|
||||
// V3 RPC client
|
||||
const rpc = useRRV3Rpc({
|
||||
autoConnect: true,
|
||||
onError: (message) => pushToast(message, 'error'),
|
||||
});
|
||||
|
||||
// toast event bus (listen to rr_toast)
|
||||
type ToastItem = { id: string; message: string; level: 'info' | 'warn' | 'error' };
|
||||
const toasts = ref<ToastItem[]>([]);
|
||||
@@ -304,13 +350,17 @@ async function bootstrap() {
|
||||
const q = getQuery();
|
||||
if (q.flowId) {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_GET_FLOW,
|
||||
flowId: q.flowId,
|
||||
});
|
||||
if (res && res.success && res.flow) {
|
||||
store.initFromFlow(res.flow);
|
||||
title.value = `编辑:${res.flow.name || res.flow.id}`;
|
||||
await rpc.ensureConnected();
|
||||
const flowV3 = (await rpc.request('rr_v3.getFlow', {
|
||||
flowId: q.flowId as FlowId,
|
||||
})) as FlowV3 | null;
|
||||
|
||||
if (flowV3) {
|
||||
const { flow: flowV2, warnings } = flowV3ToV2ForBuilder(flowV3);
|
||||
warnings.forEach((w) => pushToast(w, 'warn'));
|
||||
store.initFromFlow(flowV2);
|
||||
title.value = `编辑:${flowV2.name || flowV2.id}`;
|
||||
|
||||
if (q.focus) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
@@ -320,26 +370,40 @@ async function bootstrap() {
|
||||
} catch {}
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
// Flow not found - notify user and initialize empty flow
|
||||
pushToast(`工作流 "${q.flowId}" 未找到,已创建新工作流`, 'warn');
|
||||
initEmptyFlow();
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
pushToast(`加载工作流失败:${e instanceof Error ? e.message : String(e)}`, 'error');
|
||||
initEmptyFlow();
|
||||
}
|
||||
} else if (q.new === '1') {
|
||||
// Initialize an empty flow
|
||||
const now = Date.now();
|
||||
const empty: FlowV2 = {
|
||||
id: `flow_${now}`,
|
||||
name: '新建工作流',
|
||||
version: 1,
|
||||
steps: [],
|
||||
variables: [],
|
||||
meta: {
|
||||
createdAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
} as any,
|
||||
} as any;
|
||||
store.initFromFlow(empty);
|
||||
initEmptyFlow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化一个空的工作流
|
||||
*/
|
||||
function initEmptyFlow() {
|
||||
const now = Date.now();
|
||||
const empty: FlowV2 = {
|
||||
id: `flow_${now}`,
|
||||
name: '新建工作流',
|
||||
version: 1,
|
||||
steps: [],
|
||||
variables: [],
|
||||
meta: {
|
||||
createdAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
} as any,
|
||||
} as any;
|
||||
store.initFromFlow(empty);
|
||||
title.value = '新建工作流';
|
||||
}
|
||||
|
||||
// Builder helpers mostly ported from modal component
|
||||
const selectedId = computed<string | null>(() => (store.activeNodeId as any)?.value ?? null);
|
||||
const selectedEdgeId = computed<string | null>(() => (store.activeEdgeId as any)?.value ?? null);
|
||||
@@ -393,6 +457,9 @@ function fitAll() {
|
||||
fitSeq.value++;
|
||||
}
|
||||
|
||||
// trigger panel state
|
||||
const triggerPanelVisible = ref(false);
|
||||
|
||||
// rename dialog
|
||||
const renameVisible = ref(false);
|
||||
const renameName = ref('');
|
||||
@@ -408,171 +475,295 @@ function applyRename() {
|
||||
renameVisible.value = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
// Use exportFlowForSave to properly handle subflow editing:
|
||||
// - Flushes current canvas state back to flowLocal (including subflow edits)
|
||||
// - Returns deep copy with correct nodes/edges from flowLocal
|
||||
// Note: steps are NOT generated - nodes/edges are the source of truth
|
||||
const result = store.exportFlowForSave();
|
||||
await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW, flow: result });
|
||||
/**
|
||||
* 保存 Flow 到 V3 RPC
|
||||
* @returns 保存成功返回 FlowV3,失败返回 null
|
||||
*/
|
||||
async function save(): Promise<FlowV3 | null> {
|
||||
try {
|
||||
// Use main flow nodes for trigger sync (not current canvas which may be subflow)
|
||||
await syncTriggersAndSchedules(result.id, result.nodes || []);
|
||||
} catch {}
|
||||
// Use exportFlowForSave to properly handle subflow editing:
|
||||
// - Flushes current canvas state back to flowLocal (including subflow edits)
|
||||
// - Returns deep copy with correct nodes/edges from flowLocal
|
||||
// Note: steps are NOT generated - nodes/edges are the source of truth
|
||||
const flowV2 = store.exportFlowForSave();
|
||||
await rpc.ensureConnected();
|
||||
|
||||
// Convert V2 -> V3 for RPC
|
||||
const { flow: flowV3, warnings: convWarnings } = flowV2ToV3ForRpc(flowV2);
|
||||
convWarnings.forEach((w) => pushToast(w, 'warn'));
|
||||
|
||||
// Save via RPC (cast FlowV3 to JsonObject for RPC compatibility)
|
||||
const saved = (await rpc.request('rr_v3.saveFlow', {
|
||||
flow: flowV3 as unknown as JsonObject,
|
||||
})) as unknown as FlowV3;
|
||||
|
||||
// Sync timestamps back to local state
|
||||
if (!store.flowLocal.meta) {
|
||||
(store.flowLocal as any).meta = {};
|
||||
}
|
||||
(store.flowLocal as any).meta.createdAt = saved.createdAt;
|
||||
(store.flowLocal as any).meta.updatedAt = saved.updatedAt;
|
||||
|
||||
// Sync triggers (best-effort, don't block save result)
|
||||
try {
|
||||
await syncTriggersAndSchedules(flowV2.id, flowV2.nodes || []);
|
||||
} catch {}
|
||||
|
||||
return saved;
|
||||
} catch (e) {
|
||||
pushToast(`保存失败:${e instanceof Error ? e.message : String(e)}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function trigId(flowId: string, nodeId: string, kind: string) {
|
||||
return `trg_${flowId}_${nodeId}_${kind}`;
|
||||
// ==================== Trigger Sync Helpers ====================
|
||||
|
||||
function trigId(flowId: string, nodeId: string, kind: string): TriggerId {
|
||||
return `trg_${flowId}_${nodeId}_${kind}` as TriggerId;
|
||||
}
|
||||
|
||||
function schId(flowId: string, nodeId: string, idx: number) {
|
||||
return `sch_${flowId}_${nodeId}_${idx}`;
|
||||
function schId(flowId: string, nodeId: string, idx: number): TriggerId {
|
||||
return `sch_${flowId}_${nodeId}_${idx}` as TriggerId;
|
||||
}
|
||||
|
||||
async function syncTriggersAndSchedules(flowId: string, nodes: any[]) {
|
||||
const triggersNeeded: any[] = [];
|
||||
const schedulesNeeded: any[] = [];
|
||||
/**
|
||||
* 将 V2 schedule 配置转换为 cron 表达式
|
||||
* @returns cron 表达式或 null(如果无法转换)
|
||||
*/
|
||||
function scheduleToCron(schedule: { type?: string; when?: string }): string | null {
|
||||
if (!schedule) return null;
|
||||
|
||||
const type = String(schedule.type || '').trim();
|
||||
const when = String(schedule.when || '').trim();
|
||||
|
||||
if (type === 'interval') {
|
||||
const minutesRaw = Number(when);
|
||||
if (!Number.isFinite(minutesRaw)) return null;
|
||||
const minutes = Math.max(1, Math.round(minutesRaw));
|
||||
if (minutes < 60) return `*/${minutes} * * * *`;
|
||||
const hours = Math.max(1, Math.round(minutes / 60));
|
||||
return `0 */${hours} * * *`;
|
||||
}
|
||||
|
||||
if (type === 'daily') {
|
||||
const [hRaw, mRaw] = when.split(':');
|
||||
const hourRaw = Number(hRaw);
|
||||
const minuteRaw = Number(mRaw);
|
||||
if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null;
|
||||
const hour = Math.min(23, Math.max(0, Math.floor(hourRaw)));
|
||||
const minute = Math.min(59, Math.max(0, Math.floor(minuteRaw)));
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
// V3 cron 不支持 'once' 一次性定时
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 trigger 节点配置同步触发器到 V3 存储
|
||||
* @description V2 schedules 会转换为 V3 cron triggers
|
||||
*/
|
||||
async function syncTriggersAndSchedules(flowId: string, nodes: unknown[]) {
|
||||
const triggersNeeded: TriggerSpec[] = [];
|
||||
const tnodes = (nodes || []).filter((n: any) => n && n.type === 'trigger');
|
||||
for (const n of tnodes) {
|
||||
|
||||
for (const n of tnodes as any[]) {
|
||||
const cfg = n.config || {};
|
||||
const enabled = cfg.enabled !== false;
|
||||
|
||||
// URL trigger
|
||||
if (cfg.modes?.url && Array.isArray(cfg.url?.rules) && cfg.url.rules.length) {
|
||||
triggersNeeded.push({
|
||||
id: trigId(flowId, n.id, 'url'),
|
||||
type: 'url',
|
||||
kind: 'url',
|
||||
enabled,
|
||||
flowId,
|
||||
flowId: flowId as FlowId,
|
||||
match: cfg.url.rules,
|
||||
});
|
||||
}
|
||||
|
||||
// Context menu trigger
|
||||
if (cfg.modes?.contextMenu && cfg.contextMenu?.title) {
|
||||
triggersNeeded.push({
|
||||
id: trigId(flowId, n.id, 'menu'),
|
||||
type: 'contextMenu',
|
||||
kind: 'contextMenu',
|
||||
enabled,
|
||||
flowId,
|
||||
flowId: flowId as FlowId,
|
||||
title: cfg.contextMenu.title,
|
||||
contexts: cfg.contextMenu.contexts || ['all'],
|
||||
contexts: (Array.isArray(cfg.contextMenu.contexts)
|
||||
? cfg.contextMenu.contexts
|
||||
: ['all']
|
||||
).map(String),
|
||||
});
|
||||
}
|
||||
|
||||
// Command trigger
|
||||
if (cfg.modes?.command && cfg.command?.commandKey) {
|
||||
triggersNeeded.push({
|
||||
id: trigId(flowId, n.id, 'cmd'),
|
||||
type: 'command',
|
||||
kind: 'command',
|
||||
enabled,
|
||||
flowId,
|
||||
flowId: flowId as FlowId,
|
||||
commandKey: String(cfg.command.commandKey),
|
||||
});
|
||||
}
|
||||
|
||||
// DOM trigger
|
||||
if (cfg.modes?.dom && cfg.dom?.selector) {
|
||||
const debounceMsRaw = Number(cfg.dom.debounceMs);
|
||||
triggersNeeded.push({
|
||||
id: trigId(flowId, n.id, 'dom'),
|
||||
type: 'dom',
|
||||
kind: 'dom',
|
||||
enabled,
|
||||
flowId,
|
||||
selector: cfg.dom.selector,
|
||||
flowId: flowId as FlowId,
|
||||
selector: String(cfg.dom.selector),
|
||||
appear: cfg.dom.appear !== false,
|
||||
once: cfg.dom.once !== false,
|
||||
debounceMs: Number(cfg.dom.debounceMs ?? 800),
|
||||
debounceMs: Number.isFinite(debounceMsRaw) ? debounceMsRaw : 800,
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule -> Cron trigger (V3 converts schedules to cron)
|
||||
if (cfg.modes?.schedule && Array.isArray(cfg.schedules)) {
|
||||
cfg.schedules.forEach((s: any, i: number) => {
|
||||
const id = schId(flowId, n.id, i);
|
||||
schedulesNeeded.push({
|
||||
id,
|
||||
flowId,
|
||||
type: s.type || 'interval',
|
||||
when: String(s.when || ''),
|
||||
enabled: s.enabled !== false,
|
||||
const cron = scheduleToCron(s);
|
||||
if (!cron) {
|
||||
const scheduleType = String(s?.type || 'unknown');
|
||||
if (scheduleType === 'once') {
|
||||
pushToast(
|
||||
`节点 ${n.id} 的定时 #${i + 1}: V3 暂不支持一次性定时(once),已跳过`,
|
||||
'warn',
|
||||
);
|
||||
} else {
|
||||
pushToast(
|
||||
`节点 ${n.id} 的定时 #${i + 1}: 无法转换为 cron(type=${scheduleType}),已跳过`,
|
||||
'warn',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
triggersNeeded.push({
|
||||
id: schId(flowId, n.id, i),
|
||||
kind: 'cron',
|
||||
enabled: enabled && s?.enabled !== false,
|
||||
flowId: flowId as FlowId,
|
||||
cron,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// sync triggers
|
||||
|
||||
// Sync triggers via V3 RPC
|
||||
try {
|
||||
const list =
|
||||
(await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_TRIGGERS })) || {};
|
||||
const existing: any[] = list.triggers || [];
|
||||
const mine = existing.filter((x) => String(x.flowId) === String(flowId));
|
||||
const needIds = new Set(triggersNeeded.map((t) => t.id));
|
||||
// save or update
|
||||
for (const t of triggersNeeded) {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER,
|
||||
trigger: t,
|
||||
});
|
||||
}
|
||||
// delete stale
|
||||
for (const t of mine) {
|
||||
if (!needIds.has(t.id)) {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER,
|
||||
id: t.id,
|
||||
});
|
||||
await rpc.ensureConnected();
|
||||
|
||||
// Get existing triggers for this flow
|
||||
const existing = (await rpc.request('rr_v3.listTriggers', {
|
||||
flowId: flowId as FlowId,
|
||||
})) as TriggerSpec[] | null;
|
||||
|
||||
const existingById = new Map((existing || []).map((t) => [t.id, t]));
|
||||
const neededIds = new Set(triggersNeeded.map((t) => t.id));
|
||||
|
||||
// Create or update triggers
|
||||
for (const trigger of triggersNeeded) {
|
||||
// Cast TriggerSpec to JsonObject for RPC compatibility
|
||||
const triggerPayload = trigger as unknown as JsonObject;
|
||||
if (existingById.has(trigger.id)) {
|
||||
await rpc.request('rr_v3.updateTrigger', { trigger: triggerPayload });
|
||||
} else {
|
||||
await rpc.request('rr_v3.createTrigger', { trigger: triggerPayload });
|
||||
}
|
||||
}
|
||||
await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS });
|
||||
} catch {}
|
||||
// sync schedules
|
||||
try {
|
||||
const list =
|
||||
(await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES })) ||
|
||||
{};
|
||||
const existing: any[] = list.schedules || [];
|
||||
const mine = existing.filter((x) => String(x.flowId) === String(flowId));
|
||||
const needIds = new Set(schedulesNeeded.map((s) => s.id));
|
||||
for (const s of schedulesNeeded) {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW,
|
||||
schedule: s,
|
||||
});
|
||||
}
|
||||
for (const s of mine) {
|
||||
if (!needIds.has(s.id)) {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW,
|
||||
scheduleId: s.id,
|
||||
});
|
||||
|
||||
// Delete stale triggers (only node-managed triggers, not panel-created ones like interval/once)
|
||||
// Node-managed trigger IDs have prefixes: trg_{flowId}_ or sch_{flowId}_
|
||||
const nodeManagedPrefixes = [`trg_${flowId}_`, `sch_${flowId}_`];
|
||||
const isNodeManaged = (triggerId: string) =>
|
||||
nodeManagedPrefixes.some((prefix) => triggerId.startsWith(prefix));
|
||||
|
||||
for (const existing of existingById.values()) {
|
||||
if (!neededIds.has(existing.id) && isNodeManaged(existing.id)) {
|
||||
await rpc.request('rr_v3.deleteTrigger', { triggerId: existing.id });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
// Best-effort sync - log for debugging but don't block user
|
||||
console.warn('[Builder] Trigger sync failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
// Save first to ensure latest changes are persisted
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
// Export the V3 flow directly (no need for separate RPC call)
|
||||
const blob = new Blob([JSON.stringify(saved, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
await chrome.downloads.download({
|
||||
url,
|
||||
filename: `${store.flowLocal.name || 'flow'}.json`,
|
||||
saveAs: true,
|
||||
} as chrome.downloads.DownloadOptions);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
pushToast(`导出失败:${e instanceof Error ? e.message : String(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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 && Array.isArray(res.flows) && res.flows.length) {
|
||||
store.initFromFlow(res.flows[0]);
|
||||
const parsed = JSON.parse(txt);
|
||||
const candidates = extractFlowCandidates(parsed);
|
||||
|
||||
if (!candidates.length) {
|
||||
pushToast('导入失败:未找到工作流数据', 'error');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
const first = candidates[0];
|
||||
|
||||
if (isFlowV3(first)) {
|
||||
// V3 format: save via RPC, then load into builder
|
||||
await rpc.ensureConnected();
|
||||
const saved = (await rpc.request('rr_v3.saveFlow', {
|
||||
flow: first as unknown as JsonObject,
|
||||
})) as unknown as FlowV3;
|
||||
|
||||
const { flow: flowV2, warnings } = flowV3ToV2ForBuilder(saved);
|
||||
warnings.forEach((w) => pushToast(w, 'warn'));
|
||||
store.initFromFlow(flowV2);
|
||||
title.value = `编辑:${flowV2.name || flowV2.id}`;
|
||||
|
||||
// Sync triggers
|
||||
try {
|
||||
await syncTriggersAndSchedules(flowV2.id, flowV2.nodes || []);
|
||||
} catch {}
|
||||
} else {
|
||||
// V2 format: load directly, then save to convert to V3
|
||||
store.initFromFlow(first as FlowV2);
|
||||
|
||||
// If V2 flow has steps but no nodes, trigger conversion
|
||||
if (
|
||||
Array.isArray((first as any)?.steps) &&
|
||||
(!Array.isArray((first as any)?.nodes) || (first as any).nodes.length === 0)
|
||||
) {
|
||||
store.importFromSteps();
|
||||
}
|
||||
|
||||
title.value = `编辑:${store.flowLocal.name || store.flowLocal.id}`;
|
||||
await save(); // Convert and save as V3
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast(`导入失败:${e instanceof Error ? e.message : String(e)}`, 'error');
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
@@ -580,25 +771,38 @@ async function onImport(e: Event) {
|
||||
|
||||
async function runFromSelected() {
|
||||
if (!selectedId.value || !store.flowLocal?.id) return;
|
||||
|
||||
try {
|
||||
await save();
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,
|
||||
flowId: store.flowLocal.id,
|
||||
options: { returnLogs: false, startNodeId: selectedId.value },
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
await rpc.ensureConnected();
|
||||
|
||||
// Skip trigger nodes (they can't be start nodes)
|
||||
const node = store.nodes.find((n) => n.id === selectedId.value) || null;
|
||||
const startNodeId = node?.type === 'trigger' ? undefined : selectedId.value;
|
||||
|
||||
await rpc.request('rr_v3.enqueueRun', {
|
||||
flowId: saved.id as FlowId,
|
||||
...(startNodeId ? { startNodeId: startNodeId as NodeId } : {}),
|
||||
});
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
pushToast(`运行失败:${e instanceof Error ? e.message : String(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll() {
|
||||
if (!store.flowLocal?.id) return;
|
||||
|
||||
try {
|
||||
await save();
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,
|
||||
flowId: store.flowLocal.id,
|
||||
options: { returnLogs: false },
|
||||
});
|
||||
} catch {}
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
await rpc.ensureConnected();
|
||||
await rpc.request('rr_v3.enqueueRun', { flowId: saved.id as FlowId });
|
||||
} catch (e) {
|
||||
pushToast(`运行失败:${e instanceof Error ? e.message : String(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Hotkeys
|
||||
@@ -647,15 +851,20 @@ const saveState = ref<'idle' | 'saving' | 'saved'>('idle');
|
||||
const saveLabel = computed(() =>
|
||||
saveState.value === 'saving' ? '保存中…' : saveState.value === 'saved' ? '已保存' : '',
|
||||
);
|
||||
let saveTimer: any = null;
|
||||
let statusTimer: any = null;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let statusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
saveState.value = 'saving';
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
save();
|
||||
const saved = await save();
|
||||
if (!saved) {
|
||||
saveState.value = 'idle';
|
||||
return;
|
||||
}
|
||||
saveState.value = 'saved';
|
||||
if (statusTimer) clearTimeout(statusTimer);
|
||||
statusTimer = setTimeout(() => (saveState.value = 'idle'), 1200);
|
||||
@@ -810,6 +1019,13 @@ function focusNode(id: string) {
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.floating-trigger {
|
||||
position: absolute;
|
||||
right: 400px; /* offset from property panel */
|
||||
top: 52px;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.bottom-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -878,6 +1094,11 @@ function focusNode(id: string) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.top-btn.active {
|
||||
background: var(--rr-accent);
|
||||
color: #fff;
|
||||
border-color: var(--rr-accent);
|
||||
}
|
||||
.top-btn.primary {
|
||||
background: var(--rr-accent);
|
||||
color: #fff;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Element Picker Content Script
|
||||
*
|
||||
* Renders the Element Picker Panel UI (Quick Panel style) and forwards UI events
|
||||
* to background while a chrome_request_element_selection session is active.
|
||||
*
|
||||
* This script only runs in the top frame and handles:
|
||||
* - Displaying the element picker panel UI
|
||||
* - Forwarding user actions (cancel, confirm, etc.) to background
|
||||
* - Receiving state updates from background
|
||||
*/
|
||||
|
||||
import {
|
||||
createElementPickerController,
|
||||
type ElementPickerController,
|
||||
type ElementPickerUiState,
|
||||
} from '@/shared/element-picker';
|
||||
import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import type { PickedElement } from 'chrome-mcp-shared';
|
||||
|
||||
// ============================================================
|
||||
// Message Types
|
||||
// ============================================================
|
||||
|
||||
interface UiShowMessage {
|
||||
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW;
|
||||
sessionId: string;
|
||||
requests: Array<{ id: string; name: string; description?: string }>;
|
||||
activeRequestId: string | null;
|
||||
deadlineTs: number;
|
||||
}
|
||||
|
||||
interface UiUpdateMessage {
|
||||
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE;
|
||||
sessionId: string;
|
||||
activeRequestId: string | null;
|
||||
selections: Record<string, PickedElement | null>;
|
||||
deadlineTs: number;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
interface UiHideMessage {
|
||||
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface UiPingMessage {
|
||||
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING;
|
||||
}
|
||||
|
||||
type PickerMessage = UiPingMessage | UiShowMessage | UiUpdateMessage | UiHideMessage;
|
||||
|
||||
// ============================================================
|
||||
// Content Script Definition
|
||||
// ============================================================
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_idle',
|
||||
|
||||
main() {
|
||||
// Only mount UI in the top frame
|
||||
if (window.top !== window) return;
|
||||
|
||||
let controller: ElementPickerController | null = null;
|
||||
let currentSessionId: string | null = null;
|
||||
|
||||
/**
|
||||
* Ensure the controller is created and configured.
|
||||
*/
|
||||
function ensureController(): ElementPickerController {
|
||||
if (controller) return controller;
|
||||
|
||||
controller = createElementPickerController({
|
||||
onCancel: () => {
|
||||
if (!currentSessionId) return;
|
||||
void chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
|
||||
sessionId: currentSessionId,
|
||||
event: 'cancel',
|
||||
});
|
||||
},
|
||||
onConfirm: () => {
|
||||
if (!currentSessionId) return;
|
||||
void chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
|
||||
sessionId: currentSessionId,
|
||||
event: 'confirm',
|
||||
});
|
||||
},
|
||||
onSetActiveRequest: (requestId: string) => {
|
||||
if (!currentSessionId) return;
|
||||
void chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
|
||||
sessionId: currentSessionId,
|
||||
event: 'set_active_request',
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
onClearSelection: (requestId: string) => {
|
||||
if (!currentSessionId) return;
|
||||
void chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
|
||||
sessionId: currentSessionId,
|
||||
event: 'clear_selection',
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages from background.
|
||||
*/
|
||||
function handleMessage(
|
||||
message: unknown,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: unknown) => void,
|
||||
): boolean | void {
|
||||
const msg = message as PickerMessage | undefined;
|
||||
if (!msg?.action) return false;
|
||||
|
||||
// Respond to ping (used by background to check if UI script is ready)
|
||||
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING) {
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show the picker panel
|
||||
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW) {
|
||||
const showMsg = msg as UiShowMessage;
|
||||
currentSessionId = typeof showMsg.sessionId === 'string' ? showMsg.sessionId : null;
|
||||
|
||||
if (!currentSessionId) {
|
||||
sendResponse({ success: false, error: 'Missing sessionId' });
|
||||
return true;
|
||||
}
|
||||
|
||||
const ctrl = ensureController();
|
||||
const initialState: ElementPickerUiState = {
|
||||
sessionId: currentSessionId,
|
||||
requests: Array.isArray(showMsg.requests) ? showMsg.requests : [],
|
||||
activeRequestId: showMsg.activeRequestId ?? null,
|
||||
selections: {},
|
||||
deadlineTs: typeof showMsg.deadlineTs === 'number' ? showMsg.deadlineTs : Date.now(),
|
||||
errorMessage: null,
|
||||
};
|
||||
ctrl.show(initialState);
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update the picker panel state
|
||||
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE) {
|
||||
const updateMsg = msg as UiUpdateMessage;
|
||||
|
||||
if (!currentSessionId || updateMsg.sessionId !== currentSessionId) {
|
||||
sendResponse({ success: false, error: 'Session mismatch' });
|
||||
return true;
|
||||
}
|
||||
|
||||
controller?.update({
|
||||
sessionId: currentSessionId,
|
||||
activeRequestId: updateMsg.activeRequestId ?? null,
|
||||
selections: updateMsg.selections || {},
|
||||
deadlineTs: updateMsg.deadlineTs,
|
||||
errorMessage: updateMsg.errorMessage ?? null,
|
||||
});
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hide the picker panel
|
||||
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE) {
|
||||
const hideMsg = msg as UiHideMessage;
|
||||
|
||||
// Best-effort hide even if session mismatches
|
||||
if (currentSessionId && hideMsg.sessionId !== currentSessionId) {
|
||||
// Log but don't fail
|
||||
console.warn('[ElementPicker] Session mismatch on hide, hiding anyway');
|
||||
}
|
||||
|
||||
controller?.hide();
|
||||
currentSessionId = null;
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Register message listener
|
||||
chrome.runtime.onMessage.addListener(handleMessage);
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('unload', () => {
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
controller?.dispose();
|
||||
controller = null;
|
||||
currentSessionId = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -73,19 +73,16 @@
|
||||
<h2 class="section-title">快捷工具</h2>
|
||||
<div class="rr-icon-buttons">
|
||||
<button
|
||||
class="rr-icon-btn rr-icon-btn-record has-tooltip"
|
||||
:class="{ 'rr-icon-btn-recording': rrRecording }"
|
||||
class="rr-icon-btn rr-icon-btn-record rr-icon-btn-coming-soon has-tooltip"
|
||||
@click="startRecording"
|
||||
:disabled="rrRecording"
|
||||
:data-tooltip="rrRecording ? '录制中...' : '开始录制'"
|
||||
data-tooltip="录制功能开发中"
|
||||
>
|
||||
<RecordIcon :recording="rrRecording" />
|
||||
<RecordIcon :recording="false" />
|
||||
</button>
|
||||
<button
|
||||
class="rr-icon-btn rr-icon-btn-stop has-tooltip"
|
||||
class="rr-icon-btn rr-icon-btn-stop rr-icon-btn-coming-soon has-tooltip"
|
||||
@click="stopRecording"
|
||||
:disabled="!rrRecording"
|
||||
data-tooltip="停止并保存"
|
||||
data-tooltip="录制功能开发中"
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
@@ -143,12 +140,15 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="entry-item" @click="openWorkflowSidepanel">
|
||||
<button class="entry-item entry-item-coming-soon" @click="openWorkflowSidepanel">
|
||||
<div class="entry-icon workflow">
|
||||
<WorkflowIcon />
|
||||
</div>
|
||||
<div class="entry-content">
|
||||
<span class="entry-title">工作流管理</span>
|
||||
<span class="entry-title">
|
||||
工作流管理
|
||||
<span class="coming-soon-badge">Coming Soon</span>
|
||||
</span>
|
||||
<span class="entry-desc">录制与回放自动化流程</span>
|
||||
</div>
|
||||
<svg
|
||||
@@ -311,6 +311,23 @@
|
||||
/>
|
||||
|
||||
<!-- 侧边栏承担工作流管理;编辑器在独立窗口中打开 -->
|
||||
|
||||
<!-- Coming Soon Toast -->
|
||||
<Transition name="toast">
|
||||
<div v-if="comingSoonToast.show" class="coming-soon-toast">
|
||||
<svg
|
||||
class="toast-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span>{{ comingSoonToast.feature }} 功能开发中,敬请期待</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -355,6 +372,16 @@ const { theme: agentTheme, initTheme } = useAgentTheme();
|
||||
// 当前视图状态:首页 or 本地模型页
|
||||
const currentView = ref<'home' | 'local-model'>('home');
|
||||
|
||||
// Coming Soon Toast
|
||||
const comingSoonToast = ref<{ show: boolean; feature: string }>({ show: false, feature: '' });
|
||||
|
||||
function showComingSoonToast(feature: string) {
|
||||
comingSoonToast.value = { show: true, feature };
|
||||
setTimeout(() => {
|
||||
comingSoonToast.value = { show: false, feature: '' };
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Record & Replay state
|
||||
const rrRecording = ref(false);
|
||||
const rrFlows = ref<
|
||||
@@ -405,31 +432,37 @@ function isFlowBoundToCurrent(flow: any) {
|
||||
|
||||
// 运行记录与覆盖项在侧边栏页面查看
|
||||
const startRecording = async () => {
|
||||
if (rrRecording.value) return;
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING,
|
||||
meta: { name: '新录制' },
|
||||
});
|
||||
rrRecording.value = !!(res && res.success);
|
||||
} catch (e) {
|
||||
console.error('开始录制失败:', e);
|
||||
rrRecording.value = false;
|
||||
}
|
||||
// TODO: 录制回放功能开发中,暂时拦截
|
||||
showComingSoonToast('录制回放');
|
||||
return;
|
||||
// if (rrRecording.value) return;
|
||||
// try {
|
||||
// const res = await chrome.runtime.sendMessage({
|
||||
// type: BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING,
|
||||
// meta: { name: '新录制' },
|
||||
// });
|
||||
// rrRecording.value = !!(res && res.success);
|
||||
// } catch (e) {
|
||||
// console.error('开始录制失败:', e);
|
||||
// rrRecording.value = false;
|
||||
// }
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!rrRecording.value) return;
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING,
|
||||
});
|
||||
rrRecording.value = false;
|
||||
if (res && res.success) await loadFlows();
|
||||
} catch (e) {
|
||||
console.error('停止录制失败:', e);
|
||||
rrRecording.value = false;
|
||||
}
|
||||
// TODO: 录制回放功能开发中,暂时拦截
|
||||
showComingSoonToast('录制回放');
|
||||
return;
|
||||
// if (!rrRecording.value) return;
|
||||
// try {
|
||||
// const res = await chrome.runtime.sendMessage({
|
||||
// type: BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING,
|
||||
// });
|
||||
// rrRecording.value = false;
|
||||
// if (res && res.success) await loadFlows();
|
||||
// } catch (e) {
|
||||
// console.error('停止录制失败:', e);
|
||||
// rrRecording.value = false;
|
||||
// }
|
||||
};
|
||||
|
||||
const runFlow = async (flowId: string) => {
|
||||
@@ -601,7 +634,9 @@ async function openSidepanelAndClose(tab: string) {
|
||||
|
||||
// Open sidepanel from popup for workflow management
|
||||
function openWorkflowSidepanel() {
|
||||
openSidepanelAndClose('workflows');
|
||||
// TODO: 工作流功能开发中,暂时拦截
|
||||
showComingSoonToast('工作流管理');
|
||||
// openSidepanelAndClose('workflows');
|
||||
}
|
||||
|
||||
// Open sidepanel for element marker management
|
||||
@@ -2423,6 +2458,18 @@ onUnmounted(() => {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* Coming Soon 按钮样式 */
|
||||
.rr-icon-btn-coming-soon {
|
||||
opacity: 0.5;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.rr-icon-btn-coming-soon:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* CSS Tooltip - instant display */
|
||||
.has-tooltip {
|
||||
position: relative;
|
||||
@@ -2566,4 +2613,67 @@ onUnmounted(() => {
|
||||
color: var(--ac-text-subtle, #a8a29e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Coming Soon Badge */
|
||||
.coming-soon-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--ac-accent, #d97757);
|
||||
background: rgba(217, 119, 87, 0.12);
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.entry-item-coming-soon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.entry-item-coming-soon:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Coming Soon Toast */
|
||||
.coming-soon-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--ac-text, #1a1a1a);
|
||||
color: var(--ac-text-inverse, #ffffff);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--ac-radius-card, 12px);
|
||||
box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.15));
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--ac-accent, #d97757);
|
||||
}
|
||||
|
||||
/* Toast transition */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,26 +8,28 @@
|
||||
<input class="search-input" placeholder="Insert node..." v-model="q" />
|
||||
</div>
|
||||
|
||||
<!-- Flow -->
|
||||
<div class="section-divider">
|
||||
<span class="divider-label">Flow</span>
|
||||
</div>
|
||||
<div class="nodes-section">
|
||||
<button
|
||||
v-for="n in filtered.Flow"
|
||||
:key="n.type"
|
||||
class="node-btn"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(n.type, $event)"
|
||||
@click="$emit('addNode', n.type)"
|
||||
:title="n.label"
|
||||
>
|
||||
<div class="btn-icon" :class="n.iconClass">
|
||||
<component :is="iconComp(n.type)" />
|
||||
</div>
|
||||
<span class="btn-label">{{ n.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Flow (only show if there are nodes in this category) -->
|
||||
<template v-if="filtered.Flow.length > 0">
|
||||
<div class="section-divider">
|
||||
<span class="divider-label">Flow</span>
|
||||
</div>
|
||||
<div class="nodes-section">
|
||||
<button
|
||||
v-for="n in filtered.Flow"
|
||||
:key="n.type"
|
||||
class="node-btn"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(n.type, $event)"
|
||||
@click="$emit('addNode', n.type)"
|
||||
:title="n.label"
|
||||
>
|
||||
<div class="btn-icon" :class="n.iconClass">
|
||||
<component :is="iconComp(n.type)" />
|
||||
</div>
|
||||
<span class="btn-label">{{ n.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="nodes-section">
|
||||
@@ -155,6 +157,7 @@ const filtered = computed(() => {
|
||||
)
|
||||
: items;
|
||||
return {
|
||||
Flow: list.filter((x) => x.category === 'Flow'),
|
||||
Actions: list.filter((x) => x.category === 'Actions'),
|
||||
Tools: list.filter((x) => x.category === 'Tools'),
|
||||
Tabs: list.filter((x) => x.category === 'Tabs'),
|
||||
|
||||
+941
@@ -0,0 +1,941 @@
|
||||
/** * @fileoverview Trigger Panel Component for Builder * @description * A floating panel for
|
||||
managing V3 triggers in the Builder interface. * * Features: * - Lists all triggers for the current
|
||||
flow * - Enable/disable toggle for all trigger types * - Create/edit/delete for panel-managed
|
||||
triggers (interval, once) * - Manual trigger support for 'manual' type triggers * * Ownership model:
|
||||
* - Node-managed triggers (ID prefix: trg_/sch_): Created by trigger node sync, read-only in panel *
|
||||
- Panel-managed triggers (interval, once): Full CRUD in panel */
|
||||
<template>
|
||||
<aside class="trigger-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<div class="header-title">Triggers</div>
|
||||
<div class="header-sub">{{ flowId }}</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn-sm" type="button" :disabled="loading" @click="refresh"> Refresh </button>
|
||||
<button class="btn-close" type="button" title="Close" @click="emit('close')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="m4 4 8 8M12 4 4 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- Create Section (interval/once only) -->
|
||||
<div class="form-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Add Trigger</div>
|
||||
<div class="section-actions">
|
||||
<button class="btn-sm" type="button" @click="openCreate('interval')">+ Interval</button>
|
||||
<button class="btn-sm" type="button" @click="openCreate('once')">+ Once</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Other types (url/cron/command/contextMenu/dom) are configured via trigger nodes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Trigger List -->
|
||||
<div class="form-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Current Triggers ({{ triggers.length }})</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<div v-else-if="triggers.length === 0" class="muted">No triggers configured</div>
|
||||
|
||||
<div v-else class="trigger-list">
|
||||
<div v-for="trigger in sortedTriggers" :key="trigger.id" class="trigger-row">
|
||||
<div class="trigger-main">
|
||||
<div class="trigger-top">
|
||||
<span class="badge" :data-kind="trigger.kind">{{ trigger.kind }}</span>
|
||||
<span class="trigger-id">{{ trigger.id }}</span>
|
||||
<span
|
||||
v-if="ownerOf(trigger) !== 'panel'"
|
||||
class="ownership"
|
||||
:data-owner="ownerOf(trigger)"
|
||||
>
|
||||
{{ ownerLabel(ownerOf(trigger)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="trigger-desc">{{ describeTrigger(trigger) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="trigger-actions">
|
||||
<label
|
||||
class="toggle"
|
||||
:class="{ readonly: ownerOf(trigger) === 'triggerNode' }"
|
||||
:title="
|
||||
ownerOf(trigger) === 'triggerNode' ? 'Edit via trigger node in Builder' : ''
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="trigger.enabled"
|
||||
:disabled="busyIds[trigger.id] || ownerOf(trigger) === 'triggerNode'"
|
||||
@change="onToggleEnabled(trigger, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-if="trigger.kind === 'manual'"
|
||||
class="btn-sm btn-primary"
|
||||
type="button"
|
||||
:disabled="busyIds[trigger.id] || !trigger.enabled"
|
||||
@click="fireManual(trigger)"
|
||||
>
|
||||
Fire
|
||||
</button>
|
||||
|
||||
<template v-if="isPanelManaged(trigger)">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
type="button"
|
||||
title="Edit"
|
||||
:disabled="busyIds[trigger.id]"
|
||||
@click="openEdit(trigger)"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon-sm danger"
|
||||
type="button"
|
||||
title="Delete"
|
||||
:disabled="busyIds[trigger.id]"
|
||||
@click="removePanelTrigger(trigger)"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Modal -->
|
||||
<div v-if="editorOpen" class="rr-modal" @click.self="closeEditor">
|
||||
<div class="rr-dialog small">
|
||||
<div class="rr-header">
|
||||
<div class="title">{{ editorTitle }}</div>
|
||||
<button class="close" type="button" @click="closeEditor">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="m4 4 8 8M12 4 4 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="rr-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" v-model="editorKind" :disabled="editorMode === 'edit'">
|
||||
<option value="interval">interval</option>
|
||||
<option value="once">once</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="editorEnabled" />
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="editorKind === 'interval'">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Interval (minutes)</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
v-model.number="editorPeriodMinutes"
|
||||
/>
|
||||
</div>
|
||||
<div class="hint">Uses chrome.alarms.periodInMinutes for repeating triggers.</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Trigger Time</label>
|
||||
<input class="form-input" type="datetime-local" v-model="editorWhenLocal" />
|
||||
</div>
|
||||
<div class="hint"> Will auto-disable after firing. Time is in local timezone. </div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rr-footer">
|
||||
<button class="btn-cancel" type="button" @click="closeEditor">Cancel</button>
|
||||
<button class="btn-primary" type="button" :disabled="editorSaving" @click="submitEditor">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { FlowId, TriggerId } from '@/entrypoints/background/record-replay-v3/domain/ids';
|
||||
import type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';
|
||||
import type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';
|
||||
import { useRRV3Rpc } from '@/entrypoints/shared/composables';
|
||||
import { toast } from '@/entrypoints/popup/components/builder/model/toast';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type PanelEditableKind = 'interval' | 'once';
|
||||
type TriggerOwner = 'panel' | 'triggerNode' | 'external';
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
const props = defineProps<{
|
||||
flowId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
defineOptions({ name: 'TriggerPanel' });
|
||||
|
||||
// ==================== RPC & State ====================
|
||||
|
||||
const rpc = useRRV3Rpc({ autoConnect: true });
|
||||
|
||||
const loading = ref(false);
|
||||
const triggers = ref<TriggerSpec[]>([]);
|
||||
const busyIds = ref<Record<string, boolean>>({});
|
||||
|
||||
const sortedTriggers = computed(() => {
|
||||
return [...triggers.value].sort((a, b) => {
|
||||
const kindOrder = a.kind.localeCompare(b.kind);
|
||||
if (kindOrder !== 0) return kindOrder;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Editor State ====================
|
||||
|
||||
const editorOpen = ref(false);
|
||||
const editorSaving = ref(false);
|
||||
const editorMode = ref<'create' | 'edit'>('create');
|
||||
const editorKind = ref<PanelEditableKind>('interval');
|
||||
const editorEditingId = ref<TriggerId | null>(null);
|
||||
const editorEnabled = ref(true);
|
||||
const editorPeriodMinutes = ref(5);
|
||||
const editorWhenLocal = ref('');
|
||||
|
||||
const editorTitle = computed(() => {
|
||||
const mode = editorMode.value === 'create' ? 'Create' : 'Edit';
|
||||
return `${mode} ${editorKind.value} Trigger`;
|
||||
});
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
function setBusy(triggerId: string, value: boolean): void {
|
||||
busyIds.value = { ...busyIds.value, [triggerId]: value };
|
||||
}
|
||||
|
||||
function formatLocalDateTime(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
if (!Number.isFinite(date.getTime())) return String(ms);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function unixMsToDatetimeLocal(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
const year = date.getFullYear();
|
||||
const month = pad2(date.getMonth() + 1);
|
||||
const day = pad2(date.getDate());
|
||||
const hour = pad2(date.getHours());
|
||||
const minute = pad2(date.getMinutes());
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function datetimeLocalToUnixMs(value: string): number | null {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
||||
if (!match) return null;
|
||||
const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr] = match;
|
||||
const date = new Date(
|
||||
Number(yearStr),
|
||||
Number(monthStr) - 1,
|
||||
Number(dayStr),
|
||||
Number(hourStr),
|
||||
Number(minuteStr),
|
||||
Number(secondStr || 0),
|
||||
0,
|
||||
);
|
||||
const ms = date.getTime();
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
// ==================== Trigger Ownership ====================
|
||||
|
||||
function isPanelManaged(trigger: TriggerSpec): boolean {
|
||||
return trigger.kind === 'interval' || trigger.kind === 'once';
|
||||
}
|
||||
|
||||
function ownerOf(trigger: TriggerSpec): TriggerOwner {
|
||||
const flowId = String(props.flowId || '');
|
||||
const trigPrefix = `trg_${flowId}_`;
|
||||
const schPrefix = `sch_${flowId}_`;
|
||||
|
||||
if (trigger.id.startsWith(trigPrefix) || trigger.id.startsWith(schPrefix)) {
|
||||
return 'triggerNode';
|
||||
}
|
||||
if (isPanelManaged(trigger)) {
|
||||
return 'panel';
|
||||
}
|
||||
return 'external';
|
||||
}
|
||||
|
||||
function ownerLabel(owner: TriggerOwner): string {
|
||||
switch (owner) {
|
||||
case 'triggerNode':
|
||||
return 'via trigger node';
|
||||
case 'external':
|
||||
return 'external';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Trigger Description ====================
|
||||
|
||||
function describeTrigger(trigger: TriggerSpec): string {
|
||||
switch (trigger.kind) {
|
||||
case 'url': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'url' }>;
|
||||
const rules = spec.match || [];
|
||||
return `URL match rules: ${rules.length}`;
|
||||
}
|
||||
case 'cron': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'cron' }>;
|
||||
return spec.timezone ? `cron: ${spec.cron} (${spec.timezone})` : `cron: ${spec.cron}`;
|
||||
}
|
||||
case 'interval': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'interval' }>;
|
||||
return `Every ${spec.periodMinutes} minute(s)`;
|
||||
}
|
||||
case 'once': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'once' }>;
|
||||
return `At ${formatLocalDateTime(Number(spec.whenMs))}`;
|
||||
}
|
||||
case 'command': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'command' }>;
|
||||
return `commandKey: ${spec.commandKey}`;
|
||||
}
|
||||
case 'contextMenu': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'contextMenu' }>;
|
||||
return `title: ${spec.title}`;
|
||||
}
|
||||
case 'dom': {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'dom' }>;
|
||||
return `selector: ${spec.selector}`;
|
||||
}
|
||||
case 'manual':
|
||||
return 'Manual trigger (fire via button)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Data Actions ====================
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const flowId = String(props.flowId || '').trim();
|
||||
if (!flowId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await rpc.ensureConnected();
|
||||
const result = (await rpc.request('rr_v3.listTriggers', {
|
||||
flowId: flowId as FlowId,
|
||||
})) as TriggerSpec[] | null;
|
||||
triggers.value = Array.isArray(result) ? result : [];
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleEnabled(trigger: TriggerSpec, enabled: boolean): Promise<void> {
|
||||
if (busyIds.value[trigger.id]) return;
|
||||
setBusy(trigger.id, true);
|
||||
|
||||
try {
|
||||
// Node-managed triggers have toggle disabled, so this only applies to panel-managed
|
||||
await rpc.ensureConnected();
|
||||
const method = enabled ? 'rr_v3.enableTrigger' : 'rr_v3.disableTrigger';
|
||||
await rpc.request(method, { triggerId: trigger.id as TriggerId });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
setBusy(trigger.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fireManual(trigger: TriggerSpec): Promise<void> {
|
||||
if (trigger.kind !== 'manual') return;
|
||||
if (busyIds.value[trigger.id]) return;
|
||||
setBusy(trigger.id, true);
|
||||
|
||||
try {
|
||||
await rpc.ensureConnected();
|
||||
const result = (await rpc.request('rr_v3.fireTrigger', {
|
||||
triggerId: trigger.id as TriggerId,
|
||||
})) as { runId?: string } | null;
|
||||
toast(`Triggered: ${result?.runId ?? 'run enqueued'}`, 'info');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
setBusy(trigger.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Editor Actions ====================
|
||||
|
||||
function openCreate(kind: PanelEditableKind): void {
|
||||
editorMode.value = 'create';
|
||||
editorKind.value = kind;
|
||||
editorEditingId.value = null;
|
||||
editorEnabled.value = true;
|
||||
editorPeriodMinutes.value = 5;
|
||||
editorWhenLocal.value = unixMsToDatetimeLocal(Date.now() + 5 * 60 * 1000);
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(trigger: TriggerSpec): void {
|
||||
if (!isPanelManaged(trigger)) return;
|
||||
editorMode.value = 'edit';
|
||||
editorKind.value = trigger.kind as PanelEditableKind;
|
||||
editorEditingId.value = trigger.id as TriggerId;
|
||||
editorEnabled.value = !!trigger.enabled;
|
||||
|
||||
if (trigger.kind === 'interval') {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'interval' }>;
|
||||
editorPeriodMinutes.value = Number(spec.periodMinutes) || 1;
|
||||
} else {
|
||||
const spec = trigger as Extract<TriggerSpec, { kind: 'once' }>;
|
||||
editorWhenLocal.value = unixMsToDatetimeLocal(Number(spec.whenMs));
|
||||
}
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
if (editorSaving.value) return;
|
||||
editorOpen.value = false;
|
||||
}
|
||||
|
||||
async function submitEditor(): Promise<void> {
|
||||
if (editorSaving.value) return;
|
||||
|
||||
const flowId = String(props.flowId || '').trim();
|
||||
if (!flowId) {
|
||||
toast('Flow ID is empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
editorSaving.value = true;
|
||||
try {
|
||||
await rpc.ensureConnected();
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
|
||||
if (editorKind.value === 'interval') {
|
||||
const periodMinutes = Math.max(1, Math.floor(Number(editorPeriodMinutes.value || 1)));
|
||||
payload = {
|
||||
kind: 'interval',
|
||||
enabled: !!editorEnabled.value,
|
||||
flowId: flowId as FlowId,
|
||||
periodMinutes,
|
||||
};
|
||||
if (editorEditingId.value) {
|
||||
payload.id = editorEditingId.value;
|
||||
}
|
||||
} else {
|
||||
const whenMs = datetimeLocalToUnixMs(editorWhenLocal.value);
|
||||
if (whenMs === null) {
|
||||
toast('Invalid trigger time format', 'error');
|
||||
return;
|
||||
}
|
||||
if (whenMs < Date.now()) {
|
||||
toast('Trigger time is in the past. It may fire immediately.', 'warn');
|
||||
}
|
||||
payload = {
|
||||
kind: 'once',
|
||||
enabled: !!editorEnabled.value,
|
||||
flowId: flowId as FlowId,
|
||||
whenMs,
|
||||
};
|
||||
if (editorEditingId.value) {
|
||||
payload.id = editorEditingId.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (editorMode.value === 'create') {
|
||||
await rpc.request('rr_v3.createTrigger', { trigger: payload as unknown as JsonObject });
|
||||
} else {
|
||||
await rpc.request('rr_v3.updateTrigger', { trigger: payload as unknown as JsonObject });
|
||||
}
|
||||
|
||||
editorOpen.value = false;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
editorSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removePanelTrigger(trigger: TriggerSpec): Promise<void> {
|
||||
if (!isPanelManaged(trigger)) return;
|
||||
|
||||
const confirmed = confirm(`Delete trigger?\n\n${trigger.id}`);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (busyIds.value[trigger.id]) return;
|
||||
setBusy(trigger.id, true);
|
||||
|
||||
try {
|
||||
await rpc.ensureConnected();
|
||||
await rpc.request('rr_v3.deleteTrigger', { triggerId: trigger.id });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
setBusy(trigger.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
watch(
|
||||
() => props.flowId,
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trigger-panel {
|
||||
background: var(--rr-card);
|
||||
border: 1px solid var(--rr-border);
|
||||
border-radius: 16px;
|
||||
margin: 16px;
|
||||
padding: 0;
|
||||
width: 420px;
|
||||
max-height: calc(100vh - 72px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.trigger-panel::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.panel-header {
|
||||
padding: 12px 12px 12px 20px;
|
||||
border-bottom: 1px solid var(--rr-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--rr-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 11px;
|
||||
color: var(--rr-text-weak);
|
||||
font-family: 'Monaco', monospace;
|
||||
opacity: 0.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--rr-border);
|
||||
background: var(--rr-card);
|
||||
color: var(--rr-text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-close:hover {
|
||||
background: var(--rr-hover);
|
||||
border-color: var(--rr-text-weak);
|
||||
color: var(--rr-text);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--rr-text);
|
||||
}
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--rr-text-weak);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--rr-text-weak);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--rr-border);
|
||||
}
|
||||
|
||||
/* Trigger List */
|
||||
.trigger-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.trigger-row {
|
||||
border: 1px solid var(--rr-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.trigger-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--rr-text);
|
||||
}
|
||||
.trigger-id {
|
||||
font-size: 11px;
|
||||
color: var(--rr-text-weak);
|
||||
font-family: 'Monaco', monospace;
|
||||
opacity: 0.85;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ownership {
|
||||
font-size: 11px;
|
||||
color: var(--rr-text-weak);
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed var(--rr-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.trigger-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--rr-text-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.trigger-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--rr-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.toggle.readonly {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.toggle.readonly input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-sm {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--rr-border);
|
||||
background: var(--rr-card);
|
||||
color: var(--rr-text);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--rr-hover);
|
||||
border-color: var(--rr-text-weak);
|
||||
}
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-sm.btn-primary {
|
||||
background: var(--rr-accent);
|
||||
color: #fff;
|
||||
border-color: var(--rr-accent);
|
||||
}
|
||||
.btn-sm.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--rr-border);
|
||||
background: var(--rr-card);
|
||||
color: var(--rr-text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon-sm:hover:not(:disabled) {
|
||||
background: var(--rr-hover);
|
||||
border-color: var(--rr-text-weak);
|
||||
color: var(--rr-text);
|
||||
}
|
||||
.btn-icon-sm.danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: var(--rr-danger);
|
||||
}
|
||||
.btn-icon-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.rr-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.rr-dialog {
|
||||
background: var(--rr-card);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
|
||||
min-width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.rr-dialog.small {
|
||||
min-width: 320px;
|
||||
}
|
||||
.rr-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--rr-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.rr-header .title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--rr-text);
|
||||
}
|
||||
.rr-header .close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--rr-text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rr-header .close:hover {
|
||||
background: var(--rr-hover);
|
||||
color: var(--rr-text);
|
||||
}
|
||||
.rr-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.rr-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--rr-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--rr-text-secondary);
|
||||
}
|
||||
.form-input,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--rr-border);
|
||||
border-radius: 8px;
|
||||
background: var(--rr-card);
|
||||
font-size: 14px;
|
||||
color: var(--rr-text);
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--rr-accent);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--rr-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-label input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--rr-border);
|
||||
background: var(--rr-card);
|
||||
color: var(--rr-text);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
background: var(--rr-hover);
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: var(--rr-accent);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
+5
-9
@@ -17,17 +17,10 @@ import { createQuickPanelController, type QuickPanelController } from '@/shared/
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
// Exclude extension pages and browser internal pages
|
||||
excludeMatches: [
|
||||
'chrome://*',
|
||||
'chrome-extension://*',
|
||||
'edge://*',
|
||||
'about:*',
|
||||
'moz-extension://*',
|
||||
],
|
||||
runAt: 'document_idle',
|
||||
|
||||
main() {
|
||||
console.log('[QuickPanelContentScript] Content script loaded on:', window.location.href);
|
||||
let controller: QuickPanelController | null = null;
|
||||
|
||||
/**
|
||||
@@ -55,10 +48,13 @@ export default defineContentScript({
|
||||
const msg = message as { action?: string } | undefined;
|
||||
|
||||
if (msg?.action === 'toggle_quick_panel') {
|
||||
console.log('[QuickPanelContentScript] Received toggle_quick_panel message');
|
||||
try {
|
||||
const ctrl = ensureController();
|
||||
ctrl.toggle();
|
||||
sendResponse({ success: true, visible: ctrl.isVisible() });
|
||||
const visible = ctrl.isVisible();
|
||||
console.log('[QuickPanelContentScript] Toggle completed, visible:', visible);
|
||||
sendResponse({ success: true, visible });
|
||||
} catch (err) {
|
||||
console.error('[QuickPanelContentScript] Toggle error:', err);
|
||||
sendResponse({ success: false, error: String(err) });
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Shared UI Composables
|
||||
* @description Composables shared between multiple UI entrypoints (Sidepanel, Builder, Popup, etc.)
|
||||
*
|
||||
* Note: These composables are for UI-only use. Do not import them in background scripts
|
||||
* as they depend on Vue and will bloat the service worker bundle.
|
||||
*/
|
||||
|
||||
// RR V3 RPC Client
|
||||
export { useRRV3Rpc } from './useRRV3Rpc';
|
||||
export type { UseRRV3Rpc, UseRRV3RpcOptions, RpcRequestOptions } from './useRRV3Rpc';
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* @fileoverview RR V3 Port-RPC Client Composable (Shared)
|
||||
* @description RPC client for UI components to connect with Background Service Worker
|
||||
*
|
||||
* This composable is shared between Sidepanel, Builder, and other UI entrypoints.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Connect to background via chrome.runtime.Port
|
||||
* - Provide request/response RPC calls (with timeout and cancellation)
|
||||
* - Support event stream subscription
|
||||
* - Auto-reconnect with exponential backoff
|
||||
*
|
||||
* Design considerations:
|
||||
* - MV3 service worker may be terminated due to idle, causing Port disconnect
|
||||
* - Implement idempotent reconnection and subscription recovery
|
||||
*/
|
||||
|
||||
import { computed, onUnmounted, ref, shallowRef, type ComputedRef, type Ref } from 'vue';
|
||||
|
||||
import type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json';
|
||||
import type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events';
|
||||
import type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';
|
||||
import {
|
||||
RR_V3_PORT_NAME,
|
||||
createRpcRequest,
|
||||
isRpcEvent,
|
||||
isRpcResponse,
|
||||
type RpcMethod,
|
||||
} from '@/entrypoints/background/record-replay-v3/engine/transport/rpc';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/** RPC request options */
|
||||
export interface RpcRequestOptions {
|
||||
/** Timeout in milliseconds, 0 means no timeout */
|
||||
timeoutMs?: number;
|
||||
/** Abort signal for cancellation */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Composable configuration */
|
||||
export interface UseRRV3RpcOptions {
|
||||
/** Default request timeout (ms) */
|
||||
requestTimeoutMs?: number;
|
||||
/** Maximum reconnect attempts */
|
||||
maxReconnectAttempts?: number;
|
||||
/** Base delay for reconnection (ms) */
|
||||
baseReconnectDelayMs?: number;
|
||||
/** Auto-connect on initialization */
|
||||
autoConnect?: boolean;
|
||||
/** Connection state change callback */
|
||||
onConnectionChange?: (connected: boolean) => void;
|
||||
/** Error callback */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
/** Event listener function */
|
||||
type EventListener = (event: RunEvent) => void;
|
||||
|
||||
/** Pending request entry */
|
||||
interface PendingRequest {
|
||||
method: RpcMethod;
|
||||
resolve: (value: JsonValue) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
/** AbortSignal reference for cleanup */
|
||||
signal?: AbortSignal;
|
||||
/** Abort handler for cleanup */
|
||||
abortHandler?: () => void;
|
||||
}
|
||||
|
||||
/** Composable return type */
|
||||
export interface UseRRV3Rpc {
|
||||
// Connection state
|
||||
connected: Ref<boolean>;
|
||||
connecting: Ref<boolean>;
|
||||
reconnecting: Ref<boolean>;
|
||||
reconnectAttempts: Ref<number>;
|
||||
lastError: Ref<string | null>;
|
||||
isReady: ComputedRef<boolean>;
|
||||
|
||||
// Diagnostics
|
||||
pendingCount: Ref<number>;
|
||||
subscribedRunIds: Ref<Array<RunId | null>>;
|
||||
|
||||
// Connection lifecycle
|
||||
connect: () => Promise<boolean>;
|
||||
disconnect: (reason?: string) => void;
|
||||
ensureConnected: () => Promise<boolean>;
|
||||
|
||||
// RPC calls
|
||||
request: <T extends JsonValue = JsonValue>(
|
||||
method: RpcMethod,
|
||||
params?: JsonObject,
|
||||
options?: RpcRequestOptions,
|
||||
) => Promise<T>;
|
||||
|
||||
// Event subscription
|
||||
subscribe: (runId?: RunId | null) => Promise<boolean>;
|
||||
unsubscribe: (runId?: RunId | null) => Promise<boolean>;
|
||||
onEvent: (listener: EventListener) => () => void;
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRunEvent(value: unknown): value is RunEvent {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.runId === 'string' &&
|
||||
typeof obj.type === 'string' &&
|
||||
typeof obj.seq === 'number' &&
|
||||
typeof obj.ts === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Composable ====================
|
||||
|
||||
/**
|
||||
* RR V3 Port-RPC client
|
||||
*/
|
||||
export function useRRV3Rpc(options: UseRRV3RpcOptions = {}): UseRRV3Rpc {
|
||||
// Configuration
|
||||
const DEFAULT_TIMEOUT_MS = options.requestTimeoutMs ?? 12_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = options.maxReconnectAttempts ?? 8;
|
||||
const BASE_RECONNECT_DELAY_MS = options.baseReconnectDelayMs ?? 500;
|
||||
|
||||
// Reactive state
|
||||
const connected = ref(false);
|
||||
const connecting = ref(false);
|
||||
const reconnecting = ref(false);
|
||||
const reconnectAttempts = ref(0);
|
||||
const lastError = ref<string | null>(null);
|
||||
const pendingCount = ref(0);
|
||||
const subscribedRunIds = ref<Array<RunId | null>>([]);
|
||||
|
||||
// Internal state (non-reactive)
|
||||
const port = shallowRef<chrome.runtime.Port | null>(null);
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
const eventListeners = new Set<EventListener>();
|
||||
const desiredSubscriptions = new Set<RunId | null>();
|
||||
let connectPromise: Promise<boolean> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let manualDisconnect = false;
|
||||
|
||||
// Computed
|
||||
const isReady = computed(() => connected.value && port.value !== null);
|
||||
|
||||
// ==================== Internal Methods ====================
|
||||
|
||||
function setError(message: string | null): void {
|
||||
lastError.value = message;
|
||||
if (message) options.onError?.(message);
|
||||
}
|
||||
|
||||
function setConnected(next: boolean): void {
|
||||
if (connected.value === next) return;
|
||||
connected.value = next;
|
||||
options.onConnectionChange?.(next);
|
||||
}
|
||||
|
||||
function syncSubscriptionsSnapshot(): void {
|
||||
const arr = Array.from(desiredSubscriptions.values());
|
||||
arr.sort((a, b) => {
|
||||
// Both null - equal
|
||||
if (a === null && b === null) return 0;
|
||||
// null comes first
|
||||
if (a === null) return -1;
|
||||
if (b === null) return 1;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
subscribedRunIds.value = arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a pending request entry (timeout, abort listener)
|
||||
*/
|
||||
function cleanupPendingRequest(entry: PendingRequest): void {
|
||||
if (entry.timeoutId) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.timeoutId = null;
|
||||
}
|
||||
if (entry.signal && entry.abortHandler) {
|
||||
try {
|
||||
entry.signal.removeEventListener('abort', entry.abortHandler);
|
||||
} catch {
|
||||
// Ignore - signal may be invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rejectAllPending(reason: string): void {
|
||||
const error = new Error(reason);
|
||||
for (const [requestId, entry] of pendingRequests) {
|
||||
cleanupPendingRequest(entry);
|
||||
entry.reject(error);
|
||||
pendingRequests.delete(requestId);
|
||||
}
|
||||
pendingCount.value = 0;
|
||||
}
|
||||
|
||||
async function rehydrateSubscriptions(): Promise<void> {
|
||||
if (!isReady.value || desiredSubscriptions.size === 0) return;
|
||||
|
||||
for (const runId of desiredSubscriptions) {
|
||||
try {
|
||||
const params: JsonObject = runId === null ? {} : { runId };
|
||||
await request('rr_v3.subscribe', params).catch(() => {
|
||||
// Best-effort, ignore errors
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (manualDisconnect || reconnectTimer) return;
|
||||
|
||||
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnecting.value = false;
|
||||
setError('RR V3 RPC: max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
reconnecting.value = true;
|
||||
const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts.value);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectAttempts.value += 1;
|
||||
void connect().then((ok) => {
|
||||
if (!ok) scheduleReconnect();
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ==================== Port Handlers ====================
|
||||
|
||||
function handlePortDisconnect(): void {
|
||||
// Capture disconnect reason for debugging
|
||||
const disconnectReason = chrome.runtime.lastError?.message;
|
||||
const reason = disconnectReason
|
||||
? `RR V3 RPC disconnected: ${disconnectReason}`
|
||||
: 'RR V3 RPC disconnected';
|
||||
|
||||
port.value = null;
|
||||
setConnected(false);
|
||||
connecting.value = false;
|
||||
rejectAllPending(reason);
|
||||
|
||||
// Update lastError for UI visibility (only on unexpected disconnect)
|
||||
if (!manualDisconnect) {
|
||||
setError(reason);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePortMessage(msg: unknown): void {
|
||||
// Handle RPC response
|
||||
if (isRpcResponse(msg)) {
|
||||
const entry = pendingRequests.get(msg.requestId);
|
||||
if (!entry) return;
|
||||
|
||||
pendingRequests.delete(msg.requestId);
|
||||
pendingCount.value = pendingRequests.size;
|
||||
|
||||
// Clean up timeout and abort listener
|
||||
cleanupPendingRequest(entry);
|
||||
|
||||
if (msg.ok) {
|
||||
entry.resolve(msg.result as JsonValue);
|
||||
} else {
|
||||
entry.reject(new Error(msg.error || `RPC error: ${entry.method}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle event push
|
||||
if (isRpcEvent(msg)) {
|
||||
const event = msg.event;
|
||||
if (!isRunEvent(event)) return;
|
||||
|
||||
for (const listener of eventListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('[useRRV3Rpc] Event listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Public Methods ====================
|
||||
|
||||
async function connect(): Promise<boolean> {
|
||||
if (isReady.value) return true;
|
||||
if (connectPromise) return connectPromise;
|
||||
|
||||
connectPromise = (async () => {
|
||||
manualDisconnect = false;
|
||||
connecting.value = true;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {
|
||||
setError('chrome.runtime.connect not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
const p = chrome.runtime.connect({ name: RR_V3_PORT_NAME });
|
||||
port.value = p;
|
||||
|
||||
// Reset reconnect state
|
||||
reconnectAttempts.value = 0;
|
||||
reconnecting.value = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
p.onMessage.addListener(handlePortMessage);
|
||||
p.onDisconnect.addListener(handlePortDisconnect);
|
||||
|
||||
setConnected(true);
|
||||
|
||||
// Restore subscriptions
|
||||
void rehydrateSubscriptions();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(`Connection failed: ${toErrorMessage(error)}`);
|
||||
return false;
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
connectPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function disconnect(reason?: string): void {
|
||||
manualDisconnect = true;
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
reconnecting.value = false;
|
||||
|
||||
const p = port.value;
|
||||
port.value = null;
|
||||
setConnected(false);
|
||||
connecting.value = false;
|
||||
|
||||
rejectAllPending(reason || 'RR V3 RPC: client disconnected');
|
||||
|
||||
if (p) {
|
||||
try {
|
||||
p.onMessage.removeListener(handlePortMessage);
|
||||
p.onDisconnect.removeListener(handlePortDisconnect);
|
||||
p.disconnect();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConnected(): Promise<boolean> {
|
||||
if (isReady.value) return true;
|
||||
return connect();
|
||||
}
|
||||
|
||||
async function request<T extends JsonValue = JsonValue>(
|
||||
method: RpcMethod,
|
||||
params?: JsonObject,
|
||||
reqOptions: RpcRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const ready = await ensureConnected();
|
||||
const p = port.value;
|
||||
|
||||
if (!ready || !p) {
|
||||
throw new Error('RR V3 RPC: not connected');
|
||||
}
|
||||
|
||||
const timeoutMs = reqOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const { signal } = reqOptions;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('RPC request already aborted');
|
||||
}
|
||||
|
||||
const req = createRpcRequest(method, params);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const entry: PendingRequest = {
|
||||
method,
|
||||
resolve: resolve as (value: JsonValue) => void,
|
||||
reject,
|
||||
timeoutId: null,
|
||||
signal,
|
||||
};
|
||||
|
||||
// Helper to complete request with cleanup
|
||||
const complete = (fn: () => void) => {
|
||||
pendingRequests.delete(req.requestId);
|
||||
pendingCount.value = pendingRequests.size;
|
||||
cleanupPendingRequest(entry);
|
||||
fn();
|
||||
};
|
||||
|
||||
// Timeout handling
|
||||
if (timeoutMs > 0) {
|
||||
entry.timeoutId = setTimeout(() => {
|
||||
complete(() => reject(new Error(`RPC timeout (${timeoutMs}ms): ${method}`)));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Abort handling
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
complete(() => reject(new Error('RPC request aborted')));
|
||||
};
|
||||
entry.abortHandler = onAbort;
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
pendingRequests.set(req.requestId, entry);
|
||||
pendingCount.value = pendingRequests.size;
|
||||
|
||||
try {
|
||||
p.postMessage(req);
|
||||
} catch (e) {
|
||||
complete(() => reject(new Error(`Failed to send RPC request: ${toErrorMessage(e)}`)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function subscribe(runId: RunId | null = null): Promise<boolean> {
|
||||
desiredSubscriptions.add(runId);
|
||||
syncSubscriptionsSnapshot();
|
||||
|
||||
try {
|
||||
const params: JsonObject = runId === null ? {} : { runId };
|
||||
await request('rr_v3.subscribe', params);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(toErrorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(runId: RunId | null = null): Promise<boolean> {
|
||||
desiredSubscriptions.delete(runId);
|
||||
syncSubscriptionsSnapshot();
|
||||
|
||||
try {
|
||||
const params: JsonObject = runId === null ? {} : { runId };
|
||||
await request('rr_v3.unsubscribe', params);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(toErrorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEvent(listener: EventListener): () => void {
|
||||
eventListeners.add(listener);
|
||||
return () => eventListeners.delete(listener);
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect('Component unmounted');
|
||||
});
|
||||
|
||||
if (options.autoConnect) {
|
||||
void ensureConnected();
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
reconnecting,
|
||||
reconnectAttempts,
|
||||
lastError,
|
||||
isReady,
|
||||
pendingCount,
|
||||
subscribedRunIds,
|
||||
connect,
|
||||
disconnect,
|
||||
ensureConnected,
|
||||
request,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
onEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @fileoverview Shared Utilities Index
|
||||
* @description Utility functions shared between UI entrypoints
|
||||
*/
|
||||
|
||||
// Flow conversion utilities
|
||||
export {
|
||||
flowV2ToV3ForRpc,
|
||||
flowV3ToV2ForBuilder,
|
||||
isFlowV3,
|
||||
isFlowV2,
|
||||
extractFlowCandidates,
|
||||
type FlowConversionResult,
|
||||
} from './rr-flow-convert';
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @fileoverview V2/V3 Flow 双向转换工具
|
||||
* @description 桥接 Builder V2 Flow 类型与 V3 RPC FlowV3 类型
|
||||
*
|
||||
* 设计说明:
|
||||
* - Builder store 目前仍使用 V2 类型 (type, version, steps)
|
||||
* - RPC 层使用 V3 类型 (kind, schemaVersion, entryNodeId)
|
||||
* - 本模块提供 UI 层的类型转换,封装底层转换器
|
||||
*/
|
||||
|
||||
import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';
|
||||
import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';
|
||||
import {
|
||||
convertFlowV2ToV3,
|
||||
convertFlowV3ToV2,
|
||||
} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface FlowConversionResult<T> {
|
||||
flow: T;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ==================== V2 -> V3 (for RPC calls) ====================
|
||||
|
||||
/**
|
||||
* 将 V2 Flow 转换为 V3 格式,用于 RPC 保存
|
||||
* @param flowV2 Builder store 中的 V2 Flow
|
||||
* @returns V3 Flow 和警告信息
|
||||
* @throws 转换失败时抛出错误
|
||||
*/
|
||||
export function flowV2ToV3ForRpc(flowV2: FlowV2): FlowConversionResult<FlowV3> {
|
||||
const result = convertFlowV2ToV3(flowV2 as unknown as Parameters<typeof convertFlowV2ToV3>[0]);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
const errorMsg =
|
||||
result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error';
|
||||
throw new Error(`V2→V3 conversion failed: ${errorMsg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
flow: result.data,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== V3 -> V2 (for Builder display) ====================
|
||||
|
||||
/**
|
||||
* 将 V3 Flow 转换为 V2 格式,用于 Builder 显示和编辑
|
||||
* @param flowV3 从 RPC 获取的 V3 Flow
|
||||
* @returns V2 Flow 和警告信息
|
||||
* @throws 转换失败时抛出错误
|
||||
*/
|
||||
export function flowV3ToV2ForBuilder(flowV3: FlowV3): FlowConversionResult<FlowV2> {
|
||||
const result = convertFlowV3ToV2(flowV3);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
const errorMsg =
|
||||
result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error';
|
||||
throw new Error(`V3→V2 conversion failed: ${errorMsg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
flow: result.data as unknown as FlowV2,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Type Guards ====================
|
||||
|
||||
/**
|
||||
* 判断是否为 V3 Flow
|
||||
* @description 用于导入时判断 JSON 格式
|
||||
*/
|
||||
export function isFlowV3(value: unknown): value is FlowV3 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
obj.schemaVersion === 3 &&
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.entryNodeId === 'string' &&
|
||||
Array.isArray(obj.nodes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 V2 Flow
|
||||
* @description 用于导入时判断 JSON 格式
|
||||
*/
|
||||
export function isFlowV2(value: unknown): value is FlowV2 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.name === 'string' &&
|
||||
// V2 有 version 字段(数字),且没有 schemaVersion
|
||||
typeof obj.version === 'number' &&
|
||||
obj.schemaVersion === undefined &&
|
||||
// V2 可能有 steps 或 nodes
|
||||
(Array.isArray(obj.steps) || Array.isArray(obj.nodes))
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Import Helpers ====================
|
||||
|
||||
/**
|
||||
* 从导入的 JSON 中提取 Flow 候选列表
|
||||
* @description 支持单个 Flow、Flow 数组、或 { flows: Flow[] } 格式
|
||||
*/
|
||||
export function extractFlowCandidates(parsed: unknown): unknown[] {
|
||||
// 数组格式
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// 对象格式
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// { flows: [...] } 格式
|
||||
if (Array.isArray(obj.flows)) {
|
||||
return obj.flows;
|
||||
}
|
||||
|
||||
// 单个 Flow 对象
|
||||
if (obj.id && (Array.isArray(obj.steps) || Array.isArray(obj.nodes))) {
|
||||
return [obj];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
<!-- Dismiss button -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 flex-shrink-0 ac-btn ac-focus-ring"
|
||||
class="p-1 flex-shrink-0 ac-btn ac-focus-ring cursor-pointer"
|
||||
:style="{
|
||||
color: 'var(--ac-danger)',
|
||||
borderRadius: 'var(--ac-radius-button)',
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
<!-- Remove button (appears on hover) -->
|
||||
<button
|
||||
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-error)',
|
||||
color: 'white',
|
||||
@@ -133,7 +133,7 @@
|
||||
<button
|
||||
v-if="showExpandButton"
|
||||
type="button"
|
||||
class="absolute top-2 right-2 p-1.5 transition-all hover:scale-105"
|
||||
class="absolute top-2 right-2 p-1.5 transition-all hover:scale-105 cursor-pointer"
|
||||
:style="expandButtonStyle"
|
||||
title="Expand editor"
|
||||
@click="openDrawer"
|
||||
@@ -283,7 +283,7 @@
|
||||
<!-- Primary Action Button: Send (idle) / Stop (loading) -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 transition-colors"
|
||||
class="p-1 transition-colors cursor-pointer"
|
||||
:style="primaryActionButtonStyle"
|
||||
:disabled="primaryActionDisabled"
|
||||
:title="isRequestActive ? 'Stop' : 'Send'"
|
||||
@@ -291,11 +291,11 @@
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
<!-- Stop icon (square) when request is active -->
|
||||
<svg v-if="isRequestActive" class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg v-if="isRequestActive" class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
<!-- Send icon (arrow up) when idle -->
|
||||
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg v-else class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@
|
||||
<!-- Save Button -->
|
||||
<div class="px-3 py-2">
|
||||
<button
|
||||
class="w-full px-3 py-1.5 text-xs rounded transition-colors hover:opacity-90"
|
||||
class="w-full px-3 py-1.5 text-xs rounded transition-colors hover:opacity-90 cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-accent, #c87941)',
|
||||
color: 'var(--ac-accent-contrast, #ffffff)',
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@
|
||||
|
||||
<!-- Edit button (placeholder, appears on hover) -->
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 cursor-pointer"
|
||||
:style="{ color: 'var(--ac-text-subtle)' }"
|
||||
title="Edit (coming soon)"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
v-for="attachment in thread.attachments"
|
||||
:key="`${attachment.messageId}:${attachment.index}`"
|
||||
type="button"
|
||||
class="relative group/thumb w-16 h-16 rounded-lg overflow-hidden cursor-pointer transition-opacity hover:opacity-90"
|
||||
class="relative group/thumb w-16 h-16 rounded-lg overflow-hidden transition-opacity hover:opacity-90 cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface-muted)',
|
||||
border: 'var(--ac-border-width) solid var(--ac-border)',
|
||||
@@ -114,7 +114,7 @@
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20"
|
||||
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20 cursor-pointer"
|
||||
:style="{ color: 'white' }"
|
||||
aria-label="Close image preview"
|
||||
@click="closeViewer"
|
||||
|
||||
+3
-3
@@ -125,7 +125,7 @@
|
||||
<!-- Open Project Button -->
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
class="p-1.5 rounded-md transition-colors cursor-pointer"
|
||||
:style="actionButtonStyle"
|
||||
title="Open project"
|
||||
@click.stop="handleOpenProject"
|
||||
@@ -149,7 +149,7 @@
|
||||
<!-- Rename Button -->
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
class="p-1.5 rounded-md transition-colors cursor-pointer"
|
||||
:style="actionButtonStyle"
|
||||
title="Rename"
|
||||
@click.stop="startRename"
|
||||
@@ -166,7 +166,7 @@
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
class="p-1.5 rounded-md transition-colors cursor-pointer"
|
||||
:style="deleteButtonStyle"
|
||||
title="Delete"
|
||||
@click.stop="handleDelete"
|
||||
|
||||
+2
-2
@@ -104,7 +104,7 @@
|
||||
<!-- Rename Button -->
|
||||
<button
|
||||
v-if="editingSessionId !== session.id"
|
||||
class="p-1 ac-btn"
|
||||
class="p-1 ac-btn cursor-pointer"
|
||||
:style="{
|
||||
color: 'var(--ac-text-muted, #6e6e6e)',
|
||||
borderRadius: 'var(--ac-radius-button)',
|
||||
@@ -123,7 +123,7 @@
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
class="p-1 ac-btn"
|
||||
class="p-1 ac-btn cursor-pointer"
|
||||
:style="{
|
||||
color: 'var(--ac-danger, #dc2626)',
|
||||
borderRadius: 'var(--ac-radius-button)',
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- New Session Button -->
|
||||
<button
|
||||
class="flex-shrink-0 px-3 py-2 text-sm font-medium"
|
||||
class="flex-shrink-0 px-3 py-2 text-sm font-medium cursor-pointer"
|
||||
:style="newButtonStyle"
|
||||
:disabled="isCreating"
|
||||
@click="handleNewSession"
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<button
|
||||
v-if="!searchQuery"
|
||||
class="px-4 py-2 text-sm font-medium"
|
||||
class="px-4 py-2 text-sm font-medium cursor-pointer"
|
||||
:style="newButtonStyle"
|
||||
@click="handleNewSession"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<!-- Brand / Context -->
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 overflow-hidden -ml-1">
|
||||
<!-- Back Button (when in chat view) -->
|
||||
<button
|
||||
v-if="showBackButton"
|
||||
class="flex items-center justify-center w-8 h-8 -ml-3 flex-shrink-0 ac-btn"
|
||||
class="flex items-center justify-center w-8 h-8 flex-shrink-0 ac-btn"
|
||||
:style="{
|
||||
color: 'var(--ac-text-muted)',
|
||||
borderRadius: 'var(--ac-radius-button)',
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Brand -->
|
||||
<h1
|
||||
class="text-lg font-medium tracking-tight flex-shrink-0 ml-[-10px]"
|
||||
class="text-lg font-medium tracking-tight flex-shrink-0"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-heading)',
|
||||
color: 'var(--ac-text)',
|
||||
|
||||
+6
-6
@@ -134,7 +134,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-xs font-medium"
|
||||
class="px-3 py-2 text-xs font-medium cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-chip-bg)',
|
||||
color: 'var(--ac-chip-text)',
|
||||
@@ -148,7 +148,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-xs font-medium"
|
||||
class="px-3 py-2 text-xs font-medium cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--ac-text-muted)',
|
||||
@@ -230,7 +230,7 @@
|
||||
<div class="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-[11px] font-medium"
|
||||
class="px-2 py-1 text-[11px] font-medium cursor-pointer"
|
||||
:style="chipStyle"
|
||||
:disabled="isClearing || selectableProjectIds.length === 0"
|
||||
@click="selectAll"
|
||||
@@ -239,7 +239,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-[11px] font-medium"
|
||||
class="px-2 py-1 text-[11px] font-medium cursor-pointer"
|
||||
:style="chipStyle"
|
||||
:disabled="isClearing || selectableProjectIds.length === 0"
|
||||
@click="invertSelection"
|
||||
@@ -248,7 +248,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-[11px] font-medium"
|
||||
class="px-2 py-1 text-[11px] font-medium cursor-pointer"
|
||||
:style="chipStyle"
|
||||
:disabled="isClearing || selectedCount === 0"
|
||||
@click="clearSelection"
|
||||
@@ -349,7 +349,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-xs font-semibold rounded-lg flex-shrink-0"
|
||||
class="px-3 py-2 text-xs font-semibold rounded-lg flex-shrink-0 cursor-pointer"
|
||||
:disabled="!canClear"
|
||||
:style="clearButtonStyle"
|
||||
@click="clearSelected"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 transition-colors hover:opacity-80"
|
||||
class="p-1.5 transition-colors hover:opacity-80 cursor-pointer"
|
||||
:style="closeButtonStyle"
|
||||
aria-label="Close expanded editor"
|
||||
@click="emit('close')"
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
:style="removeButtonStyle"
|
||||
title="Remove image"
|
||||
@click="emit('attachment:remove', index)"
|
||||
@@ -138,7 +138,7 @@
|
||||
<!-- Cancel button: Show when request is active (not just streaming) -->
|
||||
<button
|
||||
v-if="isRequestActive && canCancel && !sending"
|
||||
class="px-3 py-1.5 text-xs transition-colors"
|
||||
class="px-3 py-1.5 text-xs transition-colors cursor-pointer"
|
||||
:style="cancelButtonStyle"
|
||||
:disabled="cancelling"
|
||||
@click="emit('cancel')"
|
||||
@@ -148,7 +148,7 @@
|
||||
|
||||
<!-- Send button -->
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
class="px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer"
|
||||
:style="sendButtonStyle"
|
||||
:disabled="!canSend || sending"
|
||||
@click="handleSubmit"
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<button
|
||||
v-show="isHovering"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-4 h-4 -ml-1 mr-1 rounded-full transition-colors"
|
||||
class="flex items-center justify-center w-4 h-4 -ml-1 mr-1 rounded-full transition-colors cursor-pointer"
|
||||
:style="revertButtonStyle"
|
||||
:aria-label="`Revert changes to ${element.label}`"
|
||||
:title="`Revert all changes to ${element.label}`"
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-0.5 text-[10px] transition-colors"
|
||||
class="px-2 py-0.5 text-[10px] transition-colors cursor-pointer"
|
||||
:style="includeButtonStyle"
|
||||
:aria-pressed="viewMode === 'include'"
|
||||
@click="viewMode = 'include'"
|
||||
@@ -33,7 +33,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-0.5 text-[10px] transition-colors"
|
||||
class="px-2 py-0.5 text-[10px] transition-colors cursor-pointer"
|
||||
:style="excludeButtonStyle"
|
||||
:aria-pressed="viewMode === 'exclude'"
|
||||
@click="viewMode = 'exclude'"
|
||||
|
||||
-1
@@ -168,7 +168,6 @@ function formatLine(text: string): string {
|
||||
.thinking-section {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
border-left: 2px solid var(--ac-accent);
|
||||
padding-left: 12px;
|
||||
background: var(--ac-surface-muted);
|
||||
border-radius: var(--ac-radius-inner);
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
<button
|
||||
v-if="isDetailsTruncated"
|
||||
class="w-full px-3 py-1 text-[10px] text-left"
|
||||
class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface-muted)',
|
||||
color: 'var(--ac-link)',
|
||||
@@ -135,7 +135,7 @@
|
||||
</div>
|
||||
<button
|
||||
v-if="isDetailsTruncated"
|
||||
class="w-full px-3 py-1 text-[10px] text-left"
|
||||
class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface-muted)',
|
||||
color: 'var(--ac-link)',
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@
|
||||
v-for="attachment in item.attachments"
|
||||
:key="`${attachment.messageId}:${attachment.index}`"
|
||||
type="button"
|
||||
class="relative group w-16 h-16 rounded-lg overflow-hidden cursor-pointer transition-opacity hover:opacity-90"
|
||||
class="relative group w-16 h-16 rounded-lg overflow-hidden transition-opacity hover:opacity-90 cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface-muted)',
|
||||
border: 'var(--ac-border-width) solid var(--ac-border)',
|
||||
@@ -102,7 +102,7 @@
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20"
|
||||
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20 cursor-pointer"
|
||||
:style="{ color: 'white' }"
|
||||
aria-label="Close image preview"
|
||||
@click="closeViewer"
|
||||
|
||||
@@ -458,8 +458,9 @@ export function useAgentSessions(options: UseAgentSessionsOptions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session preview locally (without server call).
|
||||
* Used when sending the first message to update the display immediately.
|
||||
* Update session preview and updatedAt locally (without server call).
|
||||
* Used when sending a message to update the display immediately.
|
||||
* Always updates updatedAt so the session moves to the top of the list.
|
||||
* @param sessionId - The session to update
|
||||
* @param preview - The preview text (user's raw input)
|
||||
* @param previewMeta - Optional structured metadata for special rendering (e.g., web editor apply chip)
|
||||
@@ -474,23 +475,30 @@ export function useAgentSessions(options: UseAgentSessionsOptions) {
|
||||
const trimmed = preview.trim().replace(/\s+/g, ' ');
|
||||
const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 1) + '…' : trimmed;
|
||||
|
||||
// Always update updatedAt to move session to top of list
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Update in current project sessions
|
||||
const index = sessions.value.findIndex((s) => s.id === sessionId);
|
||||
if (index !== -1 && !sessions.value[index].preview) {
|
||||
if (index !== -1) {
|
||||
sessions.value[index] = {
|
||||
...sessions.value[index],
|
||||
preview: truncated,
|
||||
previewMeta,
|
||||
// Only update preview if not already set
|
||||
preview: sessions.value[index].preview || truncated,
|
||||
previewMeta: sessions.value[index].previewMeta || previewMeta,
|
||||
// Always update timestamp so session moves to top
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// Also update in allSessions for global list view
|
||||
const allIndex = allSessions.value.findIndex((s) => s.id === sessionId);
|
||||
if (allIndex !== -1 && !allSessions.value[allIndex].preview) {
|
||||
if (allIndex !== -1) {
|
||||
allSessions.value[allIndex] = {
|
||||
...allSessions.value[allIndex],
|
||||
preview: truncated,
|
||||
previewMeta,
|
||||
preview: allSessions.value[allIndex].preview || truncated,
|
||||
previewMeta: allSessions.value[allIndex].previewMeta || previewMeta,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,502 +1,11 @@
|
||||
/**
|
||||
* @fileoverview RR V3 Port-RPC Client Composable
|
||||
* @description RPC client for Sidepanel UI to connect with Background Service Worker
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Connect to background via chrome.runtime.Port
|
||||
* - Provide request/response RPC calls (with timeout and cancellation)
|
||||
* - Support event stream subscription
|
||||
* - Auto-reconnect with exponential backoff
|
||||
*
|
||||
* Design considerations:
|
||||
* - MV3 service worker may be terminated due to idle, causing Port disconnect
|
||||
* - Implement idempotent reconnection and subscription recovery
|
||||
* @fileoverview Re-export shared useRRV3Rpc composable
|
||||
* @description This file re-exports the shared composable for backward compatibility
|
||||
*/
|
||||
|
||||
import { computed, onUnmounted, ref, shallowRef, type ComputedRef, type Ref } from 'vue';
|
||||
|
||||
import type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json';
|
||||
import type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events';
|
||||
import type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';
|
||||
import {
|
||||
RR_V3_PORT_NAME,
|
||||
createRpcRequest,
|
||||
isRpcEvent,
|
||||
isRpcResponse,
|
||||
type RpcMethod,
|
||||
} from '@/entrypoints/background/record-replay-v3/engine/transport/rpc';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/** RPC request options */
|
||||
export interface RpcRequestOptions {
|
||||
/** Timeout in milliseconds, 0 means no timeout */
|
||||
timeoutMs?: number;
|
||||
/** Abort signal for cancellation */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Composable configuration */
|
||||
export interface UseRRV3RpcOptions {
|
||||
/** Default request timeout (ms) */
|
||||
requestTimeoutMs?: number;
|
||||
/** Maximum reconnect attempts */
|
||||
maxReconnectAttempts?: number;
|
||||
/** Base delay for reconnection (ms) */
|
||||
baseReconnectDelayMs?: number;
|
||||
/** Auto-connect on initialization */
|
||||
autoConnect?: boolean;
|
||||
/** Connection state change callback */
|
||||
onConnectionChange?: (connected: boolean) => void;
|
||||
/** Error callback */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
/** Event listener function */
|
||||
type EventListener = (event: RunEvent) => void;
|
||||
|
||||
/** Pending request entry */
|
||||
interface PendingRequest {
|
||||
method: RpcMethod;
|
||||
resolve: (value: JsonValue) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
/** AbortSignal reference for cleanup */
|
||||
signal?: AbortSignal;
|
||||
/** Abort handler for cleanup */
|
||||
abortHandler?: () => void;
|
||||
}
|
||||
|
||||
/** Composable return type */
|
||||
export interface UseRRV3Rpc {
|
||||
// Connection state
|
||||
connected: Ref<boolean>;
|
||||
connecting: Ref<boolean>;
|
||||
reconnecting: Ref<boolean>;
|
||||
reconnectAttempts: Ref<number>;
|
||||
lastError: Ref<string | null>;
|
||||
isReady: ComputedRef<boolean>;
|
||||
|
||||
// Diagnostics
|
||||
pendingCount: Ref<number>;
|
||||
subscribedRunIds: Ref<Array<RunId | null>>;
|
||||
|
||||
// Connection lifecycle
|
||||
connect: () => Promise<boolean>;
|
||||
disconnect: (reason?: string) => void;
|
||||
ensureConnected: () => Promise<boolean>;
|
||||
|
||||
// RPC calls
|
||||
request: <T extends JsonValue = JsonValue>(
|
||||
method: RpcMethod,
|
||||
params?: JsonObject,
|
||||
options?: RpcRequestOptions,
|
||||
) => Promise<T>;
|
||||
|
||||
// Event subscription
|
||||
subscribe: (runId?: RunId | null) => Promise<boolean>;
|
||||
unsubscribe: (runId?: RunId | null) => Promise<boolean>;
|
||||
onEvent: (listener: EventListener) => () => void;
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRunEvent(value: unknown): value is RunEvent {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.runId === 'string' &&
|
||||
typeof obj.type === 'string' &&
|
||||
typeof obj.seq === 'number' &&
|
||||
typeof obj.ts === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Composable ====================
|
||||
|
||||
/**
|
||||
* RR V3 Port-RPC client
|
||||
*/
|
||||
export function useRRV3Rpc(options: UseRRV3RpcOptions = {}): UseRRV3Rpc {
|
||||
// Configuration
|
||||
const DEFAULT_TIMEOUT_MS = options.requestTimeoutMs ?? 12_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = options.maxReconnectAttempts ?? 8;
|
||||
const BASE_RECONNECT_DELAY_MS = options.baseReconnectDelayMs ?? 500;
|
||||
|
||||
// Reactive state
|
||||
const connected = ref(false);
|
||||
const connecting = ref(false);
|
||||
const reconnecting = ref(false);
|
||||
const reconnectAttempts = ref(0);
|
||||
const lastError = ref<string | null>(null);
|
||||
const pendingCount = ref(0);
|
||||
const subscribedRunIds = ref<Array<RunId | null>>([]);
|
||||
|
||||
// Internal state (non-reactive)
|
||||
const port = shallowRef<chrome.runtime.Port | null>(null);
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
const eventListeners = new Set<EventListener>();
|
||||
const desiredSubscriptions = new Set<RunId | null>();
|
||||
let connectPromise: Promise<boolean> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let manualDisconnect = false;
|
||||
|
||||
// Computed
|
||||
const isReady = computed(() => connected.value && port.value !== null);
|
||||
|
||||
// ==================== Internal Methods ====================
|
||||
|
||||
function setError(message: string | null): void {
|
||||
lastError.value = message;
|
||||
if (message) options.onError?.(message);
|
||||
}
|
||||
|
||||
function setConnected(next: boolean): void {
|
||||
if (connected.value === next) return;
|
||||
connected.value = next;
|
||||
options.onConnectionChange?.(next);
|
||||
}
|
||||
|
||||
function syncSubscriptionsSnapshot(): void {
|
||||
const arr = Array.from(desiredSubscriptions.values());
|
||||
arr.sort((a, b) => {
|
||||
// Both null - equal
|
||||
if (a === null && b === null) return 0;
|
||||
// null comes first
|
||||
if (a === null) return -1;
|
||||
if (b === null) return 1;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
subscribedRunIds.value = arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a pending request entry (timeout, abort listener)
|
||||
*/
|
||||
function cleanupPendingRequest(entry: PendingRequest): void {
|
||||
if (entry.timeoutId) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.timeoutId = null;
|
||||
}
|
||||
if (entry.signal && entry.abortHandler) {
|
||||
try {
|
||||
entry.signal.removeEventListener('abort', entry.abortHandler);
|
||||
} catch {
|
||||
// Ignore - signal may be invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rejectAllPending(reason: string): void {
|
||||
const error = new Error(reason);
|
||||
for (const [requestId, entry] of pendingRequests) {
|
||||
cleanupPendingRequest(entry);
|
||||
entry.reject(error);
|
||||
pendingRequests.delete(requestId);
|
||||
}
|
||||
pendingCount.value = 0;
|
||||
}
|
||||
|
||||
async function rehydrateSubscriptions(): Promise<void> {
|
||||
if (!isReady.value || desiredSubscriptions.size === 0) return;
|
||||
|
||||
for (const runId of desiredSubscriptions) {
|
||||
try {
|
||||
const params: JsonObject = runId === null ? {} : { runId };
|
||||
await request('rr_v3.subscribe', params).catch(() => {
|
||||
// Best-effort, ignore errors
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (manualDisconnect || reconnectTimer) return;
|
||||
|
||||
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnecting.value = false;
|
||||
setError('RR V3 RPC: max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
reconnecting.value = true;
|
||||
const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts.value);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectAttempts.value += 1;
|
||||
void connect().then((ok) => {
|
||||
if (!ok) scheduleReconnect();
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ==================== Port Handlers ====================
|
||||
|
||||
function handlePortDisconnect(): void {
|
||||
// Capture disconnect reason for debugging
|
||||
const disconnectReason = chrome.runtime.lastError?.message;
|
||||
const reason = disconnectReason
|
||||
? `RR V3 RPC disconnected: ${disconnectReason}`
|
||||
: 'RR V3 RPC disconnected';
|
||||
|
||||
port.value = null;
|
||||
setConnected(false);
|
||||
connecting.value = false;
|
||||
rejectAllPending(reason);
|
||||
|
||||
// Update lastError for UI visibility (only on unexpected disconnect)
|
||||
if (!manualDisconnect) {
|
||||
setError(reason);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePortMessage(msg: unknown): void {
|
||||
// Handle RPC response
|
||||
if (isRpcResponse(msg)) {
|
||||
const entry = pendingRequests.get(msg.requestId);
|
||||
if (!entry) return;
|
||||
|
||||
pendingRequests.delete(msg.requestId);
|
||||
pendingCount.value = pendingRequests.size;
|
||||
|
||||
// Clean up timeout and abort listener
|
||||
cleanupPendingRequest(entry);
|
||||
|
||||
if (msg.ok) {
|
||||
entry.resolve(msg.result as JsonValue);
|
||||
} else {
|
||||
entry.reject(new Error(msg.error || `RPC error: ${entry.method}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle event push
|
||||
if (isRpcEvent(msg)) {
|
||||
const event = msg.event;
|
||||
if (!isRunEvent(event)) return;
|
||||
|
||||
for (const listener of eventListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('[useRRV3Rpc] Event listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Public Methods ====================
|
||||
|
||||
async function connect(): Promise<boolean> {
|
||||
if (isReady.value) return true;
|
||||
if (connectPromise) return connectPromise;
|
||||
|
||||
connectPromise = (async () => {
|
||||
manualDisconnect = false;
|
||||
connecting.value = true;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {
|
||||
setError('chrome.runtime.connect not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
const p = chrome.runtime.connect({ name: RR_V3_PORT_NAME });
|
||||
port.value = p;
|
||||
|
||||
// 重置重连状态
|
||||
reconnectAttempts.value = 0;
|
||||
reconnecting.value = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
p.onMessage.addListener(handlePortMessage);
|
||||
p.onDisconnect.addListener(handlePortDisconnect);
|
||||
|
||||
setConnected(true);
|
||||
|
||||
// Restore subscriptions
|
||||
void rehydrateSubscriptions();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(`Connection failed: ${toErrorMessage(error)}`);
|
||||
return false;
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
connectPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function disconnect(reason?: string): void {
|
||||
manualDisconnect = true;
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
reconnecting.value = false;
|
||||
|
||||
const p = port.value;
|
||||
port.value = null;
|
||||
setConnected(false);
|
||||
connecting.value = false;
|
||||
|
||||
rejectAllPending(reason || 'RR V3 RPC: client disconnected');
|
||||
|
||||
if (p) {
|
||||
try {
|
||||
p.onMessage.removeListener(handlePortMessage);
|
||||
p.onDisconnect.removeListener(handlePortDisconnect);
|
||||
p.disconnect();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConnected(): Promise<boolean> {
|
||||
if (isReady.value) return true;
|
||||
return connect();
|
||||
}
|
||||
|
||||
async function request<T extends JsonValue = JsonValue>(
|
||||
method: RpcMethod,
|
||||
params?: JsonObject,
|
||||
reqOptions: RpcRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const ready = await ensureConnected();
|
||||
const p = port.value;
|
||||
|
||||
if (!ready || !p) {
|
||||
throw new Error('RR V3 RPC: not connected');
|
||||
}
|
||||
|
||||
const timeoutMs = reqOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const { signal } = reqOptions;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('RPC request already aborted');
|
||||
}
|
||||
|
||||
const req = createRpcRequest(method, params);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const entry: PendingRequest = {
|
||||
method,
|
||||
resolve: resolve as (value: JsonValue) => void,
|
||||
reject,
|
||||
timeoutId: null,
|
||||
signal,
|
||||
};
|
||||
|
||||
// Helper to complete request with cleanup
|
||||
const complete = (fn: () => void) => {
|
||||
pendingRequests.delete(req.requestId);
|
||||
pendingCount.value = pendingRequests.size;
|
||||
cleanupPendingRequest(entry);
|
||||
fn();
|
||||
};
|
||||
|
||||
// Timeout handling
|
||||
if (timeoutMs > 0) {
|
||||
entry.timeoutId = setTimeout(() => {
|
||||
complete(() => reject(new Error(`RPC timeout (${timeoutMs}ms): ${method}`)));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Abort handling
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
complete(() => reject(new Error('RPC request aborted')));
|
||||
};
|
||||
entry.abortHandler = onAbort;
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
pendingRequests.set(req.requestId, entry);
|
||||
pendingCount.value = pendingRequests.size;
|
||||
|
||||
try {
|
||||
p.postMessage(req);
|
||||
} catch (e) {
|
||||
complete(() => reject(new Error(`Failed to send RPC request: ${toErrorMessage(e)}`)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function subscribe(runId: RunId | null = null): Promise<boolean> {
|
||||
desiredSubscriptions.add(runId);
|
||||
syncSubscriptionsSnapshot();
|
||||
|
||||
try {
|
||||
const params: JsonObject = runId === null ? {} : { runId };
|
||||
await request('rr_v3.subscribe', params);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(toErrorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(runId: RunId | null = null): Promise<boolean> {
|
||||
desiredSubscriptions.delete(runId);
|
||||
syncSubscriptionsSnapshot();
|
||||
|
||||
try {
|
||||
const params: JsonObject = runId === null ? {} : { runId };
|
||||
await request('rr_v3.unsubscribe', params);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(toErrorMessage(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEvent(listener: EventListener): () => void {
|
||||
eventListeners.add(listener);
|
||||
return () => eventListeners.delete(listener);
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect('Component unmounted');
|
||||
});
|
||||
|
||||
if (options.autoConnect) {
|
||||
void ensureConnected();
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
reconnecting,
|
||||
reconnectAttempts,
|
||||
lastError,
|
||||
isReady,
|
||||
pendingCount,
|
||||
subscribedRunIds,
|
||||
connect,
|
||||
disconnect,
|
||||
ensureConnected,
|
||||
request,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
onEvent,
|
||||
};
|
||||
}
|
||||
export {
|
||||
useRRV3Rpc,
|
||||
type UseRRV3Rpc,
|
||||
type UseRRV3RpcOptions,
|
||||
type RpcRequestOptions,
|
||||
} from '@/entrypoints/shared/composables/useRRV3Rpc';
|
||||
|
||||
@@ -645,6 +645,7 @@
|
||||
|
||||
/* Button/interactive element base */
|
||||
.agent-theme .ac-btn {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--ac-motion-fast),
|
||||
color var(--ac-motion-fast);
|
||||
@@ -656,6 +657,7 @@
|
||||
|
||||
/* Menu item */
|
||||
.agent-theme .ac-menu-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--ac-motion-fast);
|
||||
}
|
||||
|
||||
@@ -694,24 +696,32 @@
|
||||
Loading Animation - Shimmer Text & Scribble Icon
|
||||
============================================================ */
|
||||
|
||||
/* 文案 shimmer 渐变动画 */
|
||||
/* 文案 shimmer 渐变动画 - 光从左到右扫过效果 */
|
||||
.agent-theme .text-shimmer {
|
||||
display: inline-block;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--ac-accent, #d97757) 0%,
|
||||
var(--ac-accent-hover, #ffcab0) 50%,
|
||||
var(--ac-accent, #d97757) 40%,
|
||||
#ffe0d0 50%,
|
||||
var(--ac-accent, #d97757) 60%,
|
||||
var(--ac-accent, #d97757) 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
background-size: 250% 100%;
|
||||
background-repeat: no-repeat;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: ac-shimmer 3s linear infinite;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: ac-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ac-shimmer {
|
||||
to {
|
||||
background-position: 200% center;
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* @module props-bridge
|
||||
*/
|
||||
|
||||
import type { ElementLocator } from '@/common/web-editor-types';
|
||||
import type { DebugSource, ElementLocator } from '@/common/web-editor-types';
|
||||
|
||||
// =============================================================================
|
||||
// Types - Hook Status
|
||||
@@ -223,7 +223,11 @@ export interface PropsResponseData {
|
||||
hookStatus?: HookStatus;
|
||||
needsRefresh?: boolean;
|
||||
framework?: FrameworkType;
|
||||
/** Framework version (e.g., "18.2.0" for React, "3.4.21" for Vue) */
|
||||
frameworkVersion?: string;
|
||||
componentName?: string;
|
||||
/** Source file location for the component (React _debugSource / Vue data-v-inspector) */
|
||||
debugSource?: DebugSource;
|
||||
props?: SerializedProps;
|
||||
capabilities?: PropsCapabilities;
|
||||
meta?: Record<string, unknown>;
|
||||
|
||||
@@ -67,9 +67,16 @@ function isDangerousPropKey(key: string): boolean {
|
||||
return DANGEROUS_PROP_KEYS.has(String(key ?? '').trim());
|
||||
}
|
||||
|
||||
function formatFramework(framework: FrameworkType | undefined): string {
|
||||
if (framework === 'react') return 'React';
|
||||
if (framework === 'vue') return 'Vue';
|
||||
function formatFramework(framework: FrameworkType | undefined, version?: string): string {
|
||||
// Only show version for known frameworks to avoid "Unknown x.y.z" display
|
||||
if (framework === 'react') {
|
||||
const trimmedVersion = version?.trim();
|
||||
return trimmedVersion ? `React ${trimmedVersion}` : 'React';
|
||||
}
|
||||
if (framework === 'vue') {
|
||||
const trimmedVersion = version?.trim();
|
||||
return trimmedVersion ? `Vue ${trimmedVersion}` : 'Vue';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
@@ -77,6 +84,26 @@ function formatHookStatus(hookStatus: HookStatus | undefined): string {
|
||||
return hookStatus ? String(hookStatus) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format debug source for display.
|
||||
* Returns empty string if source is invalid/missing.
|
||||
*/
|
||||
function formatDebugSource(source: unknown): string {
|
||||
if (!source || typeof source !== 'object') return '';
|
||||
|
||||
const rec = source as Record<string, unknown>;
|
||||
const file = typeof rec.file === 'string' ? rec.file.trim() : '';
|
||||
if (!file) return '';
|
||||
|
||||
const lineRaw = Number(rec.line);
|
||||
const columnRaw = Number(rec.column);
|
||||
const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined;
|
||||
const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined;
|
||||
|
||||
if (!line) return file;
|
||||
return column ? `${file}:${line}:${column}` : `${file}:${line}`;
|
||||
}
|
||||
|
||||
function formatSerializedValue(value: SerializedValue): string {
|
||||
switch (value.kind) {
|
||||
case 'null':
|
||||
@@ -275,6 +302,39 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
const { container, propsBridge } = options;
|
||||
const disposer = new Disposer();
|
||||
|
||||
// ==========================================================================
|
||||
// Tooltip - fixed position at shadow root level to avoid overflow clipping
|
||||
// ==========================================================================
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'we-tooltip';
|
||||
tooltip.hidden = true;
|
||||
|
||||
const rootNode = container.getRootNode();
|
||||
if (rootNode instanceof ShadowRoot) {
|
||||
rootNode.appendChild(tooltip);
|
||||
} else {
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
disposer.add(() => tooltip.remove());
|
||||
|
||||
function showTooltip(el: Element): void {
|
||||
const text = el.getAttribute('data-tip');
|
||||
if (!text) {
|
||||
tooltip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = `${rect.left + rect.width / 2}px`;
|
||||
tooltip.style.top = `${rect.bottom + 4}px`;
|
||||
tooltip.hidden = false;
|
||||
}
|
||||
|
||||
function hideTooltip(): void {
|
||||
tooltip.hidden = true;
|
||||
}
|
||||
|
||||
// State
|
||||
let currentTarget: Element | null = null;
|
||||
let currentLocator: ElementLocator | null = null;
|
||||
@@ -303,6 +363,9 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
const metaTitleRow = document.createElement('div');
|
||||
metaTitleRow.className = 'we-props-meta-title';
|
||||
|
||||
const titleLeft = document.createElement('div');
|
||||
titleLeft.className = 'we-props-title-left';
|
||||
|
||||
const componentEl = document.createElement('div');
|
||||
componentEl.className = 'we-props-component';
|
||||
componentEl.textContent = 'Props';
|
||||
@@ -311,7 +374,34 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
frameworkEl.className = 'we-props-badge';
|
||||
frameworkEl.textContent = 'Unknown';
|
||||
|
||||
metaTitleRow.append(componentEl, frameworkEl);
|
||||
titleLeft.append(componentEl, frameworkEl);
|
||||
|
||||
// Action buttons in title row (icon style)
|
||||
const titleActions = document.createElement('div');
|
||||
titleActions.className = 'we-props-title-actions';
|
||||
|
||||
const refreshBtn = document.createElement('button');
|
||||
refreshBtn.type = 'button';
|
||||
refreshBtn.className = 'we-props-action-btn';
|
||||
refreshBtn.dataset.tip = 'Refresh';
|
||||
refreshBtn.setAttribute('aria-label', 'Refresh props');
|
||||
// Refresh icon (circular arrow)
|
||||
refreshBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.5 7C11.5 9.48528 9.48528 11.5 7 11.5C4.51472 11.5 2.5 9.48528 2.5 7C2.5 4.51472 4.51472 2.5 7 2.5C8.5 2.5 9.83 3.25 10.6 4.4M10.6 2V4.4H8.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.type = 'button';
|
||||
resetBtn.className = 'we-props-action-btn';
|
||||
resetBtn.dataset.tip = 'Reset';
|
||||
resetBtn.setAttribute('aria-label', 'Reset props changes');
|
||||
// Reset icon (undo arrow)
|
||||
resetBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 5.5H8.5C10.1569 5.5 11.5 6.84315 11.5 8.5C11.5 10.1569 10.1569 11.5 8.5 11.5H7M3 5.5L5.5 3M3 5.5L5.5 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
|
||||
titleActions.append(refreshBtn, resetBtn);
|
||||
metaTitleRow.append(titleLeft, titleActions);
|
||||
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.className = 'we-props-status';
|
||||
@@ -324,21 +414,32 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
errorEl.className = 'we-props-error';
|
||||
errorEl.hidden = true;
|
||||
|
||||
const actionsRow = document.createElement('div');
|
||||
actionsRow.className = 'we-props-actions';
|
||||
// Source row - shows component source file location with "Open in VSCode" button
|
||||
const sourceRow = document.createElement('div');
|
||||
sourceRow.className = 'we-props-source';
|
||||
sourceRow.hidden = true;
|
||||
|
||||
const refreshBtn = document.createElement('button');
|
||||
refreshBtn.type = 'button';
|
||||
refreshBtn.className = 'we-btn';
|
||||
refreshBtn.textContent = 'Refresh';
|
||||
const sourceLabelEl = document.createElement('span');
|
||||
sourceLabelEl.className = 'we-props-source-label';
|
||||
sourceLabelEl.textContent = 'Source';
|
||||
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.type = 'button';
|
||||
resetBtn.className = 'we-btn';
|
||||
resetBtn.textContent = 'Reset';
|
||||
const sourcePathEl = document.createElement('span');
|
||||
sourcePathEl.className = 'we-props-source-path';
|
||||
sourcePathEl.title = ''; // Will be set to full path on render
|
||||
|
||||
actionsRow.append(refreshBtn, resetBtn);
|
||||
meta.append(metaTitleRow, statusEl, warningEl, errorEl, actionsRow);
|
||||
const openSourceBtn = document.createElement('button');
|
||||
openSourceBtn.type = 'button';
|
||||
openSourceBtn.className = 'we-props-source-btn';
|
||||
openSourceBtn.dataset.tip = 'Open in VSCode';
|
||||
openSourceBtn.setAttribute('aria-label', 'Open in VSCode');
|
||||
// Simple arrow pointing to top-right (external link style)
|
||||
openSourceBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 2.5H9.5V8.5M9 3L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
|
||||
sourceRow.append(sourceLabelEl, sourcePathEl, openSourceBtn);
|
||||
|
||||
meta.append(metaTitleRow, statusEl, warningEl, errorEl, sourceRow);
|
||||
|
||||
// List section
|
||||
const list = document.createElement('div');
|
||||
@@ -417,10 +518,11 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
function renderMeta(): void {
|
||||
const hasTarget = Boolean(currentTarget && currentTarget.isConnected);
|
||||
const framework = lastData?.framework;
|
||||
const frameworkVersion = lastData?.frameworkVersion;
|
||||
const componentName = lastData?.componentName;
|
||||
|
||||
componentEl.textContent = componentName || 'Props';
|
||||
frameworkEl.textContent = formatFramework(framework);
|
||||
frameworkEl.textContent = formatFramework(framework, frameworkVersion);
|
||||
|
||||
statusEl.textContent = hasTarget
|
||||
? buildStatusLine(loading, lastData, lastError)
|
||||
@@ -448,13 +550,19 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
errorEl.hidden = !lastError;
|
||||
errorEl.textContent = lastError ?? '';
|
||||
|
||||
// Update refresh button text based on needsRefresh state
|
||||
// Only show "Enable & Reload" for hook issues that can benefit from early injection
|
||||
// Source display - show component file location with Open button
|
||||
const sourceText = hasTarget ? formatDebugSource(lastData?.debugSource) : '';
|
||||
sourceRow.hidden = !sourceText;
|
||||
sourcePathEl.textContent = sourceText;
|
||||
sourcePathEl.title = sourceText; // Show full path on hover
|
||||
openSourceBtn.disabled = !sourceText || loading;
|
||||
|
||||
// Update refresh button state and tooltip
|
||||
const hookStatus = lastData?.hookStatus;
|
||||
const canBenefitFromEarlyInjection =
|
||||
hookStatus === 'HOOK_MISSING' || hookStatus === 'HOOK_PRESENT_NO_RENDERERS';
|
||||
const showEnableReload = lastData?.needsRefresh && canBenefitFromEarlyInjection;
|
||||
refreshBtn.textContent = showEnableReload ? 'Enable & Reload' : 'Refresh';
|
||||
refreshBtn.dataset.tip = showEnableReload ? 'Enable & Reload' : 'Refresh';
|
||||
refreshBtn.disabled = !hasTarget || loading;
|
||||
resetBtn.disabled = !hasTarget || loading || !getCanWrite(lastData);
|
||||
}
|
||||
@@ -467,16 +575,24 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
|
||||
if (!hasTarget) {
|
||||
emptyState.hidden = false;
|
||||
emptyState.classList.remove('we-loading');
|
||||
emptyState.textContent = 'Select an element to view props.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
emptyState.hidden = false;
|
||||
emptyState.textContent = 'Loading props…';
|
||||
emptyState.classList.add('we-loading');
|
||||
// Spinner icon (thin stroke) + text
|
||||
emptyState.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="animation: we-spin 0.8s linear infinite;">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-dasharray="20 14" />
|
||||
</svg><span>Loading props…</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove loading class when not loading
|
||||
emptyState.classList.remove('we-loading');
|
||||
|
||||
const canRead = getCanRead(data);
|
||||
if (!canRead) {
|
||||
emptyState.hidden = false;
|
||||
@@ -841,10 +957,50 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to background to open source file in VSCode.
|
||||
*/
|
||||
async function openSourceInVSCode(): Promise<void> {
|
||||
if (disposer.isDisposed) return;
|
||||
|
||||
const debugSource = lastData?.debugSource;
|
||||
if (!debugSource || !formatDebugSource(debugSource)) return;
|
||||
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
||||
lastError = 'Chrome runtime API not available';
|
||||
renderMeta();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE,
|
||||
payload: { debugSource },
|
||||
});
|
||||
|
||||
if (resp?.success === false) {
|
||||
lastError = resp?.error ?? 'Failed to open source in VSCode';
|
||||
renderMeta();
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
renderMeta();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Event Handlers
|
||||
// ==========================================================================
|
||||
|
||||
// Tooltip events - bind directly to elements with data-tip
|
||||
const bindTooltip = (el: HTMLElement) => {
|
||||
disposer.listen(el, 'mouseenter', () => showTooltip(el));
|
||||
disposer.listen(el, 'mouseleave', hideTooltip);
|
||||
};
|
||||
bindTooltip(refreshBtn);
|
||||
bindTooltip(resetBtn);
|
||||
bindTooltip(openSourceBtn);
|
||||
|
||||
disposer.listen(refreshBtn, 'click', (e) => {
|
||||
e.preventDefault();
|
||||
clearAllPendingWrites();
|
||||
@@ -868,6 +1024,11 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
|
||||
void resetOverrides();
|
||||
});
|
||||
|
||||
disposer.listen(openSourceBtn, 'click', (e) => {
|
||||
e.preventDefault();
|
||||
void openSourceInVSCode();
|
||||
});
|
||||
|
||||
// Delegate input events within the list
|
||||
disposer.listen(rows, 'input', (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
|
||||
@@ -3072,11 +3072,125 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.we-props-actions {
|
||||
.we-props-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.we-props-source[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.we-props-source-label {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.we-props-source-path {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--we-text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.we-btn-small {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Source open button - minimal link style */
|
||||
.we-props-source-btn {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.we-props-source-btn:hover:not(:disabled) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.we-props-source-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.we-props-source-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tooltip - fixed position, mounted at shadow root level */
|
||||
.we-tooltip {
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.we-tooltip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.we-props-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.we-props-title-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Action button - minimal icon style for title bar */
|
||||
.we-props-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--we-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.we-props-action-btn:hover:not(:disabled) {
|
||||
color: var(--we-text-primary);
|
||||
}
|
||||
|
||||
.we-props-action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.we-props-action-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.we-props-list {
|
||||
@@ -3094,6 +3208,45 @@ const SHADOW_HOST_STYLES = /* css */ `
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
@keyframes we-shimmer {
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes we-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.we-props-empty.we-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.we-props-empty.we-loading svg {
|
||||
flex-shrink: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.we-props-empty.we-loading span {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#64748b 0%,
|
||||
#94a3b8 50%,
|
||||
#64748b 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: we-shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
.we-props-group {
|
||||
padding: 0 0 8px 0;
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Element Picker Inject Script
|
||||
*
|
||||
* Injected script to let the user manually pick elements for chrome_request_element_selection.
|
||||
* - Writes refs into window.__claudeElementMap (compatible with accessibility-tree-helper.js)
|
||||
* - Generates stable CSS selectors (prefers id/data-testid/etc.)
|
||||
* - Supports iframe picking by reporting selection via chrome.runtime.sendMessage (background reads sender.frameId)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Prevent double initialization
|
||||
if (window.__MCP_ELEMENT_PICKER_INITIALIZED__) return;
|
||||
window.__MCP_ELEMENT_PICKER_INITIALIZED__ = true;
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const UI_HOST_ID = '__mcp_element_picker_host__';
|
||||
const HIGHLIGHT_ID = '__mcp_element_picker_highlight__';
|
||||
const MAX_TEXT_LEN = 160;
|
||||
|
||||
// Highlight colors matching quick-panel accent
|
||||
const HIGHLIGHT_COLOR = 'rgba(192, 132, 252, 0.95)';
|
||||
const HIGHLIGHT_BG = 'rgba(192, 132, 252, 0.06)';
|
||||
const HIGHLIGHT_SHADOW = 'rgba(192, 132, 252, 0.20)';
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
const STATE = {
|
||||
active: false,
|
||||
sessionId: null,
|
||||
activeRequestId: null,
|
||||
listenersAttached: false,
|
||||
hoverRafId: null,
|
||||
pendingHoverEvent: null,
|
||||
lastHoverEl: null,
|
||||
highlighter: null,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// CSS Escape Helper
|
||||
// ============================================================
|
||||
|
||||
function cssEscape(value) {
|
||||
try {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
||||
return window.CSS.escape(value);
|
||||
}
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI Detection Helpers
|
||||
// ============================================================
|
||||
|
||||
function getUiHost() {
|
||||
try {
|
||||
return document.getElementById(UI_HOST_ID);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOverlayElement(node) {
|
||||
if (!(node instanceof Node)) return false;
|
||||
const host = getUiHost();
|
||||
if (!host) return false;
|
||||
if (node === host) return true;
|
||||
const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;
|
||||
return root instanceof ShadowRoot && root.host === host;
|
||||
}
|
||||
|
||||
function isEventFromUi(ev) {
|
||||
if (!ev) return false;
|
||||
try {
|
||||
if (typeof ev.composedPath === 'function') {
|
||||
const path = ev.composedPath();
|
||||
if (Array.isArray(path)) {
|
||||
return path.some((n) => isOverlayElement(n));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
return isOverlayElement(ev.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the deepest page target from an event, handling Shadow DOM.
|
||||
*/
|
||||
function getDeepPageTarget(ev) {
|
||||
if (!ev) return null;
|
||||
try {
|
||||
const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;
|
||||
if (Array.isArray(path) && path.length > 0) {
|
||||
for (const node of path) {
|
||||
if (node instanceof Element && !isOverlayElement(node)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
const fallback = ev.target instanceof Element ? ev.target : null;
|
||||
if (fallback && !isOverlayElement(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Highlighter
|
||||
// ============================================================
|
||||
|
||||
function ensureHighlighter() {
|
||||
if (STATE.highlighter && STATE.highlighter.isConnected) {
|
||||
return STATE.highlighter;
|
||||
}
|
||||
|
||||
// Remove any existing highlighter
|
||||
try {
|
||||
const existing = document.getElementById(HIGHLIGHT_ID);
|
||||
if (existing) existing.remove();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
const hl = document.createElement('div');
|
||||
hl.id = HIGHLIGHT_ID;
|
||||
Object.assign(hl.style, {
|
||||
position: 'fixed',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
border: `2px solid ${HIGHLIGHT_COLOR}`,
|
||||
borderRadius: '6px',
|
||||
boxShadow: `0 0 0 2px ${HIGHLIGHT_SHADOW}`,
|
||||
background: HIGHLIGHT_BG,
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2147483647',
|
||||
display: 'none',
|
||||
transition: 'transform 60ms linear, width 60ms linear, height 60ms linear',
|
||||
});
|
||||
|
||||
try {
|
||||
(document.documentElement || document.body).appendChild(hl);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
STATE.highlighter = hl;
|
||||
return hl;
|
||||
}
|
||||
|
||||
function clearHighlighter() {
|
||||
const hl = STATE.highlighter;
|
||||
if (!hl) return;
|
||||
try {
|
||||
hl.style.display = 'none';
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlighterTo(el) {
|
||||
const hl = ensureHighlighter();
|
||||
if (!hl || !(el instanceof Element)) return;
|
||||
|
||||
let rect;
|
||||
try {
|
||||
rect = el.getBoundingClientRect();
|
||||
} catch {
|
||||
clearHighlighter();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||
clearHighlighter();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
hl.style.display = 'block';
|
||||
hl.style.transform = `translate(${Math.round(rect.left)}px, ${Math.round(rect.top)}px)`;
|
||||
hl.style.width = `${Math.round(rect.width)}px`;
|
||||
hl.style.height = `${Math.round(rect.height)}px`;
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Selector Uniqueness Check (Optimized)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if element is inside a Shadow DOM.
|
||||
*/
|
||||
function isInShadowDom(el) {
|
||||
try {
|
||||
const root = el.getRootNode();
|
||||
return root instanceof ShadowRoot;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast uniqueness check using native querySelectorAll.
|
||||
* For Shadow DOM elements, queries within their shadow root only.
|
||||
*/
|
||||
function isSelectorUnique(selector, target) {
|
||||
if (!selector || !(target instanceof Element)) return false;
|
||||
|
||||
try {
|
||||
// For elements not in Shadow DOM, use fast native query
|
||||
if (!isInShadowDom(target)) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
return matches.length === 1 && matches[0] === target;
|
||||
}
|
||||
|
||||
// For Shadow DOM elements, query within their root
|
||||
const root = target.getRootNode();
|
||||
if (root instanceof ShadowRoot) {
|
||||
const matches = root.querySelectorAll(selector);
|
||||
return matches.length === 1 && matches[0] === target;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Selector Generation (Stable & Unique)
|
||||
// ============================================================
|
||||
|
||||
function buildPathFromAncestor(ancestor, target) {
|
||||
const segs = [];
|
||||
let cur = target;
|
||||
|
||||
const root = target.getRootNode();
|
||||
const isShadowElement = root instanceof ShadowRoot;
|
||||
const boundary = isShadowElement ? root.host : document.body;
|
||||
|
||||
while (cur && cur !== ancestor && cur !== boundary) {
|
||||
let seg = cur.tagName.toLowerCase();
|
||||
const parent = cur.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
||||
if (siblings.length > 1) {
|
||||
seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`;
|
||||
}
|
||||
}
|
||||
segs.unshift(seg);
|
||||
cur = parent;
|
||||
if (isShadowElement && cur === boundary) break;
|
||||
}
|
||||
|
||||
return segs.join(' > ');
|
||||
}
|
||||
|
||||
function buildFullPath(el) {
|
||||
let path = '';
|
||||
let current = el;
|
||||
|
||||
const root = el.getRootNode();
|
||||
const isShadowElement = root instanceof ShadowRoot;
|
||||
const boundary = isShadowElement ? root.host : document.body;
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) {
|
||||
let sel = current.tagName.toLowerCase();
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
||||
if (siblings.length > 1) {
|
||||
sel += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
||||
}
|
||||
}
|
||||
path = path ? `${sel} > ${path}` : sel;
|
||||
current = parent;
|
||||
if (isShadowElement && current === boundary) break;
|
||||
}
|
||||
|
||||
if (isShadowElement) return path || el.tagName.toLowerCase();
|
||||
return path ? `body > ${path}` : 'body';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable CSS selector for an element.
|
||||
* Prioritizes: id > data-testid/data-test/etc > anchor + relative path > full path
|
||||
*/
|
||||
function generateSelector(el) {
|
||||
if (!(el instanceof Element)) return '';
|
||||
|
||||
// Prefer unique IDs
|
||||
try {
|
||||
if (el.id) {
|
||||
const idSel = `#${cssEscape(el.id)}`;
|
||||
if (isSelectorUnique(idSel, el)) return idSel;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
|
||||
// Prefer stable test attributes
|
||||
try {
|
||||
const attrNames = [
|
||||
'data-testid',
|
||||
'data-testId',
|
||||
'data-test',
|
||||
'data-qa',
|
||||
'data-cy',
|
||||
'name',
|
||||
'aria-label',
|
||||
'title',
|
||||
'alt',
|
||||
];
|
||||
const tag = el.tagName.toLowerCase();
|
||||
for (const attr of attrNames) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (!v) continue;
|
||||
const attrSel = `[${attr}="${cssEscape(v)}"]`;
|
||||
const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel;
|
||||
if (isSelectorUnique(testSel, el)) return testSel;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
|
||||
// Anchor + relative path
|
||||
try {
|
||||
let cur = el;
|
||||
const anchorAttrs = [
|
||||
'id',
|
||||
'data-testid',
|
||||
'data-testId',
|
||||
'data-test',
|
||||
'data-qa',
|
||||
'data-cy',
|
||||
'name',
|
||||
];
|
||||
|
||||
const root = el.getRootNode();
|
||||
const isShadowElement = root instanceof ShadowRoot;
|
||||
const boundary = isShadowElement ? root.host : document.body;
|
||||
|
||||
while (cur && cur !== boundary) {
|
||||
if (cur.id) {
|
||||
const anchor = `#${cssEscape(cur.id)}`;
|
||||
if (isSelectorUnique(anchor, cur)) {
|
||||
const rel = buildPathFromAncestor(cur, el);
|
||||
const composed = rel ? `${anchor} ${rel}` : anchor;
|
||||
if (isSelectorUnique(composed, el)) return composed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of anchorAttrs) {
|
||||
const val = cur.getAttribute(attr);
|
||||
if (!val) continue;
|
||||
const aSel = `[${attr}="${cssEscape(val)}"]`;
|
||||
if (isSelectorUnique(aSel, cur)) {
|
||||
const rel = buildPathFromAncestor(cur, el);
|
||||
const composed = rel ? `${aSel} ${rel}` : aSel;
|
||||
if (isSelectorUnique(composed, el)) return composed;
|
||||
}
|
||||
}
|
||||
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
|
||||
// Fallback to full path
|
||||
return buildFullPath(el);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Text Summarization
|
||||
// ============================================================
|
||||
|
||||
function summarizeText(el) {
|
||||
if (!(el instanceof Element)) return '';
|
||||
try {
|
||||
const aria = el.getAttribute('aria-label');
|
||||
if (aria && aria.trim()) return aria.trim().slice(0, MAX_TEXT_LEN);
|
||||
const placeholder = el.getAttribute('placeholder');
|
||||
if (placeholder && placeholder.trim()) return placeholder.trim().slice(0, MAX_TEXT_LEN);
|
||||
const title = el.getAttribute('title');
|
||||
if (title && title.trim()) return title.trim().slice(0, MAX_TEXT_LEN);
|
||||
const alt = el.getAttribute('alt');
|
||||
if (alt && alt.trim()) return alt.trim().slice(0, MAX_TEXT_LEN);
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
try {
|
||||
const t = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
||||
return t ? t.slice(0, MAX_TEXT_LEN) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Ref Management (Compatible with accessibility-tree-helper.js)
|
||||
// ============================================================
|
||||
|
||||
function ensureRefForElement(el) {
|
||||
try {
|
||||
if (!window.__claudeElementMap) window.__claudeElementMap = {};
|
||||
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
// Check if element already has a ref
|
||||
let refId = null;
|
||||
try {
|
||||
for (const k in window.__claudeElementMap) {
|
||||
const w = window.__claudeElementMap[k];
|
||||
if (w && w.deref && w.deref() === el) {
|
||||
refId = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
|
||||
// Create new ref if needed
|
||||
if (!refId) {
|
||||
try {
|
||||
refId = `ref_${++window.__claudeRefCounter}`;
|
||||
window.__claudeElementMap[refId] = new WeakRef(el);
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
return refId || '';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Communication
|
||||
// ============================================================
|
||||
|
||||
function sendFrameEvent(payload) {
|
||||
try {
|
||||
chrome.runtime.sendMessage(payload);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handlers
|
||||
// ============================================================
|
||||
|
||||
function processMouseMove(ev) {
|
||||
if (!STATE.active) return;
|
||||
|
||||
// Skip if event is from our UI
|
||||
if (isEventFromUi(ev)) {
|
||||
STATE.lastHoverEl = null;
|
||||
clearHighlighter();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = getDeepPageTarget(ev);
|
||||
if (!target) {
|
||||
STATE.lastHoverEl = null;
|
||||
clearHighlighter();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if same element
|
||||
if (STATE.lastHoverEl === target) return;
|
||||
STATE.lastHoverEl = target;
|
||||
moveHighlighterTo(target);
|
||||
}
|
||||
|
||||
function onMouseMove(ev) {
|
||||
if (!STATE.active) return;
|
||||
STATE.pendingHoverEvent = ev;
|
||||
if (STATE.hoverRafId != null) return;
|
||||
STATE.hoverRafId = requestAnimationFrame(() => {
|
||||
STATE.hoverRafId = null;
|
||||
const latest = STATE.pendingHoverEvent;
|
||||
STATE.pendingHoverEvent = null;
|
||||
if (!latest) return;
|
||||
processMouseMove(latest);
|
||||
});
|
||||
}
|
||||
|
||||
function onClick(ev) {
|
||||
if (!STATE.active) return;
|
||||
|
||||
// Allow UI interactions without interference
|
||||
if (isEventFromUi(ev)) return;
|
||||
|
||||
const rawTarget = ev.target instanceof Element ? ev.target : null;
|
||||
if (!rawTarget) return;
|
||||
|
||||
// Require an active request id so background can map the selection
|
||||
if (!STATE.sessionId || !STATE.activeRequestId) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const target = getDeepPageTarget(ev) || rawTarget;
|
||||
if (!(target instanceof Element)) return;
|
||||
|
||||
const ref = ensureRefForElement(target);
|
||||
const selector = generateSelector(target);
|
||||
let rect;
|
||||
try {
|
||||
rect = target.getBoundingClientRect();
|
||||
} catch {
|
||||
rect = { x: 0, y: 0, width: 0, height: 0, left: 0, top: 0 };
|
||||
}
|
||||
|
||||
const center = {
|
||||
x: Math.round(rect.left + rect.width / 2),
|
||||
y: Math.round(rect.top + rect.height / 2),
|
||||
};
|
||||
|
||||
sendFrameEvent({
|
||||
type: 'element_picker_frame_event',
|
||||
sessionId: STATE.sessionId,
|
||||
event: 'selected',
|
||||
requestId: STATE.activeRequestId,
|
||||
element: {
|
||||
ref,
|
||||
selector,
|
||||
selectorType: 'css',
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
center,
|
||||
text: summarizeText(target),
|
||||
tagName: target.tagName ? String(target.tagName).toLowerCase() : '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyDown(ev) {
|
||||
if (!STATE.active) return;
|
||||
if (ev && ev.key === 'Escape') {
|
||||
if (isEventFromUi(ev)) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (STATE.sessionId) {
|
||||
sendFrameEvent({
|
||||
type: 'element_picker_frame_event',
|
||||
sessionId: STATE.sessionId,
|
||||
event: 'cancel',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Listener Management
|
||||
// ============================================================
|
||||
|
||||
function attachListeners() {
|
||||
if (STATE.listenersAttached) return;
|
||||
window.addEventListener('mousemove', onMouseMove, true);
|
||||
window.addEventListener('click', onClick, true);
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
STATE.listenersAttached = true;
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
if (!STATE.listenersAttached) return;
|
||||
window.removeEventListener('mousemove', onMouseMove, true);
|
||||
window.removeEventListener('click', onClick, true);
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
STATE.listenersAttached = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Management API
|
||||
// ============================================================
|
||||
|
||||
function startSession(payload) {
|
||||
const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';
|
||||
if (!sessionId) return;
|
||||
|
||||
STATE.active = true;
|
||||
STATE.sessionId = sessionId;
|
||||
STATE.activeRequestId =
|
||||
payload && payload.activeRequestId ? String(payload.activeRequestId) : null;
|
||||
ensureHighlighter();
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function stopSession(payload) {
|
||||
const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';
|
||||
// Only stop if session matches or no specific session requested
|
||||
if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;
|
||||
|
||||
STATE.active = false;
|
||||
STATE.sessionId = null;
|
||||
STATE.activeRequestId = null;
|
||||
STATE.lastHoverEl = null;
|
||||
detachListeners();
|
||||
clearHighlighter();
|
||||
|
||||
// Remove highlighter element
|
||||
try {
|
||||
const hl = STATE.highlighter;
|
||||
if (hl && hl.remove) hl.remove();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
STATE.highlighter = null;
|
||||
}
|
||||
|
||||
function setActiveRequest(payload) {
|
||||
const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';
|
||||
if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;
|
||||
STATE.activeRequestId =
|
||||
payload && payload.activeRequestId ? String(payload.activeRequestId) : null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose API for Background Script
|
||||
// ============================================================
|
||||
|
||||
window.__mcpElementPicker = {
|
||||
startSession,
|
||||
stopSession,
|
||||
setActiveRequest,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Message Listener (for direct communication)
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
try {
|
||||
if (request && request.action === 'chrome_request_element_selection_ping') {
|
||||
sendResponse({ status: 'pong' });
|
||||
return false;
|
||||
}
|
||||
if (request && request.action === 'elementPickerStart') {
|
||||
startSession(request);
|
||||
sendResponse({ success: true });
|
||||
return false;
|
||||
}
|
||||
if (request && request.action === 'elementPickerStop') {
|
||||
stopSession(request);
|
||||
sendResponse({ success: true });
|
||||
return false;
|
||||
}
|
||||
if (request && request.action === 'elementPickerSetActiveRequest') {
|
||||
setActiveRequest(request);
|
||||
sendResponse({ success: true });
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})();
|
||||
@@ -393,6 +393,43 @@
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get React version from renderer or global.
|
||||
* Prioritizes specific renderer version for multi-renderer scenarios.
|
||||
*
|
||||
* @param {object} hookInfo - Result from detectStatus()
|
||||
* @param {object} [specificRenderer] - Specific renderer to prefer (from resolveFiberWithRenderer)
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
getVersion(hookInfo, specificRenderer) {
|
||||
try {
|
||||
// Priority 1: Specific renderer version (for multi-renderer scenarios)
|
||||
if (specificRenderer) {
|
||||
const version = specificRenderer.version;
|
||||
if (typeof version === 'string' && version.trim()) {
|
||||
return version.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Any renderer with version
|
||||
const renderers = hookInfo?.renderers || [];
|
||||
for (const item of renderers) {
|
||||
const version = item?.renderer?.version;
|
||||
if (typeof version === 'string' && version.trim()) {
|
||||
return version.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Global React object (if exposed)
|
||||
if (typeof window !== 'undefined' && window.React?.version) {
|
||||
return String(window.React.version).trim();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find React fiber from DOM node
|
||||
*/
|
||||
@@ -450,6 +487,66 @@
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract debug source from React Fiber.
|
||||
* Walks up the fiber tree checking _debugSource and _debugOwner._debugSource.
|
||||
*
|
||||
* @param {object} fiber - React Fiber node
|
||||
* @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}
|
||||
*/
|
||||
getDebugSource(fiber) {
|
||||
try {
|
||||
let current = fiber;
|
||||
for (let i = 0; i < 40 && current; i++) {
|
||||
if (!isObject(current)) break;
|
||||
|
||||
// Try direct _debugSource first
|
||||
const src = isObject(current._debugSource) ? current._debugSource : null;
|
||||
if (src) {
|
||||
const file = safeString(src.fileName).trim();
|
||||
if (file) {
|
||||
return this.buildDebugSourceResult(file, src.lineNumber, src.columnNumber, current);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to _debugOwner._debugSource
|
||||
const owner = isObject(current._debugOwner) ? current._debugOwner : null;
|
||||
const ownerSrc = owner && isObject(owner._debugSource) ? owner._debugSource : null;
|
||||
if (ownerSrc) {
|
||||
const ownerFile = safeString(ownerSrc.fileName).trim();
|
||||
if (ownerFile) {
|
||||
return this.buildDebugSourceResult(
|
||||
ownerFile,
|
||||
ownerSrc.lineNumber,
|
||||
ownerSrc.columnNumber,
|
||||
owner,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
current = current.return;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort extraction
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build debug source result with validated line/column values.
|
||||
* @private
|
||||
*/
|
||||
buildDebugSourceResult(file, lineNumber, columnNumber, fiberForName) {
|
||||
const line = Number(lineNumber);
|
||||
const column = Number(columnNumber);
|
||||
return {
|
||||
file,
|
||||
line: Number.isFinite(line) && line > 0 ? line : undefined,
|
||||
column: Number.isFinite(column) && column > 0 ? column : undefined,
|
||||
componentName: this.getComponentName(fiberForName),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve fiber using renderer.findFiberByHostInstance when available
|
||||
*/
|
||||
@@ -571,6 +668,125 @@
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse Vue inspector location attribute value.
|
||||
* Format: "src/components/Foo.vue:23:7" or "C:\path\file.vue:10:5" (Windows)
|
||||
*
|
||||
* Uses trailing regex to safely handle Windows paths with drive letters.
|
||||
*
|
||||
* @param {string} value - The data-v-inspector attribute value
|
||||
* @returns {{ file: string, line?: number, column?: number } | null}
|
||||
*/
|
||||
parseVInspector(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const raw = value.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// Match only trailing :line or :line:column to avoid Windows drive letter issues
|
||||
const match = raw.match(/:([\d]+)(?::([\d]+))?$/);
|
||||
if (!match) {
|
||||
// No line info, return file only
|
||||
return { file: raw };
|
||||
}
|
||||
|
||||
const file = raw.slice(0, match.index).trim();
|
||||
if (!file) return null;
|
||||
|
||||
const line = Number.parseInt(match[1], 10);
|
||||
const column = match[2] ? Number.parseInt(match[2], 10) : undefined;
|
||||
|
||||
return {
|
||||
file,
|
||||
line: Number.isFinite(line) && line > 0 ? line : undefined,
|
||||
column: Number.isFinite(column) && column > 0 ? column : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Walk up DOM tree to find data-v-inspector attribute.
|
||||
* This attribute is injected by @vitejs/plugin-vue-inspector.
|
||||
*
|
||||
* @param {Element} element - Starting DOM element
|
||||
* @param {number} [maxDepth=15] - Maximum depth to traverse
|
||||
* @returns {{ file: string, line?: number, column?: number } | null}
|
||||
*/
|
||||
findInspectorLocation(element, maxDepth = 15) {
|
||||
try {
|
||||
let node = element;
|
||||
for (let depth = 0; depth < maxDepth && node; depth++) {
|
||||
if (typeof node.getAttribute === 'function') {
|
||||
const attr = node.getAttribute('data-v-inspector');
|
||||
if (attr) {
|
||||
const parsed = this.parseVInspector(attr);
|
||||
if (parsed?.file) return parsed;
|
||||
}
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort extraction
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Vue component debug source.
|
||||
* Priority: data-v-inspector (has line/column) > type.__file (file only)
|
||||
*
|
||||
* @param {object} instance - Vue component instance
|
||||
* @param {Element} targetElement - DOM element for inspector lookup
|
||||
* @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}
|
||||
*/
|
||||
getDebugSource(instance, targetElement) {
|
||||
try {
|
||||
// Priority 1: data-v-inspector attribute (has precise line/column)
|
||||
const inspector = this.findInspectorLocation(targetElement);
|
||||
if (inspector?.file) {
|
||||
return {
|
||||
file: inspector.file,
|
||||
line: inspector.line,
|
||||
column: inspector.column,
|
||||
componentName: this.getComponentName(instance),
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: type.__file (file only, no line/column)
|
||||
const typeFile = instance?.type?.__file;
|
||||
if (typeof typeFile === 'string') {
|
||||
const file = typeFile.trim();
|
||||
if (file) {
|
||||
return {
|
||||
file,
|
||||
componentName: this.getComponentName(instance),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort extraction
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Vue 3 version from instance.
|
||||
* Note: This adapter only supports Vue 3 (via __vueParentComponent).
|
||||
*
|
||||
* @param {object} instance - Vue 3 component instance
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
getVersion(instance) {
|
||||
try {
|
||||
// Vue 3: Get version from app context
|
||||
const appVersion = instance?.appContext?.app?.version;
|
||||
if (typeof appVersion === 'string' && appVersion.trim()) {
|
||||
return appVersion.trim();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get writable props container (vnode.props or instance.props)
|
||||
* @deprecated Use getWriteContainers for better targeting
|
||||
@@ -1385,7 +1601,9 @@
|
||||
if (init?.hookStatus) data.hookStatus = init.hookStatus;
|
||||
if (typeof init?.needsRefresh === 'boolean') data.needsRefresh = init.needsRefresh;
|
||||
if (init?.framework) data.framework = init.framework;
|
||||
if (init?.frameworkVersion) data.frameworkVersion = init.frameworkVersion;
|
||||
if (init?.componentName) data.componentName = init.componentName;
|
||||
if (init?.debugSource) data.debugSource = init.debugSource;
|
||||
if (init?.props) data.props = init.props;
|
||||
if (init?.capabilities) data.capabilities = init.capabilities;
|
||||
if (init?.meta) data.meta = init.meta;
|
||||
@@ -1428,10 +1646,13 @@
|
||||
const fw = target ? FrameworkDetector.detect(target) : { framework: 'unknown', data: null };
|
||||
|
||||
let componentName;
|
||||
let debugSource;
|
||||
let canRead = false;
|
||||
let canWrite = false;
|
||||
let needsRefresh = false;
|
||||
|
||||
let frameworkVersion;
|
||||
|
||||
if (fw.framework === 'react') {
|
||||
const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);
|
||||
const componentFiber = fiberInfo.fiber
|
||||
@@ -1439,12 +1660,19 @@
|
||||
: null;
|
||||
|
||||
componentName = componentFiber ? ReactAdapter.getComponentName(componentFiber) : undefined;
|
||||
// Extract debug source from component fiber or raw fiber
|
||||
const sourceFiber = componentFiber || fiberInfo.fiber;
|
||||
debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;
|
||||
// Pass specific renderer to prioritize its version in multi-renderer scenarios
|
||||
frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);
|
||||
canRead = Boolean(componentFiber);
|
||||
canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);
|
||||
needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;
|
||||
} else if (fw.framework === 'vue') {
|
||||
const instance = fw.data;
|
||||
componentName = VueAdapter.getComponentName(instance);
|
||||
debugSource = instance ? VueAdapter.getDebugSource(instance, target) : undefined;
|
||||
frameworkVersion = VueAdapter.getVersion(instance);
|
||||
canRead = Boolean(instance);
|
||||
canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);
|
||||
needsRefresh = false;
|
||||
@@ -1453,7 +1681,9 @@
|
||||
const data = buildResponseData({
|
||||
hookStatus,
|
||||
framework: fw.framework,
|
||||
frameworkVersion,
|
||||
componentName,
|
||||
debugSource,
|
||||
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
|
||||
needsRefresh,
|
||||
});
|
||||
@@ -1494,10 +1724,18 @@
|
||||
? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)
|
||||
: null;
|
||||
|
||||
// Extract debug source even if component fiber not found
|
||||
const sourceFiber = componentFiber || fiberInfo.fiber;
|
||||
const debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;
|
||||
// Pass specific renderer to prioritize its version in multi-renderer scenarios
|
||||
const frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);
|
||||
|
||||
if (!componentFiber) {
|
||||
const data = buildResponseData({
|
||||
hookStatus,
|
||||
framework: 'react',
|
||||
frameworkVersion,
|
||||
debugSource,
|
||||
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
|
||||
needsRefresh: false,
|
||||
});
|
||||
@@ -1519,7 +1757,9 @@
|
||||
const data = buildResponseData({
|
||||
hookStatus,
|
||||
framework: 'react',
|
||||
frameworkVersion,
|
||||
componentName,
|
||||
debugSource,
|
||||
props: serialized,
|
||||
capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),
|
||||
needsRefresh,
|
||||
@@ -1530,10 +1770,13 @@
|
||||
|
||||
if (fw.framework === 'vue') {
|
||||
const instance = fw.data;
|
||||
const frameworkVersion = VueAdapter.getVersion(instance);
|
||||
|
||||
if (!instance) {
|
||||
const data = buildResponseData({
|
||||
hookStatus,
|
||||
framework: 'vue',
|
||||
frameworkVersion,
|
||||
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
|
||||
needsRefresh: false,
|
||||
});
|
||||
@@ -1546,6 +1789,7 @@
|
||||
}
|
||||
|
||||
const componentName = VueAdapter.getComponentName(instance);
|
||||
const debugSource = VueAdapter.getDebugSource(instance, target);
|
||||
|
||||
// Read both props and attrs
|
||||
let rootProps = null;
|
||||
@@ -1592,7 +1836,9 @@
|
||||
const data = buildResponseData({
|
||||
hookStatus,
|
||||
framework: 'vue',
|
||||
frameworkVersion,
|
||||
componentName,
|
||||
debugSource,
|
||||
props: serialized,
|
||||
capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),
|
||||
needsRefresh: false,
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
/**
|
||||
* Element Picker Controller
|
||||
*
|
||||
* Creates and manages the Element Picker Panel UI, which displays:
|
||||
* - List of element requests from the AI
|
||||
* - Current selection status for each request
|
||||
* - Countdown timer
|
||||
* - Cancel/Confirm actions
|
||||
*/
|
||||
|
||||
import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
|
||||
import {
|
||||
mountQuickPanelShadowHost,
|
||||
type QuickPanelShadowHostElements,
|
||||
type QuickPanelShadowHostManager,
|
||||
} from '@/shared/quick-panel/ui';
|
||||
import type { PickedElement } from 'chrome-mcp-shared';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface ElementPickerControllerOptions {
|
||||
/** Custom host element ID */
|
||||
hostId?: string;
|
||||
/** Custom z-index */
|
||||
zIndex?: number;
|
||||
/** Called when user clicks Cancel */
|
||||
onCancel?: () => void;
|
||||
/** Called when user clicks Confirm */
|
||||
onConfirm?: () => void;
|
||||
/** Called when user switches to a different request */
|
||||
onSetActiveRequest?: (requestId: string) => void;
|
||||
/** Called when user clears a selection */
|
||||
onClearSelection?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export interface ElementPickerController {
|
||||
/** Show the panel with initial state */
|
||||
show: (state: ElementPickerUiState) => void;
|
||||
/** Update the panel state */
|
||||
update: (patch: ElementPickerUiPatch) => void;
|
||||
/** Hide and clean up the panel */
|
||||
hide: () => void;
|
||||
/** Check if the panel is currently visible */
|
||||
isVisible: () => boolean;
|
||||
/** Dispose and clean up all resources */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface ElementPickerUiRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ElementPickerUiState {
|
||||
sessionId: string;
|
||||
requests: ElementPickerUiRequest[];
|
||||
activeRequestId: string | null;
|
||||
selections: Record<string, PickedElement | null>;
|
||||
deadlineTs: number;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export type ElementPickerUiPatch = Partial<Omit<ElementPickerUiState, 'sessionId'>> & {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const DEFAULT_HOST_ID = '__mcp_element_picker_host__';
|
||||
const DEFAULT_Z_INDEX = 2147483647;
|
||||
|
||||
// ============================================================
|
||||
// Styles (Quick Panel compatible)
|
||||
// ============================================================
|
||||
|
||||
const ELEMENT_PICKER_STYLES = /* css */ `
|
||||
/* Overlay positioning - bottom-right corner */
|
||||
.ep-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Panel sizing */
|
||||
.ep-panel {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: min(600px, calc(100vh - 32px));
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Countdown badge */
|
||||
.ep-countdown {
|
||||
font-family: var(--ac-font-code);
|
||||
font-size: 12px;
|
||||
color: var(--ac-text-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--qp-glass-divider);
|
||||
background: color-mix(in srgb, var(--qp-glass-input-bg) 80%, transparent);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ep-countdown--warning {
|
||||
color: var(--ac-warning);
|
||||
border-color: color-mix(in srgb, var(--ac-warning) 40%, var(--qp-glass-divider));
|
||||
}
|
||||
|
||||
.ep-countdown--danger {
|
||||
color: var(--ac-danger);
|
||||
border-color: color-mix(in srgb, var(--ac-danger) 40%, var(--qp-glass-divider));
|
||||
animation: ep-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ep-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Hint text */
|
||||
.ep-hint {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 12px;
|
||||
color: var(--ac-text-muted);
|
||||
}
|
||||
|
||||
/* Error banner */
|
||||
.ep-error {
|
||||
margin: 0 0 10px 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--ac-radius-card);
|
||||
border: 1px solid color-mix(in srgb, var(--ac-danger) 55%, var(--ac-border));
|
||||
background: color-mix(in srgb, var(--ac-danger) 10%, transparent);
|
||||
color: color-mix(in srgb, var(--ac-danger) 85%, var(--ac-text));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Request list */
|
||||
.ep-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Request item card */
|
||||
.ep-item {
|
||||
border-radius: var(--ac-radius-card);
|
||||
border: var(--ac-border-width) solid var(--ac-border);
|
||||
box-shadow: var(--ac-shadow-card);
|
||||
background: var(--ac-surface);
|
||||
padding: 10px 12px;
|
||||
transition: border-color var(--ac-motion-fast), box-shadow var(--ac-motion-fast);
|
||||
}
|
||||
|
||||
.ep-item--active {
|
||||
border-color: color-mix(in srgb, var(--ac-accent) 55%, var(--ac-border));
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--ac-accent-subtle) 65%, transparent),
|
||||
var(--ac-shadow-card);
|
||||
}
|
||||
|
||||
/* Item header */
|
||||
.ep-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ep-item-title {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--ac-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
.ep-badge {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--qp-glass-divider);
|
||||
color: var(--ac-text-muted);
|
||||
background: color-mix(in srgb, var(--ac-surface-muted) 65%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ep-badge--selected {
|
||||
border-color: color-mix(in srgb, var(--ac-success) 55%, var(--qp-glass-divider));
|
||||
color: color-mix(in srgb, var(--ac-success) 85%, var(--ac-text));
|
||||
background: color-mix(in srgb, var(--ac-success) 10%, transparent);
|
||||
}
|
||||
|
||||
.ep-badge--picking {
|
||||
border-color: color-mix(in srgb, var(--ac-accent) 55%, var(--qp-glass-divider));
|
||||
color: var(--ac-accent);
|
||||
background: color-mix(in srgb, var(--ac-accent) 10%, transparent);
|
||||
animation: ep-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
.ep-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ac-text-muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Picked element info */
|
||||
.ep-picked {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ac-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-radius: var(--ac-radius-inner);
|
||||
background: var(--ac-surface-muted);
|
||||
}
|
||||
|
||||
.ep-picked-text {
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ep-picked-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ep-picked code {
|
||||
font-family: var(--ac-font-code);
|
||||
font-size: 10px;
|
||||
color: var(--ac-text-muted);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Action buttons row */
|
||||
.ep-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.ep-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ep-footer-left {
|
||||
font-size: 11px;
|
||||
color: var(--ac-text-muted);
|
||||
}
|
||||
|
||||
.ep-footer-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
|
||||
function formatCountdown(deadlineTs: number): {
|
||||
text: string;
|
||||
level: 'normal' | 'warning' | 'danger';
|
||||
} {
|
||||
const remainingMs = Math.max(0, deadlineTs - Date.now());
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const text = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
// Warning at 1 minute, danger at 30 seconds
|
||||
let level: 'normal' | 'warning' | 'danger' = 'normal';
|
||||
if (totalSeconds <= 30) {
|
||||
level = 'danger';
|
||||
} else if (totalSeconds <= 60) {
|
||||
level = 'warning';
|
||||
}
|
||||
|
||||
return { text, level };
|
||||
}
|
||||
|
||||
function truncate(text: string, max = 80): string {
|
||||
const t = String(text || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (t.length <= max) return t;
|
||||
return `${t.slice(0, Math.max(0, max - 1))}...`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Controller Factory
|
||||
// ============================================================
|
||||
|
||||
export function createElementPickerController(
|
||||
options: ElementPickerControllerOptions = {},
|
||||
): ElementPickerController {
|
||||
let disposed = false;
|
||||
|
||||
let shadowHost: QuickPanelShadowHostManager | null = null;
|
||||
let elements: QuickPanelShadowHostElements | null = null;
|
||||
let disposer: Disposer | null = null;
|
||||
let state: ElementPickerUiState | null = null;
|
||||
|
||||
// DOM refs
|
||||
let overlayEl: HTMLDivElement | null = null;
|
||||
let panelEl: HTMLDivElement | null = null;
|
||||
let countdownEl: HTMLSpanElement | null = null;
|
||||
let errorEl: HTMLDivElement | null = null;
|
||||
let listEl: HTMLDivElement | null = null;
|
||||
let confirmBtn: HTMLButtonElement | null = null;
|
||||
let cancelBtn: HTMLButtonElement | null = null;
|
||||
let progressEl: HTMLSpanElement | null = null;
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Cached item elements for incremental updates
|
||||
interface ItemElements {
|
||||
container: HTMLDivElement;
|
||||
badge: HTMLDivElement;
|
||||
pickedContainer: HTMLDivElement | null;
|
||||
pickBtn: HTMLButtonElement;
|
||||
clearBtn: HTMLButtonElement;
|
||||
}
|
||||
const itemElementsMap = new Map<string, ItemElements>();
|
||||
|
||||
const hostId = options.hostId ?? DEFAULT_HOST_ID;
|
||||
const zIndex = options.zIndex ?? DEFAULT_Z_INDEX;
|
||||
|
||||
function ensureMounted(): void {
|
||||
if (shadowHost && elements) return;
|
||||
|
||||
shadowHost = mountQuickPanelShadowHost({ hostId, zIndex });
|
||||
elements = shadowHost.getElements();
|
||||
if (!elements) throw new Error('Failed to mount Element Picker shadow host');
|
||||
|
||||
const localDisposer = new Disposer();
|
||||
disposer = localDisposer;
|
||||
|
||||
// Inject local styles
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = ELEMENT_PICKER_STYLES;
|
||||
elements.shadowRoot.append(styleEl);
|
||||
localDisposer.add(() => styleEl.remove());
|
||||
|
||||
// Build UI structure
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.className = 'ep-overlay';
|
||||
|
||||
panelEl = document.createElement('div');
|
||||
panelEl.className = 'qp-panel qp-liquid-shimmer ep-panel';
|
||||
panelEl.setAttribute('role', 'dialog');
|
||||
panelEl.setAttribute('aria-modal', 'false');
|
||||
panelEl.setAttribute('aria-label', 'Element Picker');
|
||||
|
||||
// Header
|
||||
const headerEl = document.createElement('div');
|
||||
headerEl.className = 'qp-header';
|
||||
|
||||
const headerLeft = document.createElement('div');
|
||||
headerLeft.className = 'qp-header-left';
|
||||
|
||||
const brand = document.createElement('div');
|
||||
brand.className = 'qp-brand';
|
||||
brand.textContent = '\u{1F446}'; // Pointing up emoji
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'qp-title';
|
||||
|
||||
const titleName = document.createElement('div');
|
||||
titleName.className = 'qp-title-name';
|
||||
titleName.textContent = 'Element Picker';
|
||||
|
||||
const titleSub = document.createElement('div');
|
||||
titleSub.className = 'qp-title-sub';
|
||||
titleSub.textContent = 'Click on the requested elements';
|
||||
|
||||
title.append(titleName, titleSub);
|
||||
headerLeft.append(brand, title);
|
||||
|
||||
const headerRight = document.createElement('div');
|
||||
headerRight.className = 'qp-header-right';
|
||||
|
||||
countdownEl = document.createElement('span');
|
||||
countdownEl.className = 'ep-countdown';
|
||||
countdownEl.textContent = '03:00';
|
||||
|
||||
headerRight.append(countdownEl);
|
||||
headerEl.append(headerLeft, headerRight);
|
||||
|
||||
// Content
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'qp-content ac-scroll';
|
||||
|
||||
const hintEl = document.createElement('div');
|
||||
hintEl.className = 'ep-hint';
|
||||
hintEl.textContent = 'Click on each element the AI needs. Press Esc to cancel.';
|
||||
|
||||
errorEl = document.createElement('div');
|
||||
errorEl.className = 'ep-error';
|
||||
errorEl.hidden = true;
|
||||
|
||||
listEl = document.createElement('div');
|
||||
listEl.className = 'ep-list';
|
||||
|
||||
contentEl.append(hintEl, errorEl, listEl);
|
||||
|
||||
// Footer
|
||||
const footerEl = document.createElement('div');
|
||||
footerEl.className = 'qp-composer';
|
||||
|
||||
const footerInner = document.createElement('div');
|
||||
footerInner.className = 'ep-footer';
|
||||
|
||||
const footerLeft = document.createElement('div');
|
||||
footerLeft.className = 'ep-footer-left';
|
||||
|
||||
progressEl = document.createElement('span');
|
||||
progressEl.textContent = '0/0 selected';
|
||||
footerLeft.append(progressEl);
|
||||
|
||||
const footerRight = document.createElement('div');
|
||||
footerRight.className = 'ep-footer-right';
|
||||
|
||||
cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'qp-btn ac-btn ac-focus-ring';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
|
||||
confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className = 'qp-btn ac-btn ac-focus-ring qp-btn--primary';
|
||||
confirmBtn.textContent = 'Confirm';
|
||||
|
||||
footerRight.append(cancelBtn, confirmBtn);
|
||||
footerInner.append(footerLeft, footerRight);
|
||||
footerEl.append(footerInner);
|
||||
|
||||
panelEl.append(headerEl, contentEl, footerEl);
|
||||
overlayEl.append(panelEl);
|
||||
elements.root.append(overlayEl);
|
||||
localDisposer.add(() => overlayEl?.remove());
|
||||
|
||||
// Event listeners
|
||||
localDisposer.listen(cancelBtn, 'click', () => options.onCancel?.());
|
||||
localDisposer.listen(confirmBtn, 'click', () => options.onConfirm?.());
|
||||
|
||||
// Esc key to cancel - use capture phase on shadowRoot to intercept before Quick Panel stops propagation
|
||||
const handleEscKey = (e: Event) => {
|
||||
if (e instanceof KeyboardEvent && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
options.onCancel?.();
|
||||
}
|
||||
};
|
||||
elements.shadowRoot.addEventListener('keydown', handleEscKey, { capture: true });
|
||||
localDisposer.add(() =>
|
||||
elements?.shadowRoot.removeEventListener('keydown', handleEscKey, { capture: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timerId !== null) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render only the countdown timer (called frequently by interval).
|
||||
*/
|
||||
function renderCountdown(): void {
|
||||
if (!state || !countdownEl) return;
|
||||
const countdown = formatCountdown(state.deadlineTs);
|
||||
countdownEl.textContent = countdown.text;
|
||||
countdownEl.className = `ep-countdown${countdown.level !== 'normal' ? ` ep-countdown--${countdown.level}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a picked element info container.
|
||||
*/
|
||||
function createPickedInfoEl(picked: PickedElement): HTMLDivElement {
|
||||
const pickedEl = document.createElement('div');
|
||||
pickedEl.className = 'ep-picked';
|
||||
|
||||
if (picked.text) {
|
||||
const textEl = document.createElement('div');
|
||||
textEl.className = 'ep-picked-text';
|
||||
textEl.textContent = `"${truncate(picked.text, 80)}"`;
|
||||
pickedEl.append(textEl);
|
||||
}
|
||||
|
||||
const metaEl = document.createElement('div');
|
||||
metaEl.className = 'ep-picked-meta';
|
||||
|
||||
const tagCode = document.createElement('code');
|
||||
tagCode.textContent = picked.tagName || 'element';
|
||||
metaEl.append(tagCode);
|
||||
|
||||
const refCode = document.createElement('code');
|
||||
refCode.textContent = `ref=${picked.ref}`;
|
||||
metaEl.append(refCode);
|
||||
|
||||
if (picked.frameId > 0) {
|
||||
const frameCode = document.createElement('code');
|
||||
frameCode.textContent = `frame=${picked.frameId}`;
|
||||
metaEl.append(frameCode);
|
||||
}
|
||||
|
||||
pickedEl.append(metaEl);
|
||||
|
||||
const selectorEl = document.createElement('div');
|
||||
const selectorCode = document.createElement('code');
|
||||
selectorCode.textContent = truncate(picked.selector || '', 100);
|
||||
selectorEl.append(selectorCode);
|
||||
pickedEl.append(selectorEl);
|
||||
|
||||
return pickedEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single request item element.
|
||||
*/
|
||||
function createItemEl(req: ElementPickerUiRequest): ItemElements {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ep-item';
|
||||
item.dataset.requestId = req.id;
|
||||
|
||||
// Header row
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ep-item-header';
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'ep-item-title';
|
||||
titleEl.textContent = req.name;
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'ep-badge';
|
||||
badge.textContent = 'Pending';
|
||||
|
||||
header.append(titleEl, badge);
|
||||
item.append(header);
|
||||
|
||||
// Description (static, only added once)
|
||||
if (req.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'ep-desc';
|
||||
desc.textContent = req.description;
|
||||
item.append(desc);
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ep-actions';
|
||||
|
||||
const pickBtn = document.createElement('button');
|
||||
pickBtn.type = 'button';
|
||||
pickBtn.className = 'qp-btn ac-btn ac-focus-ring';
|
||||
pickBtn.textContent = 'Pick';
|
||||
pickBtn.addEventListener('click', () => options.onSetActiveRequest?.(req.id));
|
||||
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.type = 'button';
|
||||
clearBtn.className = 'qp-btn ac-btn ac-focus-ring';
|
||||
clearBtn.textContent = 'Clear';
|
||||
clearBtn.disabled = true;
|
||||
clearBtn.addEventListener('click', () => options.onClearSelection?.(req.id));
|
||||
|
||||
actions.append(pickBtn, clearBtn);
|
||||
item.append(actions);
|
||||
|
||||
return { container: item, badge, pickedContainer: null, pickBtn, clearBtn };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single item's display state.
|
||||
*/
|
||||
function updateItemEl(
|
||||
itemEls: ItemElements,
|
||||
req: ElementPickerUiRequest,
|
||||
picked: PickedElement | null,
|
||||
isActive: boolean,
|
||||
): void {
|
||||
const { container, badge, pickBtn, clearBtn } = itemEls;
|
||||
|
||||
// Update active state
|
||||
container.classList.toggle('ep-item--active', isActive);
|
||||
|
||||
// Update badge
|
||||
if (picked) {
|
||||
badge.className = 'ep-badge ep-badge--selected';
|
||||
badge.textContent = 'Selected';
|
||||
} else if (isActive) {
|
||||
badge.className = 'ep-badge ep-badge--picking';
|
||||
badge.textContent = 'Picking...';
|
||||
} else {
|
||||
badge.className = 'ep-badge';
|
||||
badge.textContent = 'Pending';
|
||||
}
|
||||
|
||||
// Update pick button
|
||||
pickBtn.textContent = isActive ? 'Picking...' : 'Pick';
|
||||
pickBtn.disabled = isActive;
|
||||
|
||||
// Update clear button
|
||||
clearBtn.disabled = !picked;
|
||||
|
||||
// Handle picked info container
|
||||
const actionsEl = container.querySelector('.ep-actions');
|
||||
if (picked) {
|
||||
if (!itemEls.pickedContainer) {
|
||||
// Create and insert picked info before actions
|
||||
const pickedEl = createPickedInfoEl(picked);
|
||||
actionsEl?.parentNode?.insertBefore(pickedEl, actionsEl);
|
||||
itemEls.pickedContainer = pickedEl;
|
||||
} else {
|
||||
// Update existing picked info
|
||||
const newPickedEl = createPickedInfoEl(picked);
|
||||
itemEls.pickedContainer.replaceWith(newPickedEl);
|
||||
itemEls.pickedContainer = newPickedEl;
|
||||
}
|
||||
} else if (itemEls.pickedContainer) {
|
||||
// Remove picked info
|
||||
itemEls.pickedContainer.remove();
|
||||
itemEls.pickedContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list initially or rebuild if requests changed.
|
||||
*/
|
||||
function buildList(): void {
|
||||
if (!state || !listEl) return;
|
||||
|
||||
// Clear existing items and cache
|
||||
listEl.innerHTML = '';
|
||||
itemElementsMap.clear();
|
||||
|
||||
for (const req of state.requests) {
|
||||
const itemEls = createItemEl(req);
|
||||
itemElementsMap.set(req.id, itemEls);
|
||||
listEl.append(itemEls.container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full render - updates all dynamic parts.
|
||||
*/
|
||||
function render(): void {
|
||||
if (!state || !listEl || !countdownEl || !confirmBtn || !errorEl || !progressEl) return;
|
||||
|
||||
// Countdown (always update)
|
||||
renderCountdown();
|
||||
|
||||
// Error banner
|
||||
const err = state.errorMessage ? state.errorMessage.trim() : '';
|
||||
if (err) {
|
||||
errorEl.hidden = false;
|
||||
errorEl.textContent = err;
|
||||
} else {
|
||||
errorEl.hidden = true;
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
|
||||
// Rebuild list if requests changed (rare case)
|
||||
const needsRebuild =
|
||||
itemElementsMap.size !== state.requests.length ||
|
||||
state.requests.some((r) => !itemElementsMap.has(r.id));
|
||||
if (needsRebuild) {
|
||||
buildList();
|
||||
}
|
||||
|
||||
// Count selected and update items
|
||||
let selectedCount = 0;
|
||||
for (const req of state.requests) {
|
||||
const picked = state.selections[req.id] || null;
|
||||
const isActive = state.activeRequestId === req.id;
|
||||
if (picked) selectedCount++;
|
||||
|
||||
const itemEls = itemElementsMap.get(req.id);
|
||||
if (itemEls) {
|
||||
updateItemEl(itemEls, req, picked, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
// Progress text
|
||||
progressEl.textContent = `${selectedCount}/${state.requests.length} selected`;
|
||||
|
||||
// Confirm button state
|
||||
const allSelected = selectedCount === state.requests.length;
|
||||
confirmBtn.disabled = !allSelected;
|
||||
confirmBtn.textContent = allSelected
|
||||
? 'Confirm'
|
||||
: `Confirm (${selectedCount}/${state.requests.length})`;
|
||||
}
|
||||
|
||||
function show(next: ElementPickerUiState): void {
|
||||
if (disposed) return;
|
||||
ensureMounted();
|
||||
|
||||
state = next;
|
||||
render();
|
||||
|
||||
clearTimer();
|
||||
// Timer only updates countdown, not the full list
|
||||
timerId = setInterval(() => {
|
||||
if (disposed || !state) return;
|
||||
renderCountdown();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function update(patch: ElementPickerUiPatch): void {
|
||||
if (disposed) return;
|
||||
if (!state || state.sessionId !== patch.sessionId) {
|
||||
// If we don't have matching state yet, ignore update
|
||||
return;
|
||||
}
|
||||
|
||||
state = {
|
||||
...state,
|
||||
...patch,
|
||||
sessionId: state.sessionId, // Keep stable
|
||||
requests: patch.requests ?? state.requests,
|
||||
activeRequestId: patch.activeRequestId ?? state.activeRequestId,
|
||||
selections: patch.selections ?? state.selections,
|
||||
deadlineTs: patch.deadlineTs ?? state.deadlineTs,
|
||||
errorMessage: patch.errorMessage ?? state.errorMessage,
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
clearTimer();
|
||||
state = null;
|
||||
itemElementsMap.clear();
|
||||
|
||||
try {
|
||||
disposer?.dispose();
|
||||
} finally {
|
||||
disposer = null;
|
||||
}
|
||||
|
||||
overlayEl = null;
|
||||
panelEl = null;
|
||||
countdownEl = null;
|
||||
errorEl = null;
|
||||
listEl = null;
|
||||
confirmBtn = null;
|
||||
cancelBtn = null;
|
||||
progressEl = null;
|
||||
|
||||
try {
|
||||
shadowHost?.dispose();
|
||||
} finally {
|
||||
shadowHost = null;
|
||||
elements = null;
|
||||
}
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
hide();
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
update,
|
||||
hide,
|
||||
isVisible: () => !!shadowHost && !!elements,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Element Picker (UI)
|
||||
*
|
||||
* A Quick Panel-styled floating panel used by chrome_request_element_selection.
|
||||
*/
|
||||
|
||||
export { createElementPickerController } from './controller';
|
||||
export type {
|
||||
ElementPickerController,
|
||||
ElementPickerControllerOptions,
|
||||
ElementPickerUiState,
|
||||
ElementPickerUiRequest,
|
||||
ElementPickerUiPatch,
|
||||
} from './controller';
|
||||
@@ -53,6 +53,8 @@ export {
|
||||
type QuickPanelMessageRendererOptions,
|
||||
} from './message-renderer';
|
||||
|
||||
export { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';
|
||||
|
||||
export {
|
||||
mountQuickPanelAiChatPanel,
|
||||
type QuickPanelAiChatPanelManager,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Quick Panel Markdown Renderer
|
||||
*
|
||||
* Simple markdown renderer for Quick Panel.
|
||||
* Currently uses plain text rendering - markdown support to be added later
|
||||
* when proper Vue/content-script integration is resolved.
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface MarkdownRendererInstance {
|
||||
/** Update the markdown content */
|
||||
setContent: (content: string, isStreaming?: boolean) => void;
|
||||
/** Get current content */
|
||||
getContent: () => string;
|
||||
/** Dispose resources */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Factory
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Create a markdown renderer instance that mounts to a container element.
|
||||
* Currently renders as plain text - markdown support pending.
|
||||
*
|
||||
* @param container - The DOM element to render content into
|
||||
* @returns Markdown renderer instance with setContent and dispose methods
|
||||
*/
|
||||
export function createMarkdownRenderer(container: HTMLElement): MarkdownRendererInstance {
|
||||
let currentContent = '';
|
||||
|
||||
// Create a wrapper div for content
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'qp-markdown-content';
|
||||
container.appendChild(contentEl);
|
||||
|
||||
return {
|
||||
setContent(newContent: string, _streaming = false) {
|
||||
currentContent = newContent;
|
||||
// For now, render as plain text with basic whitespace preservation
|
||||
contentEl.textContent = newContent;
|
||||
},
|
||||
|
||||
getContent() {
|
||||
return currentContent;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
try {
|
||||
contentEl.remove();
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,16 +3,15 @@
|
||||
*
|
||||
* Renders AgentChat-compatible messages for the Quick Panel AI Chat UI.
|
||||
* Features:
|
||||
* - XSS-safe rendering (textContent only, no innerHTML)
|
||||
* - Markdown rendering for assistant messages via markstream-vue
|
||||
* - XSS-safe rendering for user messages (textContent only)
|
||||
* - Streaming message support (in-place updates via message id)
|
||||
* - Auto-scroll with proximity detection
|
||||
* - Memory-efficient DOM recycling
|
||||
*
|
||||
* Note: This renderer is framework-agnostic and directly manipulates DOM
|
||||
* for optimal performance in content script context.
|
||||
*/
|
||||
|
||||
import type { AgentMessage, AgentRole } from 'chrome-mcp-shared';
|
||||
import { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -61,6 +60,8 @@ interface MessageEntry {
|
||||
timeEl: HTMLSpanElement;
|
||||
metaRightEl: HTMLSpanElement;
|
||||
requestIdEl: HTMLElement;
|
||||
/** Markdown renderer for assistant messages */
|
||||
markdownRenderer: MarkdownRendererInstance | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -187,6 +188,12 @@ function createMessageEntry(messageId: string, message: AgentMessage): MessageEn
|
||||
bubble.append(textEl, metaEl);
|
||||
wrapper.append(bubble);
|
||||
|
||||
// Create markdown renderer for assistant messages
|
||||
let markdownRenderer: MarkdownRendererInstance | null = null;
|
||||
if (message.role === 'assistant') {
|
||||
markdownRenderer = createMarkdownRenderer(textEl);
|
||||
}
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
bubble,
|
||||
@@ -197,6 +204,7 @@ function createMessageEntry(messageId: string, message: AgentMessage): MessageEn
|
||||
timeEl: metaLeft.time,
|
||||
metaRightEl: metaRight.container,
|
||||
requestIdEl: metaRight.requestId,
|
||||
markdownRenderer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,10 +229,17 @@ function updateMessageEntry(entry: MessageEntry, messageId: string, message: Age
|
||||
entry.bubble.className = bubbleClass;
|
||||
}
|
||||
|
||||
// Update text content (XSS-safe)
|
||||
// Update content based on message role
|
||||
const textContent = message.content ?? '';
|
||||
if (entry.textEl.textContent !== textContent) {
|
||||
entry.textEl.textContent = textContent;
|
||||
|
||||
if (message.role === 'assistant' && entry.markdownRenderer) {
|
||||
// Use markdown renderer for assistant messages
|
||||
entry.markdownRenderer.setContent(textContent, isStreamingMessage(message));
|
||||
} else {
|
||||
// Use plain text for user messages (XSS-safe)
|
||||
if (entry.textEl.textContent !== textContent) {
|
||||
entry.textEl.textContent = textContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Update time display
|
||||
@@ -343,6 +358,11 @@ export function createQuickPanelMessageRenderer(
|
||||
|
||||
entries.delete(id);
|
||||
|
||||
// Dispose markdown renderer if exists
|
||||
if (entry.markdownRenderer) {
|
||||
entry.markdownRenderer.dispose();
|
||||
}
|
||||
|
||||
try {
|
||||
entry.wrapper.remove();
|
||||
} catch {
|
||||
@@ -354,6 +374,13 @@ export function createQuickPanelMessageRenderer(
|
||||
function clear(): void {
|
||||
if (disposed) return;
|
||||
|
||||
// Dispose all markdown renderers
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.markdownRenderer) {
|
||||
entry.markdownRenderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
entries.clear();
|
||||
container.textContent = '';
|
||||
}
|
||||
@@ -361,6 +388,13 @@ export function createQuickPanelMessageRenderer(
|
||||
function setMessages(messages: AgentMessage[]): void {
|
||||
if (disposed) return;
|
||||
|
||||
// Dispose all existing markdown renderers
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.markdownRenderer) {
|
||||
entry.markdownRenderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing state
|
||||
entries.clear();
|
||||
container.textContent = '';
|
||||
@@ -388,6 +422,13 @@ export function createQuickPanelMessageRenderer(
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
// Dispose all markdown renderers
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.markdownRenderer) {
|
||||
entry.markdownRenderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
entries.clear();
|
||||
container.textContent = '';
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ const THEME_STORAGE_KEY = 'agentTheme';
|
||||
/** Default theme if none is set */
|
||||
const DEFAULT_THEME_ID = 'warm-editorial';
|
||||
|
||||
/** Dark theme ID for dark mode */
|
||||
const DARK_THEME_ID = 'dark-console';
|
||||
|
||||
/** Valid theme IDs (subset supported by Quick Panel) */
|
||||
const VALID_THEME_IDS = new Set([
|
||||
'warm-editorial',
|
||||
@@ -86,6 +89,15 @@ const VALID_THEME_IDS = new Set([
|
||||
'swiss-grid',
|
||||
]);
|
||||
|
||||
/** Light theme IDs that should switch to dark in dark mode */
|
||||
const LIGHT_THEME_IDS = new Set([
|
||||
'warm-editorial',
|
||||
'blueprint-architect',
|
||||
'zen-journal',
|
||||
'neo-pop',
|
||||
'swiss-grid',
|
||||
]);
|
||||
|
||||
/** Events to stop from propagating to the host page */
|
||||
const BLOCKED_EVENT_TYPES = [
|
||||
// Pointer events
|
||||
@@ -142,6 +154,28 @@ function normalizeThemeId(value: unknown): string {
|
||||
return VALID_THEME_IDS.has(trimmed) ? trimmed : DEFAULT_THEME_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if system prefers dark mode.
|
||||
*/
|
||||
function systemPrefersDark(): boolean {
|
||||
try {
|
||||
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective theme ID considering system dark mode preference.
|
||||
* If system is in dark mode and the theme is a light theme, switch to dark-console.
|
||||
*/
|
||||
function getEffectiveThemeId(baseThemeId: string): string {
|
||||
if (systemPrefersDark() && LIGHT_THEME_IDS.has(baseThemeId)) {
|
||||
return DARK_THEME_ID;
|
||||
}
|
||||
return baseThemeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored theme ID from chrome.storage.
|
||||
*/
|
||||
@@ -156,10 +190,12 @@ async function readStoredThemeId(): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a theme ID to the root element.
|
||||
* Apply a theme ID to the root element, considering system dark mode preference.
|
||||
*/
|
||||
function applyThemeId(root: HTMLElement, themeId: string): void {
|
||||
root.dataset.agentTheme = normalizeThemeId(themeId);
|
||||
const normalizedTheme = normalizeThemeId(themeId);
|
||||
const effectiveTheme = getEffectiveThemeId(normalizedTheme);
|
||||
root.dataset.agentTheme = effectiveTheme;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -265,6 +301,15 @@ export function mountQuickPanelShadowHost(
|
||||
applyThemeId(root, themeId);
|
||||
})();
|
||||
|
||||
// System dark mode change listener
|
||||
// Re-apply theme when system color scheme changes
|
||||
let currentStoredThemeId = DEFAULT_THEME_ID;
|
||||
|
||||
// Track the stored theme ID
|
||||
void (async () => {
|
||||
currentStoredThemeId = await readStoredThemeId();
|
||||
})();
|
||||
|
||||
// Theme change listener
|
||||
const handleStorageChange = (
|
||||
changes: Record<string, chrome.storage.StorageChange>,
|
||||
@@ -273,7 +318,9 @@ export function mountQuickPanelShadowHost(
|
||||
if (areaName !== 'local') return;
|
||||
const change = changes[THEME_STORAGE_KEY];
|
||||
if (!change) return;
|
||||
applyThemeId(root, change.newValue);
|
||||
// Update tracked theme ID and apply
|
||||
currentStoredThemeId = normalizeThemeId(change.newValue);
|
||||
applyThemeId(root, currentStoredThemeId);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -283,6 +330,23 @@ export function mountQuickPanelShadowHost(
|
||||
// Best-effort: theme sync is optional
|
||||
}
|
||||
|
||||
try {
|
||||
const darkModeMediaQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (darkModeMediaQuery) {
|
||||
const handleDarkModeChange = (): void => {
|
||||
applyThemeId(root, currentStoredThemeId);
|
||||
};
|
||||
|
||||
// Use addEventListener for modern browsers
|
||||
if (typeof darkModeMediaQuery.addEventListener === 'function') {
|
||||
darkModeMediaQuery.addEventListener('change', handleDarkModeChange);
|
||||
disposer.add(() => darkModeMediaQuery.removeEventListener('change', handleDarkModeChange));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: dark mode detection is optional
|
||||
}
|
||||
|
||||
// Helper to check if a node belongs to this shadow host
|
||||
const isOverlayElement = (node: unknown): boolean => {
|
||||
if (!(node instanceof Node)) return false;
|
||||
|
||||
@@ -176,14 +176,14 @@ export const QUICK_PANEL_STYLES = /* css */ `
|
||||
--ac-hover-bg: rgba(255, 255, 255, 0.06);
|
||||
--ac-hover-bg-subtle: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--ac-accent: #c084fc;
|
||||
--ac-accent-hover: #d8b4fe;
|
||||
--ac-accent-subtle: rgba(192, 132, 252, 0.14);
|
||||
--ac-accent-contrast: #0a0c10;
|
||||
--ac-accent: #d97757;
|
||||
--ac-accent-hover: #e8956f;
|
||||
--ac-accent-subtle: rgba(217, 119, 87, 0.18);
|
||||
--ac-accent-contrast: #ffffff;
|
||||
|
||||
--ac-focus-ring: rgba(192, 132, 252, 0.35);
|
||||
--ac-focus-ring: rgba(217, 119, 87, 0.4);
|
||||
--ac-timeline-node-pulse-shadow:
|
||||
0 0 0 2px rgba(192, 132, 252, 0.35), 0 0 14px rgba(192, 132, 252, 0.25);
|
||||
0 0 0 2px rgba(217, 119, 87, 0.35), 0 0 14px rgba(217, 119, 87, 0.25);
|
||||
|
||||
--ac-scrollbar-thumb: rgba(255, 255, 255, 0.12);
|
||||
--ac-scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);
|
||||
@@ -869,4 +869,128 @@ export const QUICK_PANEL_STYLES = /* css */ `
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Markdown Content Styles (for markstream-vue)
|
||||
* ============================================================ */
|
||||
|
||||
.qp-markdown-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ac-text);
|
||||
}
|
||||
|
||||
.qp-markdown-content pre {
|
||||
background-color: var(--ac-surface-muted);
|
||||
border: var(--ac-border-width) solid var(--ac-border);
|
||||
border-radius: var(--ac-radius-inner);
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.qp-markdown-content code {
|
||||
font-family: var(--ac-font-code);
|
||||
font-size: 0.875em;
|
||||
color: var(--ac-text);
|
||||
}
|
||||
|
||||
.qp-markdown-content :not(pre) > code {
|
||||
background-color: var(--ac-surface-muted);
|
||||
padding: 0.125em 0.25em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.qp-markdown-content p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.qp-markdown-content p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.qp-markdown-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.qp-markdown-content ul,
|
||||
.qp-markdown-content ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.qp-markdown-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.qp-markdown-content h1,
|
||||
.qp-markdown-content h2,
|
||||
.qp-markdown-content h3,
|
||||
.qp-markdown-content h4,
|
||||
.qp-markdown-content h5,
|
||||
.qp-markdown-content h6 {
|
||||
margin: 0.75em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.qp-markdown-content h1 { font-size: 1.5em; }
|
||||
.qp-markdown-content h2 { font-size: 1.3em; }
|
||||
.qp-markdown-content h3 { font-size: 1.15em; }
|
||||
.qp-markdown-content h4 { font-size: 1em; }
|
||||
|
||||
.qp-markdown-content blockquote {
|
||||
border-left: 3px solid var(--ac-border-strong);
|
||||
padding-left: 1em;
|
||||
margin: 0.5em 0;
|
||||
color: var(--ac-text-muted);
|
||||
}
|
||||
|
||||
.qp-markdown-content a {
|
||||
color: var(--ac-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.qp-markdown-content a:hover {
|
||||
color: var(--ac-link-hover);
|
||||
}
|
||||
|
||||
.qp-markdown-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.qp-markdown-content th,
|
||||
.qp-markdown-content td {
|
||||
border: var(--ac-border-width) solid var(--ac-border);
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.qp-markdown-content th {
|
||||
background-color: var(--ac-surface-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qp-markdown-content hr {
|
||||
border: none;
|
||||
border-top: var(--ac-border-width) solid var(--ac-border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.qp-markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--ac-radius-inner);
|
||||
}
|
||||
|
||||
.qp-markdown-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qp-markdown-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview Interval Trigger Handler Tests
|
||||
* @description 测试 interval 触发器的安装、卸载和触发行为
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { TriggerId, FlowId } from '@/entrypoints/background/record-replay-v3/domain/ids';
|
||||
import type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';
|
||||
import type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';
|
||||
import { createIntervalTriggerHandler } from '@/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger';
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
function createMockLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockFireCallback(): TriggerFireCallback & { calls: Array<{ triggerId: string }> } {
|
||||
const calls: Array<{ triggerId: string }> = [];
|
||||
return {
|
||||
calls,
|
||||
onFire: vi.fn(async (triggerId) => {
|
||||
calls.push({ triggerId });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createIntervalTriggerSpec(
|
||||
overrides: Partial<TriggerSpecByKind<'interval'>> = {},
|
||||
): TriggerSpecByKind<'interval'> {
|
||||
return {
|
||||
id: 'interval-trigger-1' as TriggerId,
|
||||
kind: 'interval',
|
||||
flowId: 'flow-1' as FlowId,
|
||||
enabled: true,
|
||||
periodMinutes: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Mock chrome.alarms ====================
|
||||
|
||||
let alarmListeners: Array<(alarm: chrome.alarms.Alarm) => void> = [];
|
||||
let createdAlarms: Map<string, { periodInMinutes?: number; delayInMinutes?: number }> = new Map();
|
||||
|
||||
function setupMockChromeAlarms() {
|
||||
alarmListeners = [];
|
||||
createdAlarms = new Map();
|
||||
|
||||
const alarms = {
|
||||
create: vi.fn((name: string, info: { periodInMinutes?: number; delayInMinutes?: number }) => {
|
||||
createdAlarms.set(name, info);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
clear: vi.fn((name: string) => {
|
||||
createdAlarms.delete(name);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
getAll: vi.fn(() => {
|
||||
return Promise.resolve(
|
||||
Array.from(createdAlarms.entries()).map(([name]) => ({ name, scheduledTime: 0 })),
|
||||
);
|
||||
}),
|
||||
onAlarm: {
|
||||
addListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
|
||||
alarmListeners.push(listener);
|
||||
}),
|
||||
removeListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
|
||||
alarmListeners = alarmListeners.filter((l) => l !== listener);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
(globalThis as unknown as { chrome: { alarms: typeof alarms } }).chrome = { alarms };
|
||||
|
||||
return alarms;
|
||||
}
|
||||
|
||||
function simulateAlarmFire(name: string) {
|
||||
for (const listener of alarmListeners) {
|
||||
listener({ name, scheduledTime: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('IntervalTriggerHandler', () => {
|
||||
let mockAlarms: ReturnType<typeof setupMockChromeAlarms>;
|
||||
let mockLogger: ReturnType<typeof createMockLogger>;
|
||||
let fireCallback: ReturnType<typeof createMockFireCallback>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAlarms = setupMockChromeAlarms();
|
||||
mockLogger = createMockLogger();
|
||||
fireCallback = createMockFireCallback();
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
it('creates repeating alarm with correct periodInMinutes', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
const trigger = createIntervalTriggerSpec({ periodMinutes: 10 });
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
expect(mockAlarms.create).toHaveBeenCalledWith(
|
||||
'rr_v3_interval_interval-trigger-1',
|
||||
expect.objectContaining({
|
||||
periodInMinutes: 10,
|
||||
delayInMinutes: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds alarm listener on first install', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
|
||||
expect(mockAlarms.onAlarm.addListener).not.toHaveBeenCalled();
|
||||
|
||||
await handler.install(createIntervalTriggerSpec());
|
||||
|
||||
expect(mockAlarms.onAlarm.addListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers trigger ID', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
const trigger = createIntervalTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
expect(handler.getInstalledIds()).toContain(trigger.id);
|
||||
});
|
||||
|
||||
it('throws error for invalid periodMinutes', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
|
||||
await expect(
|
||||
handler.install(createIntervalTriggerSpec({ periodMinutes: 0 })),
|
||||
).rejects.toThrow('periodMinutes must be >= 1');
|
||||
|
||||
await expect(
|
||||
handler.install(createIntervalTriggerSpec({ periodMinutes: -5 })),
|
||||
).rejects.toThrow('periodMinutes must be >= 1');
|
||||
|
||||
await expect(
|
||||
handler.install(createIntervalTriggerSpec({ periodMinutes: NaN as number })),
|
||||
).rejects.toThrow('periodMinutes must be a finite number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
it('clears alarm and removes trigger from installed list', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
const trigger = createIntervalTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
expect(handler.getInstalledIds()).toContain(trigger.id);
|
||||
|
||||
await handler.uninstall(trigger.id);
|
||||
|
||||
expect(mockAlarms.clear).toHaveBeenCalledWith('rr_v3_interval_interval-trigger-1');
|
||||
expect(handler.getInstalledIds()).not.toContain(trigger.id);
|
||||
});
|
||||
|
||||
it('removes alarm listener when last trigger is uninstalled', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
const trigger = createIntervalTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
await handler.uninstall(trigger.id);
|
||||
|
||||
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallAll', () => {
|
||||
it('clears all interval alarms', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
|
||||
await handler.install(createIntervalTriggerSpec({ id: 'trigger-1' as TriggerId }));
|
||||
await handler.install(createIntervalTriggerSpec({ id: 'trigger-2' as TriggerId }));
|
||||
|
||||
await handler.uninstallAll();
|
||||
|
||||
expect(handler.getInstalledIds()).toHaveLength(0);
|
||||
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('alarm handling', () => {
|
||||
it('fires callback when alarm triggers', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
const trigger = createIntervalTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
// Simulate alarm fire
|
||||
simulateAlarmFire('rr_v3_interval_interval-trigger-1');
|
||||
|
||||
// Wait for async callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fireCallback.onFire).toHaveBeenCalledWith(
|
||||
trigger.id,
|
||||
expect.objectContaining({
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores alarms from other handlers', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
await handler.install(createIntervalTriggerSpec());
|
||||
|
||||
// Simulate alarm from different handler
|
||||
simulateAlarmFire('rr_v3_cron_some-other-trigger');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fireCallback.onFire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores alarms for uninstalled triggers', async () => {
|
||||
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
|
||||
const trigger = createIntervalTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
await handler.uninstall(trigger.id);
|
||||
|
||||
simulateAlarmFire('rr_v3_interval_interval-trigger-1');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fireCallback.onFire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @fileoverview Once Trigger Handler Tests
|
||||
* @description 测试 once 触发器的安装、卸载、触发和自动禁用行为
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { TriggerId, FlowId } from '@/entrypoints/background/record-replay-v3/domain/ids';
|
||||
import type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';
|
||||
import type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';
|
||||
import { createOnceTriggerHandler } from '@/entrypoints/background/record-replay-v3/engine/triggers/once-trigger';
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
function createMockLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockFireCallback(): TriggerFireCallback & { calls: Array<{ triggerId: string }> } {
|
||||
const calls: Array<{ triggerId: string }> = [];
|
||||
return {
|
||||
calls,
|
||||
onFire: vi.fn(async (triggerId) => {
|
||||
calls.push({ triggerId });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createOnceTriggerSpec(
|
||||
overrides: Partial<TriggerSpecByKind<'once'>> = {},
|
||||
): TriggerSpecByKind<'once'> {
|
||||
return {
|
||||
id: 'once-trigger-1' as TriggerId,
|
||||
kind: 'once',
|
||||
flowId: 'flow-1' as FlowId,
|
||||
enabled: true,
|
||||
whenMs: Date.now() + 60000, // 1 minute from now
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Mock chrome.alarms ====================
|
||||
|
||||
let alarmListeners: Array<(alarm: chrome.alarms.Alarm) => void> = [];
|
||||
let createdAlarms: Map<string, { when?: number }> = new Map();
|
||||
|
||||
function setupMockChromeAlarms() {
|
||||
alarmListeners = [];
|
||||
createdAlarms = new Map();
|
||||
|
||||
const alarms = {
|
||||
create: vi.fn((name: string, info: { when?: number }) => {
|
||||
createdAlarms.set(name, info);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
clear: vi.fn((name: string) => {
|
||||
createdAlarms.delete(name);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
getAll: vi.fn(() => {
|
||||
return Promise.resolve(
|
||||
Array.from(createdAlarms.entries()).map(([name, info]) => ({
|
||||
name,
|
||||
scheduledTime: info.when ?? 0,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
onAlarm: {
|
||||
addListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
|
||||
alarmListeners.push(listener);
|
||||
}),
|
||||
removeListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
|
||||
alarmListeners = alarmListeners.filter((l) => l !== listener);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
(globalThis as unknown as { chrome: { alarms: typeof alarms } }).chrome = { alarms };
|
||||
|
||||
return alarms;
|
||||
}
|
||||
|
||||
function simulateAlarmFire(name: string) {
|
||||
for (const listener of alarmListeners) {
|
||||
listener({ name, scheduledTime: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('OnceTriggerHandler', () => {
|
||||
let mockAlarms: ReturnType<typeof setupMockChromeAlarms>;
|
||||
let mockLogger: ReturnType<typeof createMockLogger>;
|
||||
let fireCallback: ReturnType<typeof createMockFireCallback>;
|
||||
let disabledTriggers: Set<TriggerId>;
|
||||
let mockDisableTrigger: (triggerId: TriggerId) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAlarms = setupMockChromeAlarms();
|
||||
mockLogger = createMockLogger();
|
||||
fireCallback = createMockFireCallback();
|
||||
disabledTriggers = new Set();
|
||||
mockDisableTrigger = vi.fn(async (triggerId: TriggerId) => {
|
||||
disabledTriggers.add(triggerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
it('creates one-shot alarm with correct when timestamp', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const futureTime = Date.now() + 300000; // 5 minutes
|
||||
const trigger = createOnceTriggerSpec({ whenMs: futureTime });
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
expect(mockAlarms.create).toHaveBeenCalledWith(
|
||||
'rr_v3_once_once-trigger-1',
|
||||
expect.objectContaining({ when: futureTime }),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds alarm listener on first install', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
|
||||
expect(mockAlarms.onAlarm.addListener).not.toHaveBeenCalled();
|
||||
|
||||
await handler.install(createOnceTriggerSpec());
|
||||
|
||||
expect(mockAlarms.onAlarm.addListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers trigger ID', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
expect(handler.getInstalledIds()).toContain(trigger.id);
|
||||
});
|
||||
|
||||
it('throws error for invalid whenMs', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handler.install(createOnceTriggerSpec({ whenMs: NaN as number })),
|
||||
).rejects.toThrow('whenMs must be a finite number');
|
||||
|
||||
await expect(
|
||||
handler.install(createOnceTriggerSpec({ whenMs: Infinity as number })),
|
||||
).rejects.toThrow('whenMs must be a finite number');
|
||||
});
|
||||
|
||||
it('floors whenMs to integer', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec({ whenMs: 1234567890123.999 });
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
expect(mockAlarms.create).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ when: 1234567890123 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
it('clears alarm and removes trigger from installed list', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
expect(handler.getInstalledIds()).toContain(trigger.id);
|
||||
|
||||
await handler.uninstall(trigger.id);
|
||||
|
||||
expect(mockAlarms.clear).toHaveBeenCalledWith('rr_v3_once_once-trigger-1');
|
||||
expect(handler.getInstalledIds()).not.toContain(trigger.id);
|
||||
});
|
||||
|
||||
it('removes alarm listener when last trigger is uninstalled', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
await handler.uninstall(trigger.id);
|
||||
|
||||
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallAll', () => {
|
||||
it('clears all once alarms', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
|
||||
await handler.install(createOnceTriggerSpec({ id: 'trigger-1' as TriggerId }));
|
||||
await handler.install(createOnceTriggerSpec({ id: 'trigger-2' as TriggerId }));
|
||||
|
||||
await handler.uninstallAll();
|
||||
|
||||
expect(handler.getInstalledIds()).toHaveLength(0);
|
||||
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('alarm handling', () => {
|
||||
it('fires callback when alarm triggers', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
|
||||
// Simulate alarm fire
|
||||
simulateAlarmFire('rr_v3_once_once-trigger-1');
|
||||
|
||||
// Wait for async callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fireCallback.onFire).toHaveBeenCalledWith(
|
||||
trigger.id,
|
||||
expect.objectContaining({
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables trigger after firing', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
simulateAlarmFire('rr_v3_once_once-trigger-1');
|
||||
|
||||
// Wait for async callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockDisableTrigger).toHaveBeenCalledWith(trigger.id);
|
||||
expect(disabledTriggers.has(trigger.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('uninstalls trigger after firing', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
expect(handler.getInstalledIds()).toContain(trigger.id);
|
||||
|
||||
simulateAlarmFire('rr_v3_once_once-trigger-1');
|
||||
|
||||
// Wait for async cleanup
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(handler.getInstalledIds()).not.toContain(trigger.id);
|
||||
});
|
||||
|
||||
it('ignores alarms from other handlers', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
await handler.install(createOnceTriggerSpec());
|
||||
|
||||
// Simulate alarm from different handler
|
||||
simulateAlarmFire('rr_v3_interval_some-other-trigger');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fireCallback.onFire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores alarms for uninstalled triggers', async () => {
|
||||
const handler = createOnceTriggerHandler(fireCallback, {
|
||||
logger: mockLogger,
|
||||
disableTrigger: mockDisableTrigger,
|
||||
});
|
||||
const trigger = createOnceTriggerSpec();
|
||||
|
||||
await handler.install(trigger);
|
||||
await handler.uninstall(trigger.id);
|
||||
|
||||
simulateAlarmFire('rr_v3_once_once-trigger-1');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fireCallback.onFire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -368,6 +368,47 @@ describe('V3 RPC Queue Management APIs', () => {
|
||||
),
|
||||
).rejects.toThrow('maxAttempts must be >= 1');
|
||||
});
|
||||
|
||||
it('persists startNodeId in RunRecord when provided', async () => {
|
||||
// Setup: add a flow with multiple nodes
|
||||
const flow = createTestFlow('flow-start-node');
|
||||
getInternal(storage).flowsMap.set(flow.id, flow);
|
||||
|
||||
// Act: enqueue with startNodeId
|
||||
const targetNodeId = flow.nodes[0].id; // Use the first node
|
||||
await (server as unknown as { handleRequest: Function }).handleRequest(
|
||||
{
|
||||
method: 'rr_v3.enqueueRun',
|
||||
params: { flowId: 'flow-start-node', startNodeId: targetNodeId },
|
||||
requestId: 'req-1',
|
||||
},
|
||||
{ subscriptions: new Set() },
|
||||
);
|
||||
|
||||
// Assert: RunRecord should have startNodeId
|
||||
const runsMap = getInternal(storage).runsMap;
|
||||
expect(runsMap.size).toBe(1);
|
||||
const runRecord = Array.from(runsMap.values())[0];
|
||||
expect(runRecord.startNodeId).toBe(targetNodeId);
|
||||
});
|
||||
|
||||
it('throws if startNodeId does not exist in flow', async () => {
|
||||
// Setup: add a flow
|
||||
const flow = createTestFlow('flow-invalid-start');
|
||||
getInternal(storage).flowsMap.set(flow.id, flow);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
(server as unknown as { handleRequest: Function }).handleRequest(
|
||||
{
|
||||
method: 'rr_v3.enqueueRun',
|
||||
params: { flowId: 'flow-invalid-start', startNodeId: 'non-existent-node' },
|
||||
requestId: 'req-1',
|
||||
},
|
||||
{ subscriptions: new Set() },
|
||||
),
|
||||
).rejects.toThrow('startNodeId "non-existent-node" not found in flow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rr_v3.listQueue', () => {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* @fileoverview V2 to V3 Flow Conversion Tests
|
||||
* @description 测试 V2→V3 转换逻辑,特别是 entryNodeId 计算
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
convertFlowV2ToV3,
|
||||
convertFlowV3ToV2,
|
||||
} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3';
|
||||
|
||||
// ==================== Test Helpers ====================
|
||||
|
||||
function createV2Flow(overrides: Partial<Parameters<typeof convertFlowV2ToV3>[0]> = {}) {
|
||||
return {
|
||||
id: 'test-flow',
|
||||
name: 'Test Flow',
|
||||
version: 2,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== entryNodeId Calculation Tests ====================
|
||||
|
||||
describe('convertFlowV2ToV3 - entryNodeId calculation', () => {
|
||||
describe('basic scenarios', () => {
|
||||
it('selects the only executable node as entry', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [{ id: 'nav-1', type: 'navigate' }],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.entryNodeId).toBe('nav-1');
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('selects node with inDegree=0 as entry', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-1', type: 'navigate' },
|
||||
{ id: 'click-1', type: 'click' },
|
||||
],
|
||||
edges: [{ id: 'e1', from: 'nav-1', to: 'click-1' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.entryNodeId).toBe('nav-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger node handling', () => {
|
||||
it('ignores trigger node when selecting entry', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'trigger-1', type: 'trigger' },
|
||||
{ id: 'nav-1', type: 'navigate' },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.entryNodeId).toBe('nav-1');
|
||||
});
|
||||
|
||||
it('ignores edges from trigger node when calculating inDegree', () => {
|
||||
// Scenario: trigger → navigate → click
|
||||
// Without this fix, navigate would have inDegree=1 and not be selected
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'trigger-1', type: 'trigger' },
|
||||
{ id: 'nav-1', type: 'navigate' },
|
||||
{ id: 'click-1', type: 'click' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: 'trigger-1', to: 'nav-1' },
|
||||
{ id: 'e2', from: 'nav-1', to: 'click-1' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// navigate should be entry because trigger edges are ignored
|
||||
expect(result.data?.entryNodeId).toBe('nav-1');
|
||||
});
|
||||
|
||||
it('returns error when only trigger nodes exist', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [{ id: 'trigger-1', type: 'trigger' }],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Could not determine entry node. No valid root node found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple root nodes - stable selection', () => {
|
||||
it('warns and selects by UI coordinates (leftmost, then topmost)', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-b', type: 'navigate', ui: { x: 200, y: 100 } },
|
||||
{ id: 'nav-a', type: 'navigate', ui: { x: 100, y: 200 } },
|
||||
{ id: 'nav-c', type: 'navigate', ui: { x: 100, y: 100 } },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// nav-c has smallest x, and smallest y at that x
|
||||
expect(result.data?.entryNodeId).toBe('nav-c');
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.warnings.some((w) => w.includes('Multiple inDegree=0'))).toBe(true);
|
||||
expect(result.warnings.some((w) => w.includes('ui(x=100, y=100)'))).toBe(true);
|
||||
});
|
||||
|
||||
it('selects by ID when no UI coordinates available', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-b', type: 'navigate' },
|
||||
{ id: 'nav-a', type: 'navigate' },
|
||||
{ id: 'nav-c', type: 'navigate' },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// nav-a comes first alphabetically
|
||||
expect(result.data?.entryNodeId).toBe('nav-a');
|
||||
expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses UI for nodes that have it, ignoring nodes without UI', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-a', type: 'navigate' }, // no UI
|
||||
{ id: 'nav-b', type: 'navigate', ui: { x: 50, y: 50 } },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// nav-b has UI coordinates, so it's preferred
|
||||
expect(result.data?.entryNodeId).toBe('nav-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycle detection', () => {
|
||||
it('falls back using stable selection when graph has cycle (no inDegree=0)', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-1', type: 'navigate' },
|
||||
{ id: 'click-1', type: 'click' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: 'nav-1', to: 'click-1' },
|
||||
{ id: 'e2', from: 'click-1', to: 'nav-1' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.entryNodeId).toBeTruthy();
|
||||
expect(result.warnings.some((w) => w.includes('cycles'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses stable selection (by id) for cycle fallback', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'z-node', type: 'navigate' },
|
||||
{ id: 'a-node', type: 'click' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: 'z-node', to: 'a-node' },
|
||||
{ id: 'e2', from: 'a-node', to: 'z-node' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should select 'a-node' as it comes first alphabetically
|
||||
expect(result.data?.entryNodeId).toBe('a-node');
|
||||
expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses stable selection (by UI) for cycle fallback when UI available', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'a-node', type: 'navigate', ui: { x: 200, y: 100 } },
|
||||
{ id: 'z-node', type: 'click', ui: { x: 100, y: 100 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: 'a-node', to: 'z-node' },
|
||||
{ id: 'e2', from: 'z-node', to: 'a-node' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should select 'z-node' as it has smaller x coordinate
|
||||
expect(result.data?.entryNodeId).toBe('z-node');
|
||||
expect(result.warnings.some((w) => w.includes('ui(x=100'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI coordinate edge cases', () => {
|
||||
it('treats NaN coordinates as invalid UI', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-a', type: 'navigate', ui: { x: NaN, y: 100 } },
|
||||
{ id: 'nav-b', type: 'navigate' },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Both nodes have no valid UI, should use ID sorting
|
||||
expect(result.data?.entryNodeId).toBe('nav-a');
|
||||
expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats Infinity coordinates as invalid UI', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-a', type: 'navigate', ui: { x: Infinity, y: 100 } },
|
||||
{ id: 'nav-b', type: 'navigate', ui: { x: 50, y: 50 } },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Only nav-b has valid UI
|
||||
expect(result.data?.entryNodeId).toBe('nav-b');
|
||||
});
|
||||
|
||||
it('uses id as tie-breaker when UI coordinates are equal', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [
|
||||
{ id: 'nav-z', type: 'navigate', ui: { x: 100, y: 100 } },
|
||||
{ id: 'nav-a', type: 'navigate', ui: { x: 100, y: 100 } },
|
||||
],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Same coordinates, should use ID as tie-breaker
|
||||
expect(result.data?.entryNodeId).toBe('nav-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and error cases', () => {
|
||||
it('returns error when no nodes exist', () => {
|
||||
const result = convertFlowV2ToV3(
|
||||
createV2Flow({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('V2 Flow has no nodes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Roundtrip Tests ====================
|
||||
|
||||
describe('V2 <-> V3 roundtrip conversion', () => {
|
||||
it('preserves basic flow structure through roundtrip', () => {
|
||||
const original = createV2Flow({
|
||||
name: 'Roundtrip Test',
|
||||
description: 'Test description',
|
||||
nodes: [
|
||||
{ id: 'nav-1', type: 'navigate', config: { url: 'https://example.com' } },
|
||||
{ id: 'click-1', type: 'click', config: { selector: '#btn' } },
|
||||
],
|
||||
edges: [{ id: 'e1', from: 'nav-1', to: 'click-1' }],
|
||||
});
|
||||
|
||||
const toV3 = convertFlowV2ToV3(original);
|
||||
expect(toV3.success).toBe(true);
|
||||
|
||||
const backToV2 = convertFlowV3ToV2(toV3.data!);
|
||||
expect(backToV2.success).toBe(true);
|
||||
|
||||
// Check structure preserved
|
||||
expect(backToV2.data?.name).toBe(original.name);
|
||||
expect(backToV2.data?.description).toBe(original.description);
|
||||
expect(backToV2.data?.nodes).toHaveLength(2);
|
||||
expect(backToV2.data?.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves node configs through roundtrip', () => {
|
||||
const original = createV2Flow({
|
||||
nodes: [
|
||||
{
|
||||
id: 'nav-1',
|
||||
type: 'navigate',
|
||||
name: 'Go to site',
|
||||
disabled: true,
|
||||
config: { url: 'https://example.com', waitUntil: 'load' },
|
||||
ui: { x: 100, y: 200 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
const toV3 = convertFlowV2ToV3(original);
|
||||
const backToV2 = convertFlowV3ToV2(toV3.data!);
|
||||
|
||||
const node = backToV2.data?.nodes?.[0];
|
||||
expect(node?.type).toBe('navigate');
|
||||
expect(node?.name).toBe('Go to site');
|
||||
expect(node?.disabled).toBe(true);
|
||||
expect(node?.config).toEqual({ url: 'https://example.com', waitUntil: 'load' });
|
||||
expect(node?.ui).toEqual({ x: 100, y: 200 });
|
||||
});
|
||||
});
|
||||
@@ -84,16 +84,16 @@ export default defineConfig({
|
||||
// suggested_key: { default: 'Ctrl+Shift+3' },
|
||||
// description: 'Run quick trigger 3',
|
||||
// },
|
||||
open_workflow_sidepanel: {
|
||||
suggested_key: { default: 'Ctrl+Shift+O' },
|
||||
description: 'Open workflow sidepanel',
|
||||
},
|
||||
// open_workflow_sidepanel: {
|
||||
// suggested_key: { default: 'Ctrl+Shift+O' },
|
||||
// description: 'Open workflow sidepanel',
|
||||
// },
|
||||
toggle_web_editor: {
|
||||
suggested_key: { default: 'Ctrl+Shift+E', mac: 'Command+Shift+E' },
|
||||
suggested_key: { default: 'Ctrl+Shift+O', mac: 'Command+Shift+O' },
|
||||
description: 'Toggle Web Editor mode',
|
||||
},
|
||||
toggle_quick_panel: {
|
||||
suggested_key: { default: 'Ctrl+Shift+K', mac: 'Command+Shift+K' },
|
||||
suggested_key: { default: 'Ctrl+Shift+U', mac: 'Command+Shift+U' },
|
||||
description: 'Toggle Quick Panel AI Chat',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Development-only files that should not be published to npm
|
||||
|
||||
# node_path.txt contains the absolute path to Node.js used during build.
|
||||
# It's written by build.ts for development hot-reload, but is useless
|
||||
# (and potentially confusing) in the published package since users will
|
||||
# have their own Node.js path written by postinstall.
|
||||
node_path.txt
|
||||
**/node_path.txt
|
||||
@@ -20,7 +20,8 @@
|
||||
"postinstall": "node dist/scripts/postinstall.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"!dist/node_path.txt"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getSession,
|
||||
updateEngineSessionId,
|
||||
updateManagementInfo,
|
||||
touchSessionActivity,
|
||||
type AgentSession,
|
||||
} from './session-service';
|
||||
import { attachmentService, type SavedAttachment } from './attachment-service';
|
||||
@@ -226,6 +227,10 @@ export class AgentChatService {
|
||||
// Persist user message into project history for later reload.
|
||||
try {
|
||||
await touchProjectActivity(projectId);
|
||||
// Update session activity timestamp so it appears at top of session list
|
||||
if (dbSessionId) {
|
||||
await touchSessionActivity(dbSessionId);
|
||||
}
|
||||
await persistAgentMessage({
|
||||
projectId,
|
||||
role: 'user',
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* - Linux: gnome-terminal, konsole, xfce4-terminal, xterm
|
||||
*/
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { OpenProjectResponse, OpenProjectTarget } from 'chrome-mcp-shared';
|
||||
import { validateRootPath } from './project-service';
|
||||
@@ -207,6 +209,128 @@ async function openInVSCode(absolutePath: string): Promise<void> {
|
||||
await runFallbackSequence(`Failed to open VS Code for: ${absolutePath}`, attempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file in VS Code at a specific line/column.
|
||||
*
|
||||
* Uses 'code -g file:line:col' syntax for goto functionality.
|
||||
* Also opens the project root with -r to reuse existing window.
|
||||
*
|
||||
* Security:
|
||||
* - Validates that file path stays within project root
|
||||
* - Uses spawn with args array (no shell interpolation)
|
||||
*
|
||||
* @param projectRoot - Project root directory (for security validation and -r flag)
|
||||
* @param filePath - File path (relative or absolute)
|
||||
* @param line - Optional line number (1-based)
|
||||
* @param column - Optional column number (1-based)
|
||||
*/
|
||||
export async function openFileInVSCode(
|
||||
projectRoot: string,
|
||||
filePath: string,
|
||||
line?: number,
|
||||
column?: number,
|
||||
): Promise<OpenProjectResponse> {
|
||||
try {
|
||||
// Validate project root
|
||||
const projectValidation = await validateRootPath(projectRoot);
|
||||
if (!projectValidation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: projectValidation.error ?? 'Invalid project rootPath',
|
||||
};
|
||||
}
|
||||
if (!projectValidation.exists) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Project directory does not exist: ${projectValidation.absolute}`,
|
||||
};
|
||||
}
|
||||
|
||||
const rootAbs = projectValidation.absolute;
|
||||
|
||||
// Validate file path
|
||||
const trimmedFile = String(filePath ?? '').trim();
|
||||
if (!trimmedFile) {
|
||||
return { success: false, error: 'filePath is required' };
|
||||
}
|
||||
|
||||
// Resolve file path (relative paths are resolved against project root)
|
||||
const absoluteFile = path.isAbsolute(trimmedFile)
|
||||
? path.resolve(trimmedFile)
|
||||
: path.resolve(rootAbs, trimmedFile);
|
||||
|
||||
// Security: ensure file stays within project root
|
||||
const relativeToRoot = path.relative(rootAbs, absoluteFile);
|
||||
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
||||
return { success: false, error: 'File path must be within project directory' };
|
||||
}
|
||||
|
||||
// Check file exists (best-effort, don't fail hard if file is new)
|
||||
try {
|
||||
const fileStat = await stat(absoluteFile);
|
||||
if (!fileStat.isFile()) {
|
||||
return { success: false, error: `Not a file: ${absoluteFile}` };
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: `File does not exist: ${absoluteFile}` };
|
||||
}
|
||||
|
||||
// Validate and sanitize line/column
|
||||
const safeLine =
|
||||
typeof line === 'number' && Number.isFinite(line) && line > 0 ? Math.floor(line) : undefined;
|
||||
const safeColumn =
|
||||
typeof column === 'number' && Number.isFinite(column) && column > 0
|
||||
? Math.floor(column)
|
||||
: undefined;
|
||||
|
||||
// Build goto argument: file:line:col
|
||||
let gotoArg = absoluteFile;
|
||||
if (safeLine) {
|
||||
gotoArg += `:${safeLine}`;
|
||||
if (safeColumn) {
|
||||
gotoArg += `:${safeColumn}`;
|
||||
}
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
// Build launch attempts
|
||||
// Use -r to reuse existing window, -g for goto
|
||||
const attempts: LaunchAttempt[] = [
|
||||
{
|
||||
label: 'code -r -g',
|
||||
cmd: 'code',
|
||||
args: ['-r', rootAbs, '-g', gotoArg],
|
||||
successAfterMs: 8000,
|
||||
},
|
||||
];
|
||||
|
||||
if (platform === 'win32') {
|
||||
attempts.push({
|
||||
label: 'code.cmd -r -g',
|
||||
cmd: 'code.cmd',
|
||||
args: ['-r', rootAbs, '-g', gotoArg],
|
||||
successAfterMs: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: use --args to pass flags to VS Code
|
||||
attempts.push({
|
||||
label: 'open -b com.microsoft.VSCode --args',
|
||||
cmd: 'open',
|
||||
args: ['-b', 'com.microsoft.VSCode', '--args', '-r', rootAbs, '-g', gotoArg],
|
||||
successAfterMs: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
await runFallbackSequence(`Failed to open VS Code for: ${gotoArg}`, attempts);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: formatSpawnError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Terminal
|
||||
// ============================================================
|
||||
|
||||
@@ -427,6 +427,16 @@ export async function updateEngineSessionId(
|
||||
await updateSession(sessionId, { engineSessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch session activity - updates the updatedAt timestamp.
|
||||
* Used when a message is sent to move the session to the top of the list.
|
||||
*/
|
||||
export async function touchSessionActivity(sessionId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
await db.update(sessions).set({ updatedAt: now }).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cached management information.
|
||||
*/
|
||||
|
||||
@@ -8,25 +8,12 @@ import {
|
||||
colorText,
|
||||
registerWithElevatedPermissions,
|
||||
ensureExecutionPermissions,
|
||||
writeNodePathFile,
|
||||
} from './scripts/utils';
|
||||
import { BrowserType, parseBrowserType, detectInstalledBrowsers } from './scripts/browser-config';
|
||||
import { runDoctor } from './scripts/doctor';
|
||||
import { runReport } from './scripts/report';
|
||||
|
||||
// Import writeNodePath from postinstall
|
||||
async function writeNodePath(): Promise<void> {
|
||||
try {
|
||||
const nodePath = process.execPath;
|
||||
const nodePathFile = path.join(__dirname, 'node_path.txt');
|
||||
|
||||
console.log(colorText(`Writing Node.js path: ${nodePath}`, 'blue'));
|
||||
fs.writeFileSync(nodePathFile, nodePath, 'utf8');
|
||||
console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
|
||||
} catch (error: any) {
|
||||
console.warn(colorText(`⚠️ Failed to write Node.js path: ${error.message}`, 'yellow'));
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.version(require('../package.json').version)
|
||||
.description('Mcp Chrome Bridge - Local service for communicating with Chrome extension');
|
||||
@@ -42,7 +29,7 @@ program
|
||||
.action(async (options) => {
|
||||
try {
|
||||
// Write Node.js path for run_host scripts
|
||||
await writeNodePath();
|
||||
writeNodePathFile(__dirname);
|
||||
|
||||
// Determine which browsers to register
|
||||
let targetBrowsers: BrowserType[] | undefined;
|
||||
|
||||
@@ -118,4 +118,12 @@ filesToMakeExecutable.forEach((file) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Write node_path.txt immediately after build to ensure Chrome uses the correct Node.js version.
|
||||
// This is critical for development mode where dist is deleted on each rebuild.
|
||||
// The file points to the same Node.js that compiled the native modules (better-sqlite3 etc.)
|
||||
console.log('写入 node_path.txt...');
|
||||
const nodePathFile = path.join(distDir, 'node_path.txt');
|
||||
fs.writeFileSync(nodePathFile, process.execPath, 'utf8');
|
||||
console.log(`已写入 Node.js 路径: ${process.execPath}`);
|
||||
|
||||
console.log('✅ 构建完成');
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { COMMAND_NAME } from './constant';
|
||||
import { colorText, tryRegisterUserLevelHost } from './utils';
|
||||
import { colorText, tryRegisterUserLevelHost, writeNodePathFile } from './utils';
|
||||
|
||||
// Check if this script is run directly
|
||||
const isDirectRun = require.main === module;
|
||||
@@ -73,22 +73,6 @@ function isRunningElevated(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Node.js path for run_host scripts to avoid fragile relative paths
|
||||
*/
|
||||
async function writeNodePath(): Promise<void> {
|
||||
try {
|
||||
const nodePath = process.execPath;
|
||||
const nodePathFile = path.join(__dirname, '..', 'node_path.txt');
|
||||
|
||||
console.log(colorText(`Writing Node.js path: ${nodePath}`, 'blue'));
|
||||
fs.writeFileSync(nodePathFile, nodePath, 'utf8');
|
||||
console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
|
||||
} catch (error: any) {
|
||||
console.warn(colorText(`⚠️ Failed to write Node.js path: ${error.message}`, 'yellow'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保执行权限(无论是否为全局安装)
|
||||
*/
|
||||
@@ -310,7 +294,7 @@ async function main(): Promise<void> {
|
||||
await ensureExecutionPermissions();
|
||||
|
||||
// Write Node.js path for run_host scripts to use
|
||||
await writeNodePath();
|
||||
writeNodePathFile(path.join(__dirname, '..'));
|
||||
|
||||
// If global installation, try automatic registration
|
||||
if (isGlobalInstall) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { tryRegisterUserLevelHost } from './utils';
|
||||
import { registerUserLevelHostWithNodePath } from './utils';
|
||||
|
||||
tryRegisterUserLevelHost();
|
||||
registerUserLevelHostWithNodePath();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'path';
|
||||
import { COMMAND_NAME } from './constant';
|
||||
import { colorText, registerWithElevatedPermissions } from './utils';
|
||||
import { colorText, registerWithElevatedPermissions, writeNodePathFile } from './utils';
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
@@ -9,6 +10,9 @@ async function main(): Promise<void> {
|
||||
console.log(colorText(`正在注册 ${COMMAND_NAME} Native Messaging主机...`, 'blue'));
|
||||
|
||||
try {
|
||||
// Write Node.js path before registration
|
||||
writeNodePathFile(path.join(__dirname, '..'));
|
||||
|
||||
await registerWithElevatedPermissions();
|
||||
console.log(
|
||||
colorText('注册成功!现在Chrome扩展可以通过Native Messaging与本地服务通信。', 'green'),
|
||||
|
||||
@@ -124,6 +124,28 @@ export async function getMainPath(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Node.js executable path to node_path.txt for run_host scripts.
|
||||
* This ensures the native host uses the same Node.js version that was used during installation,
|
||||
* avoiding NODE_MODULE_VERSION mismatch errors with native modules like better-sqlite3.
|
||||
*
|
||||
* @param distDir - The dist directory where node_path.txt should be written
|
||||
* @param nodeExecPath - The Node.js executable path to write (defaults to current process.execPath)
|
||||
*/
|
||||
export function writeNodePathFile(distDir: string, nodeExecPath = process.execPath): void {
|
||||
try {
|
||||
const nodePathFile = path.join(distDir, 'node_path.txt');
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
|
||||
console.log(colorText(`Writing Node.js path: ${nodeExecPath}`, 'blue'));
|
||||
fs.writeFileSync(nodePathFile, nodeExecPath, 'utf8');
|
||||
console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(colorText(`⚠️ Failed to write Node.js path: ${message}`, 'yellow'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保关键文件具有执行权限
|
||||
*/
|
||||
@@ -259,6 +281,21 @@ function verifyWindowsRegistryEntry(registryKey: string, expectedPath: string):
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write node_path.txt and then register user-level Native Messaging host.
|
||||
* This is the recommended entry point for development and production registration,
|
||||
* as it ensures the Node.js path is captured before registration.
|
||||
*
|
||||
* @param browsers - Optional list of browsers to register for
|
||||
* @returns true if at least one browser was registered successfully
|
||||
*/
|
||||
export async function registerUserLevelHostWithNodePath(
|
||||
browsers?: BrowserType[],
|
||||
): Promise<boolean> {
|
||||
writeNodePathFile(path.join(__dirname, '..'));
|
||||
return tryRegisterUserLevelHost(browsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试注册用户级别的Native Messaging主机
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,7 @@ import { getDefaultWorkspaceDir, getDefaultProjectRoot } from '../../agent/stora
|
||||
import { openDirectoryPicker } from '../../agent/directory-picker';
|
||||
import type { EngineName } from '../../agent/engines/types';
|
||||
import { attachmentService } from '../../agent/attachment-service';
|
||||
import { openProjectDirectory } from '../../agent/open-project';
|
||||
import { openProjectDirectory, openFileInVSCode } from '../../agent/open-project';
|
||||
import type {
|
||||
AttachmentStatsResponse,
|
||||
AttachmentCleanupRequest,
|
||||
@@ -727,6 +727,66 @@ export function registerAgentRoutes(fastify: FastifyInstance, options: AgentRout
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /agent/projects/:projectId/open-file
|
||||
* Open a file in VSCode at a specific line/column.
|
||||
*
|
||||
* Request body:
|
||||
* - filePath: string (required) - File path (relative or absolute)
|
||||
* - line?: number - Line number (1-based)
|
||||
* - column?: number - Column number (1-based)
|
||||
*/
|
||||
fastify.post(
|
||||
'/agent/projects/:projectId/open-file',
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Body: { filePath?: string; line?: number; column?: number };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { projectId } = request.params;
|
||||
const { filePath, line, column } = request.body || {};
|
||||
|
||||
if (!projectId) {
|
||||
return reply
|
||||
.status(HTTP_STATUS.BAD_REQUEST)
|
||||
.send({ success: false, error: 'projectId is required' });
|
||||
}
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return reply
|
||||
.status(HTTP_STATUS.BAD_REQUEST)
|
||||
.send({ success: false, error: 'filePath is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await getProject(projectId);
|
||||
if (!project) {
|
||||
return reply
|
||||
.status(HTTP_STATUS.NOT_FOUND)
|
||||
.send({ success: false, error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Open the file in VSCode
|
||||
const result = await openFileInVSCode(project.rootPath, filePath, line, column);
|
||||
if (result.success) {
|
||||
return reply.status(HTTP_STATUS.OK).send({ success: true });
|
||||
}
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
success: false,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to open file in VSCode');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
success: false,
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Chat Message Routes
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Record-Replay V3 重构任务清单
|
||||
|
||||
## 项目概述
|
||||
|
||||
将 Chrome 扩展的录制回放系统从 V2 架构完全迁移到 V3 架构,包括 Builder UI、节点扩展、触发器系统等。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- **Phase 0-3**: 全部完成 ✅
|
||||
- **Phase 4 (触发器系统)**: 全部完成 ✅
|
||||
- ✅ P4-01 TriggerStore CRUD
|
||||
- ✅ P4-02 TriggerManager
|
||||
- ✅ P4-03 URL trigger
|
||||
- ✅ P4-04 Command trigger
|
||||
- ✅ P4-05 ContextMenu trigger
|
||||
- ✅ P4-06 DOM trigger
|
||||
- ✅ P4-07 Cron trigger
|
||||
- ✅ P4-08 防抖/防风暴 (cooldown/maxQueued 已实现)
|
||||
- ✅ P4-09 触发器管理 RPC API
|
||||
- **Milestone 1 (Builder 核心链路 V3 化)**: 全部完成 ✅
|
||||
- ✅ 1.1 共享 RPC 层
|
||||
- ✅ 1.2 startNodeId 端到端补完
|
||||
- ✅ 1.3 Builder RPC 迁移
|
||||
- **Milestone 2 (Builder 数据层 V3 化)**: 部分完成 🔄
|
||||
- ⏸️ 2.1 类型迁移 - 评估后延后(当前转换层工作良好)
|
||||
- ✅ 2.2 entryNodeId 计算优化
|
||||
- ✅ 2.3 Sidebar Flow 分类 bug 修复
|
||||
- **Milestone 3 (触发器扩展)**: 全部完成 ✅
|
||||
- ✅ 3.1 interval/once TriggerKind
|
||||
- ✅ 3.2 Trigger 独立面板
|
||||
- **Milestone 4 (节点扩展)**: 进行中 🔄
|
||||
- ✅ 4.1 triggerEvent/setAttribute ActionHandler
|
||||
- ⏳ 4.2 V3 Control Flow 基础设施
|
||||
- ⏳ 4.3 foreach/while/loopElements 节点
|
||||
- ⏳ 4.4 executeFlow 节点
|
||||
- **Milestone 5 (清理 V2 代码)**: 待开始
|
||||
- **测试状态**: 641 个测试全部通过
|
||||
- **下一步**: Milestone 4.2 (Control Flow 基础设施)
|
||||
|
||||
---
|
||||
|
||||
## 已完成任务详情
|
||||
|
||||
### Milestone 1: Builder 核心链路 V3 化 ✅
|
||||
|
||||
#### 1.1 共享 RPC 层 ✅
|
||||
|
||||
- 新建: `entrypoints/shared/composables/useRRV3Rpc.ts`
|
||||
- 新建: `entrypoints/shared/composables/index.ts`
|
||||
- 修改: `entrypoints/sidepanel/composables/useRRV3Rpc.ts` → re-export
|
||||
|
||||
#### 1.2 startNodeId 端到端补完 ✅
|
||||
|
||||
- `EnqueueRunInput` 新增 `startNodeId?: NodeId`
|
||||
- 在 `enqueue-run.ts` 添加 startNodeId 校验和持久化
|
||||
- 在 `rpc-server.ts` 传递 `params.startNodeId`
|
||||
- 新增 2 个测试用例
|
||||
|
||||
#### 1.3 Builder RPC 迁移 ✅
|
||||
|
||||
- 新建: `entrypoints/shared/utils/rr-flow-convert.ts` (V2/V3 双向转换)
|
||||
- 修改 `App.vue`: bootstrap/save/run/sync/export/import 全面 V3 化
|
||||
|
||||
### Milestone 2: Builder 数据层 V3 化
|
||||
|
||||
#### 2.1 类型迁移 ⏸️ (延后)
|
||||
|
||||
当前 V2/V3 转换层工作良好,暂不迁移。
|
||||
|
||||
#### 2.2 entryNodeId 计算优化 ✅
|
||||
|
||||
- 重构 `findEntryNodeId` 函数(忽略 trigger 指出的边)
|
||||
- 新增 `selectStableRootNode` 函数(稳定的多根节点选择)
|
||||
- 17 个测试用例
|
||||
|
||||
#### 2.3 Sidebar Flow 分类 bug 修复 ✅
|
||||
|
||||
- Sidebar.vue 增加 `Flow` 分类
|
||||
- trigger/executeFlow 节点移到 Flow 分类
|
||||
|
||||
### Milestone 3: 触发器系统扩展 ✅
|
||||
|
||||
#### 3.1 interval/once TriggerKind ✅
|
||||
|
||||
- `TriggerKind` 新增 `'interval' | 'once'`
|
||||
- 新建 `interval-trigger.ts` (chrome.alarms.periodInMinutes)
|
||||
- 新建 `once-trigger.ts` (chrome.alarms.when + 自动禁用)
|
||||
- 23 个测试用例
|
||||
|
||||
#### 3.2 Trigger 独立面板 ✅
|
||||
|
||||
- 新建 `TriggerPanel.vue` (浮动面板)
|
||||
- 支持 interval/once CRUD (panel-managed)
|
||||
- node-managed 触发器只读展示 + 禁用 toggle
|
||||
- ownership 模型区分触发器来源
|
||||
|
||||
### Milestone 4: 节点扩展
|
||||
|
||||
#### 4.1 triggerEvent / setAttribute ✅
|
||||
|
||||
**实现为 V2 ActionHandler(自动被 V3 复用)**
|
||||
|
||||
新建文件:
|
||||
|
||||
- `entrypoints/background/record-replay/actions/handlers/dom.ts`
|
||||
- `triggerEventHandler`: 在元素上触发自定义 DOM 事件
|
||||
- `setAttributeHandler`: 设置/删除元素属性
|
||||
|
||||
修改文件:
|
||||
|
||||
- `entrypoints/background/record-replay/actions/handlers/index.ts`
|
||||
- 导入并注册 handler
|
||||
- 更新 `ALL_HANDLERS` 和 `registerReplayHandlers`
|
||||
|
||||
设计决策:
|
||||
|
||||
- 实现为 V2 ActionHandler,V3 通过 `registerV2ReplayNodesAsV3Nodes` 自动复用
|
||||
- 使用 `resolveTargetSelector` 共享目标解析逻辑
|
||||
- 脚本执行错误区分 `TARGET_NOT_FOUND` vs `SCRIPT_FAILED`
|
||||
|
||||
---
|
||||
|
||||
## 待完成任务
|
||||
|
||||
### Milestone 4.2: V3 Control Flow 基础设施 ⏳
|
||||
|
||||
**目标**: 扩展 V3 runner 支持 control directives 和 subflows
|
||||
|
||||
**设计决策(已确定)**:
|
||||
|
||||
1. subflows 存储在 FlowV3 顶层 `subflows?: Record<SubflowId, SubflowV3>`
|
||||
2. subflow 在同一个 Runner 内递归执行(不创建新 RunRunner)
|
||||
3. 变量作用域:foreach 共享 vars,每次迭代设置 itemVar
|
||||
4. 不支持并发执行(concurrency > 1 报错)
|
||||
5. 事件流:subflow 内节点照常发 node.started/node.succeeded
|
||||
|
||||
**文件变更**:
|
||||
|
||||
- `domain/flow.ts`: 添加 `SubflowV3` 类型,FlowV3 添加 `subflows` 字段
|
||||
- `engine/plugins/types.ts`: `NodeExecutionResult` 添加 `control` 字段
|
||||
- `engine/kernel/runner.ts`: 抽象 `runGraph()` 方法,实现 control directive 处理
|
||||
- `engine/plugins/v2-action-adapter.ts`: 移除 control 排除逻辑,改为返回 control
|
||||
- `engine/transport/rpc-server.ts`: `normalizeFlowSpec` 支持 subflows
|
||||
- `storage/flows.ts`: 存储校验支持 subflows
|
||||
|
||||
### Milestone 4.3: foreach/while/loopElements 节点 ⏳
|
||||
|
||||
**依赖**: 4.2 Control Flow 基础设施
|
||||
|
||||
**文件变更**:
|
||||
|
||||
- 新建: `engine/plugins/nodes/foreach.ts`
|
||||
- 新建: `engine/plugins/nodes/while.ts`
|
||||
- 新建: `engine/plugins/nodes/loop-elements.ts`
|
||||
- 复用表达式求值器: `record-replay/engine/utils/expression.ts`
|
||||
|
||||
### Milestone 4.4: executeFlow 节点 ⏳
|
||||
|
||||
**关键设计**:
|
||||
|
||||
- 不走 enqueueRun(避免死锁)
|
||||
- 作为 control directive 由 runner 直接子执行
|
||||
- `inline=true`: 共享 vars
|
||||
- `inline=false`: clone vars
|
||||
- 递归防护: 维护 flowId 调用栈检测环
|
||||
|
||||
**文件变更**:
|
||||
|
||||
- 新建: `engine/plugins/nodes/execute-flow.ts`
|
||||
|
||||
### Milestone 5: 清理 V2 代码 ⏳
|
||||
|
||||
#### 5.1 删除 V2 兼容代码
|
||||
|
||||
- `storage/import/v2-to-v3.ts`
|
||||
- `storage/import/v2-reader.ts`
|
||||
|
||||
#### 5.2 删除 V2 消息通道
|
||||
|
||||
- `builder/App.vue` 移除 `BACKGROUND_MESSAGE_TYPES.RR_*`
|
||||
- 逐步移除 `entrypoints/background/record-replay/` 相关代码
|
||||
|
||||
---
|
||||
|
||||
## 实施优先级与依赖关系
|
||||
|
||||
```
|
||||
Milestone 1: Builder 核心链路 V3 化 ✅
|
||||
├── 1.1 共享 RPC 层 ✅
|
||||
├── 1.2 startNodeId 端到端 ✅
|
||||
└── 1.3 Builder RPC 迁移 ✅
|
||||
│
|
||||
▼
|
||||
Milestone 2: Builder 数据层 V3 化
|
||||
├── 2.1 类型迁移 ⏸️
|
||||
├── 2.2 entryNodeId 计算 ✅
|
||||
└── 2.3 Sidebar bug 修复 ✅
|
||||
│
|
||||
├─────────────────────────────┐
|
||||
▼ ▼
|
||||
Milestone 3: 触发器扩展 ✅ Milestone 4.1: 简单节点 ✅
|
||||
├── 3.1 interval/once ✅ ├── triggerEvent ✅
|
||||
└── 3.2 Trigger 面板 ✅ └── setAttribute ✅
|
||||
│
|
||||
▼
|
||||
Milestone 4.2: Control Flow 基础 ⏳
|
||||
└── subflows + control directives
|
||||
│
|
||||
▼
|
||||
Milestone 4.3: 循环节点 ⏳
|
||||
├── foreach
|
||||
├── while
|
||||
└── loopElements
|
||||
│
|
||||
▼
|
||||
Milestone 4.4: executeFlow ⏳
|
||||
│
|
||||
▼
|
||||
Milestone 5: 清理 V2 代码 ⏳
|
||||
```
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
| ----------------------------------- | ------------------------------------------- |
|
||||
| entryNodeId 计算错误导致保存失败 | 复用已测试的 v2-to-v3.ts 规则,增加 UI 提示 |
|
||||
| startNodeId 不存在导致运行失败 | 在 enqueue-run.ts 校验存在性 |
|
||||
| control flow 复杂导致 runner 不稳定 | 渐进式实现,每步都有测试覆盖 |
|
||||
| executeFlow 递归死锁 | 维护调用栈检测环,不走 enqueueRun |
|
||||
| Trigger 批量保存造成抖动 | 节流/批处理策略 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 每个 Milestone 完成后运行全量测试
|
||||
- 新增功能必须有对应的契约测试
|
||||
- 节点扩展需要覆盖正常/异常/边界用例
|
||||
|
||||
---
|
||||
|
||||
_最后更新: 2025-12-29_
|
||||
@@ -108,6 +108,16 @@ export interface AgentActRequest {
|
||||
* Optional request id from client; server will generate one if missing.
|
||||
*/
|
||||
requestId?: string;
|
||||
/**
|
||||
* Optional client metadata to store with the user message.
|
||||
* For extension-specific context that should be preserved.
|
||||
*/
|
||||
clientMeta?: Record<string, unknown>;
|
||||
/**
|
||||
* Optional display text override for the instruction.
|
||||
* When set, UI should display this instead of raw instruction.
|
||||
*/
|
||||
displayText?: string;
|
||||
}
|
||||
|
||||
export interface AgentActResponse {
|
||||
@@ -140,6 +150,11 @@ export interface AgentProject {
|
||||
* When enabled, the engine will auto-detect CCR configuration.
|
||||
*/
|
||||
useCcr?: boolean;
|
||||
/**
|
||||
* Whether to enable Chrome MCP integration for this project.
|
||||
* Default: true
|
||||
*/
|
||||
enableChromeMcp?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActiveAt?: string;
|
||||
@@ -344,3 +359,119 @@ export const DEFAULT_CODEX_CONFIG: CodexEngineConfig = {
|
||||
autoInstructions: CODEX_AUTO_INSTRUCTIONS,
|
||||
appendProjectContext: true,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Attachment Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Metadata for a persisted attachment file.
|
||||
*/
|
||||
export interface AttachmentMetadata {
|
||||
/** Schema version for forward compatibility */
|
||||
version: number;
|
||||
/** Kind of attachment (e.g., 'image', 'file') */
|
||||
kind: string;
|
||||
/** Project ID this attachment belongs to */
|
||||
projectId: string;
|
||||
/** Message ID this attachment is associated with */
|
||||
messageId: string;
|
||||
/** Index of this attachment in the message */
|
||||
index: number;
|
||||
/** Persisted filename under project dir */
|
||||
filename: string;
|
||||
/** URL path to access this attachment */
|
||||
urlPath: string;
|
||||
/** MIME type of the attachment */
|
||||
mimeType: string;
|
||||
/** File size in bytes */
|
||||
sizeBytes: number;
|
||||
/** Original filename from upload */
|
||||
originalName: string;
|
||||
/** Timestamp when attachment was created */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for attachments in a single project.
|
||||
*/
|
||||
export interface AttachmentProjectStats {
|
||||
projectId: string;
|
||||
/** Directory path for this project's attachments */
|
||||
dirPath: string;
|
||||
/** Whether the directory exists */
|
||||
exists: boolean;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
/** Last modification timestamp (only when exists is true) */
|
||||
lastModifiedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup result for a single project.
|
||||
*/
|
||||
export interface CleanupProjectResult {
|
||||
projectId: string;
|
||||
dirPath: string;
|
||||
existed: boolean;
|
||||
removedFiles: number;
|
||||
removedBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for attachment statistics endpoint.
|
||||
*/
|
||||
export interface AttachmentStatsResponse {
|
||||
success: boolean;
|
||||
rootDir: string;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
projects: Array<
|
||||
AttachmentProjectStats & {
|
||||
projectName?: string;
|
||||
existsInDb: boolean;
|
||||
}
|
||||
>;
|
||||
orphanProjectIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for attachment cleanup endpoint.
|
||||
*/
|
||||
export interface AttachmentCleanupRequest {
|
||||
/** If provided, cleanup only these projects. Otherwise cleanup all. */
|
||||
projectIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for attachment cleanup endpoint.
|
||||
*/
|
||||
export interface AttachmentCleanupResponse {
|
||||
success: boolean;
|
||||
scope: 'project' | 'selected' | 'all';
|
||||
removedFiles: number;
|
||||
removedBytes: number;
|
||||
results: CleanupProjectResult[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Open Project Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Target application for opening a project directory.
|
||||
*/
|
||||
export type OpenProjectTarget = 'vscode' | 'terminal';
|
||||
|
||||
/**
|
||||
* Request body for open-project endpoint.
|
||||
*/
|
||||
export interface OpenProjectRequest {
|
||||
/** Target application to open the project in */
|
||||
target: OpenProjectTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for open-project endpoint.
|
||||
*/
|
||||
export type OpenProjectResponse = { success: true } | { success: false; error: string };
|
||||
|
||||
@@ -424,7 +424,7 @@ export function registerBuiltinSpecs() {
|
||||
registerNodeSpec({
|
||||
type: 'executeFlow' as any,
|
||||
version: 1,
|
||||
display: { label: '执行子流程', iconClass: 'icon-flow', category: 'Tools' },
|
||||
display: { label: '执行子流程', iconClass: 'icon-exec', category: 'Flow' },
|
||||
ports: { inputs: 1, outputs: [{ label: 'default' }] },
|
||||
schema: [
|
||||
{ key: 'flowId', label: '流程ID', type: 'string', required: true },
|
||||
@@ -545,11 +545,11 @@ export function registerBuiltinSpecs() {
|
||||
defaults: { sleep: 1000 },
|
||||
});
|
||||
|
||||
// Trigger (builder-only)
|
||||
// Trigger (builder-only, flow-level node)
|
||||
registerNodeSpec({
|
||||
type: STEP_TYPES.TRIGGER,
|
||||
version: 1,
|
||||
display: { label: '触发器', iconClass: 'icon-trigger', category: 'Actions' },
|
||||
display: { label: '触发器', iconClass: 'icon-trigger', category: 'Flow' },
|
||||
ports: { inputs: 0, outputs: [{ label: 'default' }] },
|
||||
schema: [
|
||||
{ key: 'enabled', label: '启用', type: 'boolean', default: true },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TOOL_NAMES = {
|
||||
WEB_FETCHER: 'chrome_get_web_content',
|
||||
CLICK: 'chrome_click_element',
|
||||
FILL: 'chrome_fill_or_select',
|
||||
REQUEST_ELEMENT_SELECTION: 'chrome_request_element_selection',
|
||||
GET_INTERACTIVE_ELEMENTS: 'chrome_get_interactive_elements',
|
||||
NETWORK_CAPTURE: 'chrome_network_capture',
|
||||
// Legacy tool names (kept for internal use, not exposed in TOOL_SCHEMAS)
|
||||
@@ -975,6 +976,57 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
required: ['value'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION,
|
||||
description:
|
||||
'Request the user to manually select one or more elements on the current page. Use this as a human-in-the-loop fallback when you cannot reliably locate the target element after approximately 3 attempts using chrome_read_page combined with chrome_click_element/chrome_fill_or_select/chrome_computer. The user will see a panel with instructions and can click on the requested elements. Returns element refs compatible with chrome_click_element/chrome_fill_or_select (including iframe frameId for cross-frame support).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requests: {
|
||||
type: 'array',
|
||||
description:
|
||||
'A list of element selection requests. Each request produces exactly one picked element. The user will see these requests in a panel and select each element by clicking on the page.',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional stable request id for correlation. If omitted, an id is auto-generated (e.g., "req_1").',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Short label shown to the user describing what element to select (e.g., "Login button", "Email input field").',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional longer instruction shown to the user with more context (e.g., "Click on the primary login button in the top-right corner").',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
timeoutMs: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Timeout in milliseconds for the user to complete all selections. Default: 180000 (3 minutes). Maximum: 600000 (10 minutes).',
|
||||
},
|
||||
tabId: {
|
||||
type: 'number',
|
||||
description: 'Target tab ID. If omitted, uses the current active tab.',
|
||||
},
|
||||
windowId: {
|
||||
type: 'number',
|
||||
description: 'Window ID to select active tab from (when tabId is omitted).',
|
||||
},
|
||||
},
|
||||
required: ['requests'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.KEYBOARD,
|
||||
description:
|
||||
|
||||
@@ -26,3 +26,140 @@ export interface NativeMessage<P = any, E = any> {
|
||||
payload?: P;
|
||||
error?: E;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Element Picker Types (chrome_request_element_selection)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* A single element selection request from the AI.
|
||||
*/
|
||||
export interface ElementPickerRequest {
|
||||
/**
|
||||
* Optional stable request id. If omitted, the extension will generate one.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Short label shown to the user (e.g., "Login button").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Optional longer instruction shown to the user.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding rectangle of a picked element.
|
||||
*/
|
||||
export interface PickedElementRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center point of a picked element.
|
||||
*/
|
||||
export interface PickedElementPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A picked element that can be used with other tools (click, fill, etc.).
|
||||
*/
|
||||
export interface PickedElement {
|
||||
/**
|
||||
* Element ref written into window.__claudeElementMap (frame-local).
|
||||
* Can be used directly with chrome_click_element, chrome_fill_or_select, etc.
|
||||
*/
|
||||
ref: string;
|
||||
/**
|
||||
* Best-effort stable CSS selector.
|
||||
*/
|
||||
selector: string;
|
||||
/**
|
||||
* Selector type (currently CSS only).
|
||||
*/
|
||||
selectorType: 'css';
|
||||
/**
|
||||
* Bounding rect in the element's frame viewport coordinates.
|
||||
*/
|
||||
rect: PickedElementRect;
|
||||
/**
|
||||
* Center point in the element's frame viewport coordinates.
|
||||
* Can be used as coordinates for chrome_computer.
|
||||
*/
|
||||
center: PickedElementPoint;
|
||||
/**
|
||||
* Optional text snippet to help verify the selection.
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* Lowercased tag name.
|
||||
*/
|
||||
tagName?: string;
|
||||
/**
|
||||
* Chrome frameId for iframe targeting.
|
||||
* Pass this to chrome_click_element/chrome_fill_or_select for cross-frame support.
|
||||
*/
|
||||
frameId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for a single element selection request.
|
||||
*/
|
||||
export interface ElementPickerResultItem {
|
||||
/**
|
||||
* The request id (matches the input request).
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The request name (for reference).
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The picked element, or null if not selected.
|
||||
*/
|
||||
element: PickedElement | null;
|
||||
/**
|
||||
* Error message if selection failed for this request.
|
||||
*/
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the chrome_request_element_selection tool.
|
||||
*/
|
||||
export interface ElementPickerResult {
|
||||
/**
|
||||
* True if the user confirmed all selections.
|
||||
*/
|
||||
success: boolean;
|
||||
/**
|
||||
* Session identifier for this picker session.
|
||||
*/
|
||||
sessionId: string;
|
||||
/**
|
||||
* Timeout value used for this session.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
/**
|
||||
* True if the user cancelled the selection.
|
||||
*/
|
||||
cancelled?: boolean;
|
||||
/**
|
||||
* True if the selection timed out.
|
||||
*/
|
||||
timedOut?: boolean;
|
||||
/**
|
||||
* List of request IDs that were not selected (for debugging).
|
||||
*/
|
||||
missingRequestIds?: string[];
|
||||
/**
|
||||
* Results for each requested element.
|
||||
*/
|
||||
results: ElementPickerResultItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user