feat: commit for store

This commit is contained in:
hangerye
2025-10-09 10:00:36 +08:00
parent 078ea77a1e
commit d4e2fb3c8d
7 changed files with 461 additions and 14 deletions
@@ -42,6 +42,7 @@ export const CONTENT_MESSAGE_TYPES = {
SCREENSHOT_HELPER_PING: 'screenshot_helper_ping',
INTERACTIVE_ELEMENTS_HELPER_PING: 'interactive_elements_helper_ping',
ACCESSIBILITY_TREE_HELPER_PING: 'chrome_read_page_ping',
WAIT_HELPER_PING: 'wait_helper_ping',
} as const;
// Tool action message types (for chrome.runtime.sendMessage)
@@ -73,6 +74,9 @@ export const TOOL_MESSAGE_TYPES = {
// Network requests
NETWORK_SEND_REQUEST: 'sendPureNetworkRequest',
// Wait helper
WAIT_FOR_TEXT: 'waitForText',
// Semantic similarity engine
SIMILARITY_ENGINE_INIT: 'similarityEngineInit',
SIMILARITY_ENGINE_COMPUTE_BATCH: 'similarityEngineComputeBatch',
@@ -19,6 +19,7 @@ export abstract class BaseBrowserToolExecutor implements ToolExecutor {
files: string[],
injectImmediately = false,
world: 'MAIN' | 'ISOLATED' = 'ISOLATED',
allFrames: boolean = false,
): Promise<void> {
console.log(`Injecting ${files.join(', ')} into tab ${tabId}`);
@@ -50,7 +51,7 @@ export abstract class BaseBrowserToolExecutor implements ToolExecutor {
try {
await chrome.scripting.executeScript({
target: { tabId },
target: { tabId, allFrames },
files,
injectImmediately,
world,
@@ -28,6 +28,8 @@ interface ComputerParams {
| 'hover'
| 'wait'
| 'fill'
| 'fill_form'
| 'resize_page'
| 'screenshot';
// click/scroll coordinates in screenshot space (if screenshot context exists) or viewport space
coordinates?: Coordinates; // for click/scroll; for drag, this is endCoordinates
@@ -243,6 +245,58 @@ class ComputerTool extends BaseBrowserToolExecutor {
};
switch (params.action) {
case 'resize_page': {
const width = Number((params as any).coordinates?.x || (params as any).text);
const height = Number((params as any).coordinates?.y || (params as any).value);
const w = Number((params as any).width ?? width);
const h = Number((params as any).height ?? height);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return createErrorResponse(
'Provide width and height for resize_page (positive numbers)',
);
}
try {
// Prefer precise CDP emulation
await CDPHelper.attach(tab.id);
try {
await chrome.debugger.sendCommand(
{ tabId: tab.id },
'Emulation.setDeviceMetricsOverride',
{
width: Math.round(w),
height: Math.round(h),
deviceScaleFactor: 0,
mobile: false,
screenWidth: Math.round(w),
screenHeight: Math.round(h),
},
);
} finally {
await CDPHelper.detach(tab.id);
}
} catch (e) {
// Fallback: window resize
if (tab.windowId !== undefined) {
await chrome.windows.update(tab.windowId, {
width: Math.round(w),
height: Math.round(h),
});
} else {
return createErrorResponse(
`Failed to resize via CDP and cannot determine windowId: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: true, action: 'resize_page', width: w, height: h }),
},
],
isError: false,
};
}
case 'hover': {
// Resolve target point from ref | selector | coordinates
let coord: Coordinates | undefined = undefined;
@@ -782,6 +836,52 @@ class ComputerTool extends BaseBrowserToolExecutor {
} as any);
return res;
}
case 'fill_form': {
const elements = (params as any).elements as Array<{
ref: string;
value: string | number | boolean;
}>;
if (!Array.isArray(elements) || elements.length === 0) {
return createErrorResponse('elements must be a non-empty array for fill_form');
}
const results: Array<{ ref: string; ok: boolean; error?: string }> = [];
for (const item of elements) {
if (!item || !item.ref) {
results.push({ ref: String(item?.ref || ''), ok: false, error: 'missing ref' });
continue;
}
try {
const r = await fillTool.execute({
ref: item.ref as any,
value: item.value as any,
} as any);
const ok = !r.isError;
results.push({ ref: item.ref, ok, error: ok ? undefined : 'failed' });
} catch (e) {
results.push({
ref: item.ref,
ok: false,
error: String(e instanceof Error ? e.message : e),
});
}
}
const successCount = results.filter((r) => r.ok).length;
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
action: 'fill_form',
filled: successCount,
total: results.length,
results,
}),
},
],
isError: false,
};
}
case 'key': {
if (!params.text)
return createErrorResponse(
@@ -821,19 +921,72 @@ class ComputerTool extends BaseBrowserToolExecutor {
}
}
case 'wait': {
const seconds = Math.max(0, Math.min(params.duration || 0, 30));
if (!seconds)
return createErrorResponse('Duration parameter is required and must be > 0');
await new Promise((r) => setTimeout(r, seconds * 1000));
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: true, action: 'wait', duration: seconds }),
},
],
isError: false,
};
const hasTextCondition =
typeof (params as any).text === 'string' && (params as any).text.trim().length > 0;
if (hasTextCondition) {
try {
// Conditional wait for text appearance/disappearance using content script
await this.injectContentScript(
tab.id,
['inject-scripts/wait-helper.js'],
false,
'ISOLATED',
true,
);
const appear = (params as any).appear !== false; // default to true
const timeoutMs = Math.max(
0,
Math.min(((params as any).timeout as number) || 10000, 120000),
);
const resp = await this.sendMessageToTab(tab.id, {
action: TOOL_MESSAGE_TYPES.WAIT_FOR_TEXT,
text: (params as any).text,
appear,
timeout: timeoutMs,
});
if (!resp || resp.success !== true) {
return createErrorResponse(
resp && resp.reason === 'timeout'
? `wait_for timed out after ${timeoutMs}ms for text: ${(params as any).text}`
: `wait_for failed: ${resp && resp.error ? resp.error : 'unknown error'}`,
);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
action: 'wait_for',
appear,
text: (params as any).text,
matched: resp.matched || null,
tookMs: resp.tookMs,
}),
},
],
isError: false,
};
} catch (e) {
return createErrorResponse(
`wait_for failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
} else {
const seconds = Math.max(0, Math.min((params as any).duration || 0, 30));
if (!seconds)
return createErrorResponse('Duration parameter is required and must be > 0');
await new Promise((r) => setTimeout(r, seconds * 1000));
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: true, action: 'wait', duration: seconds }),
},
],
isError: false,
};
}
}
case 'screenshot': {
// Reuse existing screenshot tool; it already supports base64 save option
@@ -0,0 +1,77 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
interface HandleDialogParams {
action: 'accept' | 'dismiss';
promptText?: string;
}
/**
* Handle JavaScript dialogs (alert/confirm/prompt) via CDP Page.handleJavaScriptDialog
*/
class HandleDialogTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.HANDLE_DIALOG;
async execute(args: HandleDialogParams): Promise<ToolResult> {
const { action, promptText } = args || ({} as HandleDialogParams);
if (!action || (action !== 'accept' && action !== 'dismiss')) {
return createErrorResponse('action must be "accept" or "dismiss"');
}
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return createErrorResponse('No active tab found');
// Attach debugger and try handling the dialog
try {
await chrome.debugger.attach({ tabId: activeTab.id }, '1.3');
} catch (e: any) {
if (String(e?.message || '').includes('attached')) {
// If already attached by us, proceed; otherwise fail with clear message
const targets = await chrome.debugger.getTargets();
const existing = targets.find((t) => t.tabId === activeTab.id && t.attached);
if (!existing || existing.extensionId !== chrome.runtime.id) {
return createErrorResponse(
`Debugger already attached to tab ${activeTab.id} by another client (e.g., DevTools). Close it and retry.`,
);
}
} else {
throw e;
}
}
try {
// Enable Page domain to be safe
await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.enable');
await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.handleJavaScriptDialog', {
accept: action === 'accept',
promptText: action === 'accept' ? promptText : undefined,
});
} finally {
// Best-effort detach if we were the owners
try {
await chrome.debugger.detach({ tabId: activeTab.id });
} catch {
// ignore
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({ success: true, action, promptText: promptText || null }),
},
],
isError: false,
};
} catch (error) {
return createErrorResponse(
`Failed to handle dialog: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const handleDialogTool = new HandleDialogTool();
@@ -15,3 +15,4 @@ export { consoleTool } from './console';
export { fileUploadTool } from './file-upload';
export { readPageTool } from './read-page';
export { computerTool } from './computer';
export { handleDialogTool } from './dialog';
@@ -0,0 +1,171 @@
/* eslint-disable */
// wait-helper.js
// Listen for text appearance/disappearance in the current document using MutationObserver.
// Returns a stable ref (compatible with accessibility-tree-helper) for the first matching element.
(function () {
if (window.__WAIT_HELPER_INITIALIZED__) return;
window.__WAIT_HELPER_INITIALIZED__ = true;
// Ensure ref mapping infra exists (compatible with accessibility-tree-helper.js)
if (!window.__claudeElementMap) window.__claudeElementMap = {};
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
function isVisible(el) {
try {
if (!(el instanceof Element)) return false;
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
return true;
} catch {
return false;
}
}
function normalize(str) {
return String(str || '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function matchesText(el, needle) {
const t = normalize(needle);
if (!t) return false;
try {
if (!isVisible(el)) return false;
const aria = el.getAttribute('aria-label');
if (aria && normalize(aria).includes(t)) return true;
const title = el.getAttribute('title');
if (title && normalize(title).includes(t)) return true;
const alt = el.getAttribute('alt');
if (alt && normalize(alt).includes(t)) return true;
const placeholder = el.getAttribute('placeholder');
if (placeholder && normalize(placeholder).includes(t)) return true;
// input/textarea value
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const value = el.value || el.getAttribute('value');
if (value && normalize(value).includes(t)) return true;
}
const text = el.innerText || el.textContent || '';
if (normalize(text).includes(t)) return true;
} catch {}
return false;
}
function findElementByText(text) {
// Fast path: query common interactive elements first
const prioritized = Array.from(
document.querySelectorAll('a,button,input,textarea,select,label,summary,[role]'),
);
for (const el of prioritized) if (matchesText(el, text)) return el;
// Fallback: broader scan with cap to avoid blocking on huge pages
const walker = document.createTreeWalker(
document.body || document.documentElement,
NodeFilter.SHOW_ELEMENT,
);
let count = 0;
while (walker.nextNode()) {
const el = /** @type {Element} */ (walker.currentNode);
if (matchesText(el, text)) return el;
if (++count > 5000) break; // Hard cap to avoid long scans
}
return null;
}
function ensureRefForElement(el) {
// Try to reuse an existing ref
for (const k in window.__claudeElementMap) {
const weak = window.__claudeElementMap[k];
if (weak && typeof weak.deref === 'function' && weak.deref() === el) return k;
}
const refId = `ref_${++window.__claudeRefCounter}`;
window.__claudeElementMap[refId] = new WeakRef(el);
return refId;
}
function centerOf(el) {
const r = el.getBoundingClientRect();
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
}
function waitFor({ text, appear = true, timeout = 5000 }) {
return new Promise((resolve) => {
const start = Date.now();
let resolved = false;
const check = () => {
try {
const match = findElementByText(text);
if (appear) {
if (match) {
const ref = ensureRefForElement(match);
const center = centerOf(match);
done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });
}
} else {
// wait for disappearance
if (!match) {
done({ success: true, matched: null, tookMs: Date.now() - start });
}
}
} catch {}
};
const done = (result) => {
if (resolved) return;
resolved = true;
obs && obs.disconnect();
clearTimeout(timer);
resolve(result);
};
const obs = new MutationObserver(() => check());
try {
obs.observe(document.documentElement || document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
} catch {}
// Initial check
check();
const timer = setTimeout(
() => {
done({ success: false, reason: 'timeout', tookMs: Date.now() - start });
},
Math.max(0, timeout),
);
});
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (request && request.action === 'wait_helper_ping') {
sendResponse({ status: 'pong' });
return false;
}
if (request && request.action === 'waitForText') {
const text = String(request.text || '').trim();
const appear = request.appear !== false; // default true
const timeout = Number(request.timeout || 5000);
if (!text) {
sendResponse({ success: false, error: 'text is required' });
return true;
}
waitFor({ text, appear, timeout }).then((res) => sendResponse(res));
return true; // async
}
} catch (e) {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
return true;
}
return false;
});
})();
+40
View File
@@ -29,6 +29,7 @@ export const TOOL_NAMES = {
FILE_UPLOAD: 'chrome_upload_file',
READ_PAGE: 'chrome_read_page',
COMPUTER: 'chrome_computer',
HANDLE_DIALOG: 'chrome_handle_dialog',
},
};
@@ -118,6 +119,30 @@ export const TOOL_SCHEMAS: Tool[] = [
oneOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'number' }],
description: 'Value to set for action=fill (string | boolean | number)',
},
elements: {
type: 'array',
description: 'For action=fill_form: list of elements to fill (ref + value)',
items: {
type: 'object',
properties: {
ref: { type: 'string', description: 'Element ref from chrome_read_page' },
value: { type: 'string', description: 'Value to set (stringified if non-string)' },
},
required: ['ref', 'value'],
},
},
width: { type: 'number', description: 'For action=resize_page: viewport width' },
height: { type: 'number', description: 'For action=resize_page: viewport height' },
appear: {
type: 'boolean',
description:
'For action=wait with text: whether to wait for the text to appear (true, default) or disappear (false)',
},
timeout: {
type: 'number',
description:
'For action=wait with text: timeout in milliseconds (default 10000, max 120000)',
},
duration: {
type: 'number',
description: 'Seconds to wait for action=wait (max 30s)',
@@ -571,4 +596,19 @@ export const TOOL_SCHEMAS: Tool[] = [
required: ['selector'],
},
},
{
name: TOOL_NAMES.BROWSER.HANDLE_DIALOG,
description: 'Handle JavaScript dialogs (alert/confirm/prompt) via CDP',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', description: 'accept | dismiss' },
promptText: {
type: 'string',
description: 'Optional prompt text when accepting a prompt',
},
},
required: ['action'],
},
},
];