diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b4edb5f..83e9053 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts index e0b39ea..9698185 100644 --- a/app/chrome-extension/common/message-types.ts +++ b/app/chrome-extension/common/message-types.ts @@ -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 diff --git a/app/chrome-extension/entrypoints/background/quick-panel/commands.ts b/app/chrome-extension/entrypoints/background/quick-panel/commands.ts index 51f99f2..a9425e2 100644 --- a/app/chrome-extension/entrypoints/background/quick-panel/commands.ts +++ b/app/chrome-extension/entrypoints/background/quick-panel/commands.ts @@ -103,13 +103,18 @@ async function toggleQuickPanelInActiveTab(): Promise { * 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); } diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts index 8f7ee70..75704c4 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts @@ -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 { contextMenu: createContextMenuTriggerHandlerFactory({ logger }), dom: createDomTriggerHandlerFactory({ logger }), cron: createCronTriggerHandlerFactory({ logger, now }), + interval: createIntervalTriggerHandlerFactory({ logger }), + once: createOnceTriggerHandlerFactory({ logger }), manual: createManualTriggerHandlerFactory({ logger }), }, now, diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts index 852d163..37f2c1b 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts index aaa081a..52e2591 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts @@ -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); diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts index cc428df..810fcd1 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts @@ -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`, ); } } diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts new file mode 100644 index 0000000..0bd6c4e --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts @@ -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; +} + +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(); + const versions = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + const id = triggerId as TriggerId; + bumpVersion(id); + installed.delete(id); + await clearAlarmByName(alarmNameForTrigger(id)); + + if (installed.size === 0) { + stopListening(); + } + }, + + async uninstallAll(): Promise { + for (const id of installed.keys()) { + bumpVersion(id); + } + installed.clear(); + await clearAllIntervalAlarms(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts new file mode 100644 index 0000000..fb14acf --- /dev/null +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts @@ -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; + /** + * 可选:自定义禁用触发器的方法 + * 如果未提供,将直接更新 TriggerStore + */ + disableTrigger?: (triggerId: TriggerId) => Promise; +} + +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 | 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(); + const versions = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await uninstallInternal(triggerId as TriggerId); + }, + + async uninstallAll(): Promise { + for (const id of installed.keys()) { + bumpVersion(id); + } + installed.clear(); + await clearAllOnceAlarms(); + stopListening(); + }, + + getInstalledIds(): string[] { + return Array.from(installed.keys()); + }, + }; +} diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts index 20cc797..bd1e671 100644 --- a/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts +++ b/app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts @@ -145,11 +145,13 @@ export function convertFlowV2ToV3(v2Flow: V2Flow): ConversionResult { } // 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(); + const executableNodeIds = new Set(executableNodes.map((n) => n.id)); + + // 2. 计算入度(只统计可执行节点之间的边) + const inDegree = new Map(); 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 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( + 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[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> => { + 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> => { + 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' }; + }, +}; diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts index c3f90b6..e14feaa 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts @@ -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 }); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts b/app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts new file mode 100644 index 0000000..76fe9ab --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts @@ -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; +} + +// ============================================================ +// 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(); + + 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, +): ElementPickerResultItem[] { + return requests.map((r) => ({ + id: r.id, + name: r.name, + element: pickedById.get(r.id) || null, + })); +} + +function listMissingRequestIds( + requests: NormalizedRequest[], + pickedById: Map, +): 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 { + 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, + ): Promise { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + world: 'ISOLATED', + injectImmediately: false, + func: (methodName: string, data: Record) => { + try { + const api = ( + globalThis as unknown as { + __mcpElementPicker?: Record) => 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 { + // 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(); + let activeRequestId: string | null = requests[0]?.id || null; + let uiErrorMessage: string | null = null; + let uiAvailable = true; + + let finished = false; + let timer: ReturnType | null = null; + let resolveResult: ((result: ElementPickerResult) => void) | null = null; + + // Send UI update to content script + const sendUiUpdate = async (): Promise => { + if (!uiAvailable) return; + try { + const selections: Record = {}; + 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 => { + 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 => { + 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 | 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>; + 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 => { + // Try to ping UI content script with retries + const pingWithTimeout = async (timeoutMs = 500): Promise => { + try { + const resp = await Promise.race([ + this.sendMessageToTab( + tabId, + { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING }, + 0, + ), + new Promise((_, 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((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(); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/index.ts b/app/chrome-extension/entrypoints/background/tools/browser/index.ts index 527fb0a..629f74e 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/index.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/index.ts @@ -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) diff --git a/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts b/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts index 5aaedb3..bb49265 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts @@ -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 = [ + '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; + 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 = { + 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); + } } } diff --git a/app/chrome-extension/entrypoints/background/web-editor/index.ts b/app/chrome-extension/entrypoints/background/web-editor/index.ts index d877bb8..6643fa6 100644 --- a/app/chrome-extension/entrypoints/background/web-editor/index.ts +++ b/app/chrome-extension/entrypoints/background/web-editor/index.ts @@ -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; + 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) => { diff --git a/app/chrome-extension/entrypoints/builder/App.vue b/app/chrome-extension/entrypoints/builder/App.vue index 1536166..0bfef5c 100644 --- a/app/chrome-extension/entrypoints/builder/App.vue +++ b/app/chrome-extension/entrypoints/builder/App.vue @@ -70,6 +70,24 @@ Rename + @@ -143,12 +140,15 @@ - - + +
@@ -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'), diff --git a/app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue b/app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue new file mode 100644 index 0000000..4c0fa63 --- /dev/null +++ b/app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue @@ -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 */ + + + + + diff --git a/app/chrome-extension/entrypoints/content-quick-panel.ts b/app/chrome-extension/entrypoints/quick-panel.content.ts similarity index 89% rename from app/chrome-extension/entrypoints/content-quick-panel.ts rename to app/chrome-extension/entrypoints/quick-panel.content.ts index 8efe91e..c25fd72 100644 --- a/app/chrome-extension/entrypoints/content-quick-panel.ts +++ b/app/chrome-extension/entrypoints/quick-panel.content.ts @@ -17,17 +17,10 @@ import { createQuickPanelController, type QuickPanelController } from '@/shared/ export default defineContentScript({ matches: [''], - // 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) }); diff --git a/app/chrome-extension/entrypoints/shared/composables/index.ts b/app/chrome-extension/entrypoints/shared/composables/index.ts new file mode 100644 index 0000000..a52c408 --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/composables/index.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts b/app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts new file mode 100644 index 0000000..3d527f9 --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts @@ -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 | null; + /** AbortSignal reference for cleanup */ + signal?: AbortSignal; + /** Abort handler for cleanup */ + abortHandler?: () => void; +} + +/** Composable return type */ +export interface UseRRV3Rpc { + // Connection state + connected: Ref; + connecting: Ref; + reconnecting: Ref; + reconnectAttempts: Ref; + lastError: Ref; + isReady: ComputedRef; + + // Diagnostics + pendingCount: Ref; + subscribedRunIds: Ref>; + + // Connection lifecycle + connect: () => Promise; + disconnect: (reason?: string) => void; + ensureConnected: () => Promise; + + // RPC calls + request: ( + method: RpcMethod, + params?: JsonObject, + options?: RpcRequestOptions, + ) => Promise; + + // Event subscription + subscribe: (runId?: RunId | null) => Promise; + unsubscribe: (runId?: RunId | null) => Promise; + 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; + 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(null); + const pendingCount = ref(0); + const subscribedRunIds = ref>([]); + + // Internal state (non-reactive) + const port = shallowRef(null); + const pendingRequests = new Map(); + const eventListeners = new Set(); + const desiredSubscriptions = new Set(); + let connectPromise: Promise | null = null; + let reconnectTimer: ReturnType | 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 { + 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 { + 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 { + if (isReady.value) return true; + return connect(); + } + + async function request( + method: RpcMethod, + params?: JsonObject, + reqOptions: RpcRequestOptions = {}, + ): Promise { + 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((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 { + 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 { + 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, + }; +} diff --git a/app/chrome-extension/entrypoints/shared/utils/index.ts b/app/chrome-extension/entrypoints/shared/utils/index.ts new file mode 100644 index 0000000..522adcd --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/utils/index.ts @@ -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'; diff --git a/app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts b/app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts new file mode 100644 index 0000000..338a856 --- /dev/null +++ b/app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts @@ -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 { + 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 { + const result = convertFlowV2ToV3(flowV2 as unknown as Parameters[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 { + 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; + 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; + 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; + + // { flows: [...] } 格式 + if (Array.isArray(obj.flows)) { + return obj.flows; + } + + // 单个 Flow 对象 + if (obj.id && (Array.isArray(obj.steps) || Array.isArray(obj.nodes))) { + return [obj]; + } + } + + return []; +} diff --git a/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue b/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue index 7b315b8..a661860 100644 --- a/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue +++ b/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue @@ -57,7 +57,7 @@