first commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { ToolExecutor } from '@/common/tool-handler';
|
||||
import type { ToolResult } from '@/common/tool-handler';
|
||||
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
|
||||
|
||||
const PING_TIMEOUT_MS = 300;
|
||||
|
||||
/**
|
||||
* Base class for browser tool executors
|
||||
*/
|
||||
export abstract class BaseBrowserToolExecutor implements ToolExecutor {
|
||||
abstract name: string;
|
||||
abstract execute(args: any): Promise<ToolResult>;
|
||||
|
||||
/**
|
||||
* Inject content script into tab
|
||||
*/
|
||||
protected async injectContentScript(
|
||||
tabId: number,
|
||||
files: string[],
|
||||
injectImmediately = false,
|
||||
world: 'MAIN' | 'ISOLATED' = 'ISOLATED',
|
||||
): Promise<void> {
|
||||
console.log(`Injecting ${files.join(', ')} into tab ${tabId}`);
|
||||
|
||||
// check if script is already injected
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
chrome.tabs.sendMessage(tabId, { action: `${this.name}_ping` }),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`${this.name} Ping action to tab ${tabId} timed out`)),
|
||||
PING_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
if (response && response.status === 'pong') {
|
||||
console.log(
|
||||
`pong received for action '${this.name}' in tab ${tabId}. Assuming script is active.`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`Unexpected ping response in tab ${tabId}:`, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`ping content script failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files,
|
||||
injectImmediately,
|
||||
world,
|
||||
});
|
||||
console.log(`'${files.join(', ')}' injection successful for tab ${tabId}`);
|
||||
} catch (injectionError) {
|
||||
const errorMessage =
|
||||
injectionError instanceof Error ? injectionError.message : String(injectionError);
|
||||
console.error(
|
||||
`Content script '${files.join(', ')}' injection failed for tab ${tabId}: ${errorMessage}`,
|
||||
);
|
||||
throw new Error(
|
||||
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to inject content script in tab ${tabId}: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to tab
|
||||
*/
|
||||
protected async sendMessageToTab(tabId: number, message: any): Promise<any> {
|
||||
try {
|
||||
const response = await chrome.tabs.sendMessage(tabId, message);
|
||||
|
||||
if (response && response.error) {
|
||||
throw new Error(String(response.error));
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`Error sending message to tab ${tabId} for action ${message?.action || 'unknown'}: ${errorMessage}`,
|
||||
);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,602 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { getMessage } from '@/utils/i18n';
|
||||
|
||||
/**
|
||||
* Bookmark search tool parameters interface
|
||||
*/
|
||||
interface BookmarkSearchToolParams {
|
||||
query?: string; // Search keywords for matching bookmark titles and URLs
|
||||
maxResults?: number; // Maximum number of results to return
|
||||
folderPath?: string; // Optional, specify which folder to search in (can be ID or path string like "Work/Projects")
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark add tool parameters interface
|
||||
*/
|
||||
interface BookmarkAddToolParams {
|
||||
url?: string; // URL to add as bookmark, if not provided use current active tab URL
|
||||
title?: string; // Bookmark title, if not provided use page title
|
||||
parentId?: string; // Parent folder ID or path string (like "Work/Projects"), if not provided add to "Bookmarks Bar" folder
|
||||
createFolder?: boolean; // Whether to automatically create parent folder if it doesn't exist
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark delete tool parameters interface
|
||||
*/
|
||||
interface BookmarkDeleteToolParams {
|
||||
bookmarkId?: string; // ID of bookmark to delete
|
||||
url?: string; // URL of bookmark to delete (if ID not provided, search by URL)
|
||||
title?: string; // Title of bookmark to delete (used for auxiliary matching, used together with URL)
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/**
|
||||
* Get the complete folder path of a bookmark
|
||||
* @param bookmarkNodeId ID of the bookmark or folder
|
||||
* @returns Returns folder path string (e.g., "Bookmarks Bar > Folder A > Subfolder B")
|
||||
*/
|
||||
async function getBookmarkFolderPath(bookmarkNodeId: string): Promise<string> {
|
||||
const pathParts: string[] = [];
|
||||
|
||||
try {
|
||||
// First get the node itself to check if it's a bookmark or folder
|
||||
const initialNodes = await chrome.bookmarks.get(bookmarkNodeId);
|
||||
if (initialNodes.length > 0 && initialNodes[0]) {
|
||||
const initialNode = initialNodes[0];
|
||||
|
||||
// Build path starting from parent node (same for both bookmarks and folders)
|
||||
let pathNodeId = initialNode.parentId;
|
||||
while (pathNodeId) {
|
||||
const parentNodes = await chrome.bookmarks.get(pathNodeId);
|
||||
if (parentNodes.length === 0) break;
|
||||
|
||||
const parentNode = parentNodes[0];
|
||||
if (parentNode.title) {
|
||||
pathParts.unshift(parentNode.title);
|
||||
}
|
||||
|
||||
if (!parentNode.parentId) break;
|
||||
pathNodeId = parentNode.parentId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting bookmark path for node ID ${bookmarkNodeId}:`, error);
|
||||
return pathParts.join(' > ') || 'Error getting path';
|
||||
}
|
||||
|
||||
return pathParts.join(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bookmark folder by ID or path string
|
||||
* If it's an ID, validate it
|
||||
* If it's a path string, try to parse it
|
||||
* @param pathOrId Path string (e.g., "Work/Projects") or folder ID
|
||||
* @returns Returns folder node, or null if not found
|
||||
*/
|
||||
async function findFolderByPathOrId(
|
||||
pathOrId: string,
|
||||
): Promise<chrome.bookmarks.BookmarkTreeNode | null> {
|
||||
try {
|
||||
const nodes = await chrome.bookmarks.get(pathOrId);
|
||||
if (nodes && nodes.length > 0 && !nodes[0].url) {
|
||||
return nodes[0];
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing, try to parse as path string
|
||||
}
|
||||
|
||||
const pathParts = pathOrId
|
||||
.split('/')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
if (pathParts.length === 0) return null;
|
||||
|
||||
const rootChildren = await chrome.bookmarks.getChildren('0');
|
||||
|
||||
let currentNodes = rootChildren;
|
||||
let foundFolder: chrome.bookmarks.BookmarkTreeNode | null = null;
|
||||
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i];
|
||||
foundFolder = null;
|
||||
let matchedNodeThisLevel: chrome.bookmarks.BookmarkTreeNode | null = null;
|
||||
|
||||
for (const node of currentNodes) {
|
||||
if (!node.url && node.title.toLowerCase() === part.toLowerCase()) {
|
||||
matchedNodeThisLevel = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedNodeThisLevel) {
|
||||
if (i === pathParts.length - 1) {
|
||||
foundFolder = matchedNodeThisLevel;
|
||||
} else {
|
||||
currentNodes = await chrome.bookmarks.getChildren(matchedNodeThisLevel.id);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return foundFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create folder path (if it doesn't exist)
|
||||
* @param folderPath Folder path string (e.g., "Work/Projects/Subproject")
|
||||
* @param parentId Optional parent folder ID, defaults to "Bookmarks Bar"
|
||||
* @returns Returns the created or found final folder node
|
||||
*/
|
||||
async function createFolderPath(
|
||||
folderPath: string,
|
||||
parentId?: string,
|
||||
): Promise<chrome.bookmarks.BookmarkTreeNode> {
|
||||
const pathParts = folderPath
|
||||
.split('/')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
throw new Error('Folder path cannot be empty');
|
||||
}
|
||||
|
||||
// If no parent ID specified, use "Bookmarks Bar" folder
|
||||
let currentParentId: string = parentId || '';
|
||||
if (!currentParentId) {
|
||||
const rootChildren = await chrome.bookmarks.getChildren('0');
|
||||
// Find "Bookmarks Bar" folder (usually ID is '1', but search by title for compatibility)
|
||||
const bookmarkBarFolder = rootChildren.find(
|
||||
(node) =>
|
||||
!node.url &&
|
||||
(node.title === getMessage('bookmarksBarLabel') ||
|
||||
node.title === 'Bookmarks bar' ||
|
||||
node.title === 'Bookmarks Bar'),
|
||||
);
|
||||
currentParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID
|
||||
}
|
||||
|
||||
let currentFolder: chrome.bookmarks.BookmarkTreeNode | null = null;
|
||||
|
||||
// Create or find folders level by level
|
||||
for (const folderName of pathParts) {
|
||||
const children: chrome.bookmarks.BookmarkTreeNode[] =
|
||||
await chrome.bookmarks.getChildren(currentParentId);
|
||||
|
||||
// Check if folder with same name already exists
|
||||
const existingFolder: chrome.bookmarks.BookmarkTreeNode | undefined = children.find(
|
||||
(child: chrome.bookmarks.BookmarkTreeNode) =>
|
||||
!child.url && child.title.toLowerCase() === folderName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (existingFolder) {
|
||||
currentFolder = existingFolder;
|
||||
currentParentId = existingFolder.id;
|
||||
} else {
|
||||
// Create new folder
|
||||
currentFolder = await chrome.bookmarks.create({
|
||||
parentId: currentParentId,
|
||||
title: folderName,
|
||||
});
|
||||
currentParentId = currentFolder.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentFolder) {
|
||||
throw new Error('Failed to create folder path');
|
||||
}
|
||||
|
||||
return currentFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten bookmark tree (or node array) to bookmark list (excluding folders)
|
||||
* @param nodes Bookmark tree nodes to flatten
|
||||
* @returns Returns actual bookmark node array (nodes with URLs)
|
||||
*/
|
||||
function flattenBookmarkNodesToBookmarks(
|
||||
nodes: chrome.bookmarks.BookmarkTreeNode[],
|
||||
): chrome.bookmarks.BookmarkTreeNode[] {
|
||||
const result: chrome.bookmarks.BookmarkTreeNode[] = [];
|
||||
const stack = [...nodes]; // Use stack for iterative traversal to avoid deep recursion issues
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node) continue;
|
||||
|
||||
if (node.url) {
|
||||
// It's a bookmark
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
// Add child nodes to stack for processing
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bookmarks by URL and title
|
||||
* @param url Bookmark URL
|
||||
* @param title Optional bookmark title for auxiliary matching
|
||||
* @returns Returns array of matching bookmarks
|
||||
*/
|
||||
async function findBookmarksByUrl(
|
||||
url: string,
|
||||
title?: string,
|
||||
): Promise<chrome.bookmarks.BookmarkTreeNode[]> {
|
||||
// Use Chrome API to search by URL
|
||||
const searchResults = await chrome.bookmarks.search({ url });
|
||||
|
||||
if (!title) {
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
// If title is provided, further filter results
|
||||
const titleLower = title.toLowerCase();
|
||||
return searchResults.filter(
|
||||
(bookmark) => bookmark.title && bookmark.title.toLowerCase().includes(titleLower),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark search tool
|
||||
* Used to search bookmarks in Chrome browser
|
||||
*/
|
||||
class BookmarkSearchTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.BOOKMARK_SEARCH;
|
||||
|
||||
/**
|
||||
* Execute bookmark search
|
||||
*/
|
||||
async execute(args: BookmarkSearchToolParams): Promise<ToolResult> {
|
||||
const { query = '', maxResults = 50, folderPath } = args;
|
||||
|
||||
console.log(
|
||||
`BookmarkSearchTool: Searching bookmarks, keywords: "${query}", folder path: "${folderPath}"`,
|
||||
);
|
||||
|
||||
try {
|
||||
let bookmarksToSearch: chrome.bookmarks.BookmarkTreeNode[] = [];
|
||||
let targetFolderNode: chrome.bookmarks.BookmarkTreeNode | null = null;
|
||||
|
||||
// If folder path is specified, find that folder first
|
||||
if (folderPath) {
|
||||
targetFolderNode = await findFolderByPathOrId(folderPath);
|
||||
if (!targetFolderNode) {
|
||||
return createErrorResponse(`Specified folder not found: "${folderPath}"`);
|
||||
}
|
||||
// Get all bookmarks in that folder and its subfolders
|
||||
const subTree = await chrome.bookmarks.getSubTree(targetFolderNode.id);
|
||||
bookmarksToSearch =
|
||||
subTree.length > 0 ? flattenBookmarkNodesToBookmarks(subTree[0].children || []) : [];
|
||||
}
|
||||
|
||||
let filteredBookmarks: chrome.bookmarks.BookmarkTreeNode[];
|
||||
|
||||
if (query) {
|
||||
if (targetFolderNode) {
|
||||
// Has query keywords and specified folder: manually filter bookmarks from folder
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
filteredBookmarks = bookmarksToSearch.filter(
|
||||
(bookmark) =>
|
||||
(bookmark.title && bookmark.title.toLowerCase().includes(lowerCaseQuery)) ||
|
||||
(bookmark.url && bookmark.url.toLowerCase().includes(lowerCaseQuery)),
|
||||
);
|
||||
} else {
|
||||
// Has query keywords but no specified folder: use API search
|
||||
filteredBookmarks = await chrome.bookmarks.search({ query });
|
||||
// API search may return folders (if title matches), filter them out
|
||||
filteredBookmarks = filteredBookmarks.filter((item) => !!item.url);
|
||||
}
|
||||
} else {
|
||||
// No query keywords
|
||||
if (!targetFolderNode) {
|
||||
// No folder path specified, get all bookmarks
|
||||
const tree = await chrome.bookmarks.getTree();
|
||||
bookmarksToSearch = flattenBookmarkNodesToBookmarks(tree);
|
||||
}
|
||||
filteredBookmarks = bookmarksToSearch;
|
||||
}
|
||||
|
||||
// Limit number of results
|
||||
const limitedResults = filteredBookmarks.slice(0, maxResults);
|
||||
|
||||
// Add folder path information for each bookmark
|
||||
const resultsWithPath = await Promise.all(
|
||||
limitedResults.map(async (bookmark) => {
|
||||
const path = await getBookmarkFolderPath(bookmark.id);
|
||||
return {
|
||||
id: bookmark.id,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
dateAdded: bookmark.dateAdded,
|
||||
folderPath: path,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
totalResults: resultsWithPath.length,
|
||||
query: query || null,
|
||||
folderSearched: targetFolderNode
|
||||
? targetFolderNode.title || targetFolderNode.id
|
||||
: 'All bookmarks',
|
||||
bookmarks: resultsWithPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error searching bookmarks:', error);
|
||||
return createErrorResponse(
|
||||
`Error searching bookmarks: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark add tool
|
||||
* Used to add new bookmarks to Chrome browser
|
||||
*/
|
||||
class BookmarkAddTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.BOOKMARK_ADD;
|
||||
|
||||
/**
|
||||
* Execute add bookmark operation
|
||||
*/
|
||||
async execute(args: BookmarkAddToolParams): Promise<ToolResult> {
|
||||
const { url, title, parentId, createFolder = false } = args;
|
||||
|
||||
console.log(`BookmarkAddTool: Adding bookmark, options:`, args);
|
||||
|
||||
try {
|
||||
// If no URL provided, use current active tab
|
||||
let bookmarkUrl = url;
|
||||
let bookmarkTitle = title;
|
||||
|
||||
if (!bookmarkUrl) {
|
||||
// Get current active tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0] || !tabs[0].url) {
|
||||
// tab.url might be undefined (e.g., chrome:// pages)
|
||||
return createErrorResponse('No active tab with valid URL found, and no URL provided');
|
||||
}
|
||||
|
||||
bookmarkUrl = tabs[0].url;
|
||||
if (!bookmarkTitle) {
|
||||
bookmarkTitle = tabs[0].title || bookmarkUrl; // If tab title is empty, use URL as title
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookmarkUrl) {
|
||||
// Should have been caught above, but as a safety measure
|
||||
return createErrorResponse('URL is required to create bookmark');
|
||||
}
|
||||
|
||||
// Parse parentId (could be ID or path string)
|
||||
let actualParentId: string | undefined = undefined;
|
||||
if (parentId) {
|
||||
let folderNode = await findFolderByPathOrId(parentId);
|
||||
|
||||
if (!folderNode && createFolder) {
|
||||
// If folder doesn't exist and creation is allowed, create folder path
|
||||
try {
|
||||
folderNode = await createFolderPath(parentId);
|
||||
} catch (createError) {
|
||||
return createErrorResponse(
|
||||
`Failed to create folder path: ${createError instanceof Error ? createError.message : String(createError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderNode) {
|
||||
actualParentId = folderNode.id;
|
||||
} else {
|
||||
// Check if parentId might be a direct ID missed by findFolderByPathOrId (e.g., root folder '1')
|
||||
try {
|
||||
const nodes = await chrome.bookmarks.get(parentId);
|
||||
if (nodes && nodes.length > 0 && !nodes[0].url) {
|
||||
actualParentId = nodes[0].id;
|
||||
} else {
|
||||
return createErrorResponse(
|
||||
`Specified parent folder (ID/path: "${parentId}") not found or is not a folder${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return createErrorResponse(
|
||||
`Specified parent folder (ID/path: "${parentId}") not found or invalid${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no parentId specified, default to "Bookmarks Bar"
|
||||
const rootChildren = await chrome.bookmarks.getChildren('0');
|
||||
const bookmarkBarFolder = rootChildren.find(
|
||||
(node) =>
|
||||
!node.url &&
|
||||
(node.title === getMessage('bookmarksBarLabel') ||
|
||||
node.title === 'Bookmarks bar' ||
|
||||
node.title === 'Bookmarks Bar'),
|
||||
);
|
||||
actualParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID
|
||||
}
|
||||
// If actualParentId is still undefined, chrome.bookmarks.create will use default "Other Bookmarks", but we've set Bookmarks Bar
|
||||
|
||||
// Create bookmark
|
||||
const newBookmark = await chrome.bookmarks.create({
|
||||
parentId: actualParentId, // If undefined, API uses default value
|
||||
title: bookmarkTitle || bookmarkUrl, // Ensure title is never empty
|
||||
url: bookmarkUrl,
|
||||
});
|
||||
|
||||
// Get bookmark path
|
||||
const path = await getBookmarkFolderPath(newBookmark.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: 'Bookmark added successfully',
|
||||
bookmark: {
|
||||
id: newBookmark.id,
|
||||
title: newBookmark.title,
|
||||
url: newBookmark.url,
|
||||
dateAdded: newBookmark.dateAdded,
|
||||
folderPath: path,
|
||||
},
|
||||
folderCreated: createFolder && parentId ? 'Folder created if necessary' : false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error adding bookmark:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Provide more specific error messages for common error cases, such as trying to bookmark chrome:// URLs
|
||||
if (errorMessage.includes("Can't bookmark URLs of type")) {
|
||||
return createErrorResponse(
|
||||
`Error adding bookmark: Cannot bookmark this type of URL (e.g., chrome:// system pages). ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
return createErrorResponse(`Error adding bookmark: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark delete tool
|
||||
* Used to delete bookmarks in Chrome browser
|
||||
*/
|
||||
class BookmarkDeleteTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.BOOKMARK_DELETE;
|
||||
|
||||
/**
|
||||
* Execute delete bookmark operation
|
||||
*/
|
||||
async execute(args: BookmarkDeleteToolParams): Promise<ToolResult> {
|
||||
const { bookmarkId, url, title } = args;
|
||||
|
||||
console.log(`BookmarkDeleteTool: Deleting bookmark, options:`, args);
|
||||
|
||||
if (!bookmarkId && !url) {
|
||||
return createErrorResponse('Must provide bookmark ID or URL to delete bookmark');
|
||||
}
|
||||
|
||||
try {
|
||||
let bookmarksToDelete: chrome.bookmarks.BookmarkTreeNode[] = [];
|
||||
|
||||
if (bookmarkId) {
|
||||
// Delete by ID
|
||||
try {
|
||||
const nodes = await chrome.bookmarks.get(bookmarkId);
|
||||
if (nodes && nodes.length > 0 && nodes[0].url) {
|
||||
bookmarksToDelete = nodes;
|
||||
} else {
|
||||
return createErrorResponse(
|
||||
`Bookmark with ID "${bookmarkId}" not found, or the ID does not correspond to a bookmark`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return createErrorResponse(`Invalid bookmark ID: "${bookmarkId}"`);
|
||||
}
|
||||
} else if (url) {
|
||||
// Delete by URL
|
||||
bookmarksToDelete = await findBookmarksByUrl(url, title);
|
||||
if (bookmarksToDelete.length === 0) {
|
||||
return createErrorResponse(
|
||||
`No bookmark found with URL "${url}"${title ? ` (title contains: "${title}")` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete found bookmarks
|
||||
const deletedBookmarks = [];
|
||||
const errors = [];
|
||||
|
||||
for (const bookmark of bookmarksToDelete) {
|
||||
try {
|
||||
// Get path information before deletion
|
||||
const path = await getBookmarkFolderPath(bookmark.id);
|
||||
|
||||
await chrome.bookmarks.remove(bookmark.id);
|
||||
|
||||
deletedBookmarks.push({
|
||||
id: bookmark.id,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
folderPath: path,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(
|
||||
`Failed to delete bookmark "${bookmark.title}" (ID: ${bookmark.id}): ${errorMsg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedBookmarks.length === 0) {
|
||||
return createErrorResponse(`Failed to delete bookmarks: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
success: true,
|
||||
message: `Successfully deleted ${deletedBookmarks.length} bookmark(s)`,
|
||||
deletedBookmarks,
|
||||
};
|
||||
|
||||
if (errors.length > 0) {
|
||||
result.partialSuccess = true;
|
||||
result.errors = errors;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting bookmark:', error);
|
||||
return createErrorResponse(
|
||||
`Error deleting bookmark: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bookmarkSearchTool = new BookmarkSearchTool();
|
||||
export const bookmarkAddTool = new BookmarkAddTool();
|
||||
export const bookmarkDeleteTool = new BookmarkDeleteTool();
|
@@ -0,0 +1,478 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
// Default window dimensions
|
||||
const DEFAULT_WINDOW_WIDTH = 1280;
|
||||
const DEFAULT_WINDOW_HEIGHT = 720;
|
||||
|
||||
interface NavigateToolParams {
|
||||
url?: string;
|
||||
newWindow?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for navigating to URLs in browser tabs or windows
|
||||
*/
|
||||
class NavigateTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.NAVIGATE;
|
||||
|
||||
async execute(args: NavigateToolParams): Promise<ToolResult> {
|
||||
const { newWindow = false, width, height, url, refresh = false } = args;
|
||||
|
||||
console.log(
|
||||
`Attempting to ${refresh ? 'refresh current tab' : `open URL: ${url}`} with options:`,
|
||||
args,
|
||||
);
|
||||
|
||||
try {
|
||||
// Handle refresh option first
|
||||
if (refresh) {
|
||||
console.log('Refreshing current active tab');
|
||||
|
||||
// Get current active tab
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
if (!activeTab || !activeTab.id) {
|
||||
return createErrorResponse('No active tab found to refresh');
|
||||
}
|
||||
|
||||
// Reload the tab
|
||||
await chrome.tabs.reload(activeTab.id);
|
||||
|
||||
console.log(`Refreshed tab ID: ${activeTab.id}`);
|
||||
|
||||
// Get updated tab information
|
||||
const updatedTab = await chrome.tabs.get(activeTab.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Successfully refreshed current tab',
|
||||
tabId: updatedTab.id,
|
||||
windowId: updatedTab.windowId,
|
||||
url: updatedTab.url,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate that url is provided when not refreshing
|
||||
if (!url) {
|
||||
return createErrorResponse('URL parameter is required when refresh is not true');
|
||||
}
|
||||
|
||||
// 1. Check if URL is already open
|
||||
// Get all tabs and manually compare URLs
|
||||
console.log(`Checking if URL is already open: ${url}`);
|
||||
// Get all tabs
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
// Manually filter matching tabs
|
||||
const tabs = allTabs.filter((tab) => {
|
||||
// Normalize URLs for comparison (remove trailing slashes)
|
||||
const tabUrl = tab.url?.endsWith('/') ? tab.url.slice(0, -1) : tab.url;
|
||||
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
return tabUrl === targetUrl;
|
||||
});
|
||||
console.log(`Found ${tabs.length} matching tabs`);
|
||||
|
||||
if (tabs && tabs.length > 0) {
|
||||
const existingTab = tabs[0];
|
||||
console.log(
|
||||
`URL already open in Tab ID: ${existingTab.id}, Window ID: ${existingTab.windowId}`,
|
||||
);
|
||||
|
||||
if (existingTab.id !== undefined) {
|
||||
// Activate the tab
|
||||
await chrome.tabs.update(existingTab.id, { active: true });
|
||||
|
||||
if (existingTab.windowId !== undefined) {
|
||||
// Bring the window containing this tab to the foreground and focus it
|
||||
await chrome.windows.update(existingTab.windowId, { focused: true });
|
||||
}
|
||||
|
||||
console.log(`Activated existing Tab ID: ${existingTab.id}`);
|
||||
// Get updated tab information and return it
|
||||
const updatedTab = await chrome.tabs.get(existingTab.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Activated existing tab',
|
||||
tabId: updatedTab.id,
|
||||
windowId: updatedTab.windowId,
|
||||
url: updatedTab.url,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If URL is not already open, decide how to open it based on options
|
||||
const openInNewWindow = newWindow || typeof width === 'number' || typeof height === 'number';
|
||||
|
||||
if (openInNewWindow) {
|
||||
console.log('Opening URL in a new window.');
|
||||
|
||||
// Create new window
|
||||
const newWindow = await chrome.windows.create({
|
||||
url: url,
|
||||
width: typeof width === 'number' ? width : DEFAULT_WINDOW_WIDTH,
|
||||
height: typeof height === 'number' ? height : DEFAULT_WINDOW_HEIGHT,
|
||||
focused: true,
|
||||
});
|
||||
|
||||
if (newWindow && newWindow.id !== undefined) {
|
||||
console.log(`URL opened in new Window ID: ${newWindow.id}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Opened URL in new window',
|
||||
windowId: newWindow.id,
|
||||
tabs: newWindow.tabs
|
||||
? newWindow.tabs.map((tab) => ({
|
||||
tabId: tab.id,
|
||||
url: tab.url,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log('Opening URL in the last active window.');
|
||||
// Try to open a new tab in the most recently active window
|
||||
const lastFocusedWindow = await chrome.windows.getLastFocused({ populate: false });
|
||||
|
||||
if (lastFocusedWindow && lastFocusedWindow.id !== undefined) {
|
||||
console.log(`Found last focused Window ID: ${lastFocusedWindow.id}`);
|
||||
|
||||
const newTab = await chrome.tabs.create({
|
||||
url: url,
|
||||
windowId: lastFocusedWindow.id,
|
||||
active: true,
|
||||
});
|
||||
|
||||
// Ensure the window also gets focus
|
||||
await chrome.windows.update(lastFocusedWindow.id, { focused: true });
|
||||
|
||||
console.log(
|
||||
`URL opened in new Tab ID: ${newTab.id} in existing Window ID: ${lastFocusedWindow.id}`,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Opened URL in new tab in existing window',
|
||||
tabId: newTab.id,
|
||||
windowId: lastFocusedWindow.id,
|
||||
url: newTab.url,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} else {
|
||||
// In rare cases, if there's no recently active window (e.g., browser just started with no windows)
|
||||
// Fall back to opening in a new window
|
||||
console.warn('No last focused window found, falling back to creating a new window.');
|
||||
|
||||
const fallbackWindow = await chrome.windows.create({
|
||||
url: url,
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
focused: true,
|
||||
});
|
||||
|
||||
if (fallbackWindow && fallbackWindow.id !== undefined) {
|
||||
console.log(`URL opened in fallback new Window ID: ${fallbackWindow.id}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Opened URL in new window',
|
||||
windowId: fallbackWindow.id,
|
||||
tabs: fallbackWindow.tabs
|
||||
? fallbackWindow.tabs.map((tab) => ({
|
||||
tabId: tab.id,
|
||||
url: tab.url,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts fail, return a generic error
|
||||
return createErrorResponse('Failed to open URL: Unknown error occurred');
|
||||
} catch (error) {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error);
|
||||
return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`);
|
||||
} else {
|
||||
console.error('Error in navigate:', error);
|
||||
return createErrorResponse(
|
||||
`Error navigating to URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export const navigateTool = new NavigateTool();
|
||||
|
||||
interface CloseTabsToolParams {
|
||||
tabIds?: number[];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for closing browser tabs
|
||||
*/
|
||||
class CloseTabsTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.CLOSE_TABS;
|
||||
|
||||
async execute(args: CloseTabsToolParams): Promise<ToolResult> {
|
||||
const { tabIds, url } = args;
|
||||
let urlPattern = url;
|
||||
console.log(`Attempting to close tabs with options:`, args);
|
||||
|
||||
try {
|
||||
// If URL is provided, close all tabs matching that URL
|
||||
if (urlPattern) {
|
||||
console.log(`Searching for tabs with URL: ${url}`);
|
||||
if (!urlPattern.endsWith('/')) {
|
||||
urlPattern += '/*';
|
||||
}
|
||||
const tabs = await chrome.tabs.query({ url });
|
||||
|
||||
if (!tabs || tabs.length === 0) {
|
||||
console.log(`No tabs found with URL: ${url}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: `No tabs found with URL: ${url}`,
|
||||
closedCount: 0,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found ${tabs.length} tabs with URL: ${url}`);
|
||||
const tabIdsToClose = tabs
|
||||
.map((tab) => tab.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
if (tabIdsToClose.length === 0) {
|
||||
return createErrorResponse('Found tabs but could not get their IDs');
|
||||
}
|
||||
|
||||
await chrome.tabs.remove(tabIdsToClose);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Closed ${tabIdsToClose.length} tabs with URL: ${url}`,
|
||||
closedCount: tabIdsToClose.length,
|
||||
closedTabIds: tabIdsToClose,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// If tabIds are provided, close those tabs
|
||||
if (tabIds && tabIds.length > 0) {
|
||||
console.log(`Closing tabs with IDs: ${tabIds.join(', ')}`);
|
||||
|
||||
// Verify that all tabIds exist
|
||||
const existingTabs = await Promise.all(
|
||||
tabIds.map(async (tabId) => {
|
||||
try {
|
||||
return await chrome.tabs.get(tabId);
|
||||
} catch (error) {
|
||||
console.warn(`Tab with ID ${tabId} not found`);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validTabIds = existingTabs
|
||||
.filter((tab): tab is chrome.tabs.Tab => tab !== null)
|
||||
.map((tab) => tab.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
if (validTabIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
message: 'None of the provided tab IDs exist',
|
||||
closedCount: 0,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
await chrome.tabs.remove(validTabIds);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Closed ${validTabIds.length} tabs`,
|
||||
closedCount: validTabIds.length,
|
||||
closedTabIds: validTabIds,
|
||||
invalidTabIds: tabIds.filter((id) => !validTabIds.includes(id)),
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// If no tabIds or URL provided, close the current active tab
|
||||
console.log('No tabIds or URL provided, closing active tab');
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
if (!activeTab || !activeTab.id) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
|
||||
await chrome.tabs.remove(activeTab.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Closed active tab',
|
||||
closedCount: 1,
|
||||
closedTabIds: [activeTab.id],
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in CloseTabsTool.execute:', error);
|
||||
return createErrorResponse(
|
||||
`Error closing tabs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const closeTabsTool = new CloseTabsTool();
|
||||
|
||||
interface GoBackOrForwardToolParams {
|
||||
isForward?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for navigating back or forward in browser history
|
||||
*/
|
||||
class GoBackOrForwardTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.GO_BACK_OR_FORWARD;
|
||||
|
||||
async execute(args: GoBackOrForwardToolParams): Promise<ToolResult> {
|
||||
const { isForward = false } = args;
|
||||
|
||||
console.log(`Attempting to navigate ${isForward ? 'forward' : 'back'} in browser history`);
|
||||
|
||||
try {
|
||||
// Get current active tab
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
if (!activeTab || !activeTab.id) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
|
||||
// Navigate back or forward based on the isForward parameter
|
||||
if (isForward) {
|
||||
await chrome.tabs.goForward(activeTab.id);
|
||||
console.log(`Navigated forward in tab ID: ${activeTab.id}`);
|
||||
} else {
|
||||
await chrome.tabs.goBack(activeTab.id);
|
||||
console.log(`Navigated back in tab ID: ${activeTab.id}`);
|
||||
}
|
||||
|
||||
// Get updated tab information
|
||||
const updatedTab = await chrome.tabs.get(activeTab.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully navigated ${isForward ? 'forward' : 'back'} in browser history`,
|
||||
tabId: updatedTab.id,
|
||||
windowId: updatedTab.windowId,
|
||||
url: updatedTab.url,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error);
|
||||
return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`);
|
||||
} else {
|
||||
console.error('Error in GoBackOrForwardTool.execute:', error);
|
||||
return createErrorResponse(
|
||||
`Error navigating ${isForward ? 'forward' : 'back'}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const goBackOrForwardTool = new GoBackOrForwardTool();
|
@@ -0,0 +1,343 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
const DEBUGGER_PROTOCOL_VERSION = '1.3';
|
||||
const DEFAULT_MAX_MESSAGES = 100;
|
||||
|
||||
interface ConsoleToolParams {
|
||||
url?: string;
|
||||
includeExceptions?: boolean;
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
interface ConsoleMessage {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
text: string;
|
||||
args?: any[];
|
||||
source?: string;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
stackTrace?: any;
|
||||
}
|
||||
|
||||
interface ConsoleException {
|
||||
timestamp: number;
|
||||
text: string;
|
||||
url?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
stackTrace?: any;
|
||||
}
|
||||
|
||||
interface ConsoleResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
tabId: number;
|
||||
tabUrl: string;
|
||||
tabTitle: string;
|
||||
captureStartTime: number;
|
||||
captureEndTime: number;
|
||||
totalDurationMs: number;
|
||||
messages: ConsoleMessage[];
|
||||
exceptions: ConsoleException[];
|
||||
messageCount: number;
|
||||
exceptionCount: number;
|
||||
messageLimitReached: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for capturing console output from browser tabs
|
||||
*/
|
||||
class ConsoleTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.CONSOLE;
|
||||
|
||||
async execute(args: ConsoleToolParams): Promise<ToolResult> {
|
||||
const { url, includeExceptions = true, maxMessages = DEFAULT_MAX_MESSAGES } = args;
|
||||
|
||||
let targetTab: chrome.tabs.Tab;
|
||||
|
||||
try {
|
||||
if (url) {
|
||||
// Navigate to the specified URL
|
||||
targetTab = await this.navigateToUrl(url);
|
||||
} else {
|
||||
// Use current active tab
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab?.id) {
|
||||
return createErrorResponse('No active tab found and no URL provided.');
|
||||
}
|
||||
targetTab = activeTab;
|
||||
}
|
||||
|
||||
if (!targetTab?.id) {
|
||||
return createErrorResponse('Failed to identify target tab.');
|
||||
}
|
||||
|
||||
const tabId = targetTab.id;
|
||||
|
||||
// Capture console messages (one-time capture)
|
||||
const result = await this.captureConsoleMessages(tabId, {
|
||||
includeExceptions,
|
||||
maxMessages,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('ConsoleTool: Critical error during execute:', error);
|
||||
return createErrorResponse(`Error in ConsoleTool: ${error.message || String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToUrl(url: string): Promise<chrome.tabs.Tab> {
|
||||
// Check if URL is already open
|
||||
const existingTabs = await chrome.tabs.query({ url });
|
||||
|
||||
if (existingTabs.length > 0 && existingTabs[0]?.id) {
|
||||
const tab = existingTabs[0];
|
||||
// Activate the existing tab
|
||||
await chrome.tabs.update(tab.id!, { active: true });
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
return tab;
|
||||
} else {
|
||||
// Create new tab with the URL
|
||||
const newTab = await chrome.tabs.create({ url, active: true });
|
||||
// Wait for tab to be ready
|
||||
await this.waitForTabReady(newTab.id!);
|
||||
return newTab;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForTabReady(tabId: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkTab = async () => {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (tab.status === 'complete') {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkTab, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
// Tab might be closed, resolve anyway
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
checkTab();
|
||||
});
|
||||
}
|
||||
|
||||
private formatConsoleArgs(args: any[]): string {
|
||||
if (!args || args.length === 0) return '';
|
||||
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (arg.type === 'string') {
|
||||
return arg.value || '';
|
||||
} else if (arg.type === 'number') {
|
||||
return String(arg.value || '');
|
||||
} else if (arg.type === 'boolean') {
|
||||
return String(arg.value || '');
|
||||
} else if (arg.type === 'object') {
|
||||
return arg.description || '[Object]';
|
||||
} else if (arg.type === 'undefined') {
|
||||
return 'undefined';
|
||||
} else if (arg.type === 'function') {
|
||||
return arg.description || '[Function]';
|
||||
} else {
|
||||
return arg.description || arg.value || String(arg);
|
||||
}
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
private async captureConsoleMessages(
|
||||
tabId: number,
|
||||
options: {
|
||||
includeExceptions: boolean;
|
||||
maxMessages: number;
|
||||
},
|
||||
): Promise<ConsoleResult> {
|
||||
const { includeExceptions, maxMessages } = options;
|
||||
const startTime = Date.now();
|
||||
const messages: ConsoleMessage[] = [];
|
||||
const exceptions: ConsoleException[] = [];
|
||||
let limitReached = false;
|
||||
|
||||
try {
|
||||
// Get tab information
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
|
||||
// Check if debugger is already attached
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existingTarget = targets.find(
|
||||
(t) => t.tabId === tabId && t.attached && t.type === 'page',
|
||||
);
|
||||
if (existingTarget && !existingTarget.extensionId) {
|
||||
throw new Error(
|
||||
`Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Attach debugger
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot attach to the target with an attached client')) {
|
||||
throw new Error(
|
||||
`Debugger is already attached to tab ${tabId}. This might be DevTools or another extension.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Set up event listener to collect messages
|
||||
const collectedMessages: any[] = [];
|
||||
const collectedExceptions: any[] = [];
|
||||
|
||||
const eventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => {
|
||||
if (source.tabId !== tabId) return;
|
||||
|
||||
if (method === 'Log.entryAdded' && params?.entry) {
|
||||
collectedMessages.push(params.entry);
|
||||
} else if (method === 'Runtime.consoleAPICalled' && params) {
|
||||
// Convert Runtime.consoleAPICalled to Log.entryAdded format
|
||||
const logEntry = {
|
||||
timestamp: params.timestamp,
|
||||
level: params.type || 'log',
|
||||
text: this.formatConsoleArgs(params.args || []),
|
||||
source: 'console-api',
|
||||
url: params.stackTrace?.callFrames?.[0]?.url,
|
||||
lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber,
|
||||
stackTrace: params.stackTrace,
|
||||
args: params.args,
|
||||
};
|
||||
collectedMessages.push(logEntry);
|
||||
} else if (
|
||||
method === 'Runtime.exceptionThrown' &&
|
||||
includeExceptions &&
|
||||
params?.exceptionDetails
|
||||
) {
|
||||
collectedExceptions.push(params.exceptionDetails);
|
||||
}
|
||||
};
|
||||
|
||||
chrome.debugger.onEvent.addListener(eventListener);
|
||||
|
||||
try {
|
||||
// Enable Runtime domain first to capture console API calls and exceptions
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
|
||||
|
||||
// Also enable Log domain to capture other log entries
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Log.enable');
|
||||
|
||||
// Wait for all messages to be flushed
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Process collected messages
|
||||
for (const entry of collectedMessages) {
|
||||
if (messages.length >= maxMessages) {
|
||||
limitReached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const message: ConsoleMessage = {
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.level || 'log',
|
||||
text: entry.text || '',
|
||||
source: entry.source,
|
||||
url: entry.url,
|
||||
lineNumber: entry.lineNumber,
|
||||
};
|
||||
|
||||
if (entry.stackTrace) {
|
||||
message.stackTrace = entry.stackTrace;
|
||||
}
|
||||
|
||||
if (entry.args && Array.isArray(entry.args)) {
|
||||
message.args = entry.args;
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Process collected exceptions
|
||||
for (const exceptionDetails of collectedExceptions) {
|
||||
const exception: ConsoleException = {
|
||||
timestamp: Date.now(),
|
||||
text:
|
||||
exceptionDetails.text ||
|
||||
exceptionDetails.exception?.description ||
|
||||
'Unknown exception',
|
||||
url: exceptionDetails.url,
|
||||
lineNumber: exceptionDetails.lineNumber,
|
||||
columnNumber: exceptionDetails.columnNumber,
|
||||
};
|
||||
|
||||
if (exceptionDetails.stackTrace) {
|
||||
exception.stackTrace = exceptionDetails.stackTrace;
|
||||
}
|
||||
|
||||
exceptions.push(exception);
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
chrome.debugger.onEvent.removeListener(eventListener);
|
||||
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.disable');
|
||||
} catch (e) {
|
||||
console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Log.disable');
|
||||
} catch (e) {
|
||||
console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
} catch (e) {
|
||||
console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
// Sort messages by timestamp
|
||||
messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
exceptions.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Console capture completed for tab ${tabId}. ${messages.length} messages, ${exceptions.length} exceptions captured.`,
|
||||
tabId,
|
||||
tabUrl: tab.url || '',
|
||||
tabTitle: tab.title || '',
|
||||
captureStartTime: startTime,
|
||||
captureEndTime: endTime,
|
||||
totalDurationMs: endTime - startTime,
|
||||
messages,
|
||||
exceptions,
|
||||
messageCount: messages.length,
|
||||
exceptionCount: exceptions.length,
|
||||
messageLimitReached: limitReached,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const consoleTool = new ConsoleTool();
|
@@ -0,0 +1,232 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import {
|
||||
parseISO,
|
||||
subDays,
|
||||
subWeeks,
|
||||
subMonths,
|
||||
subYears,
|
||||
startOfToday,
|
||||
startOfYesterday,
|
||||
isValid,
|
||||
format,
|
||||
} from 'date-fns';
|
||||
|
||||
interface HistoryToolParams {
|
||||
text?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
maxResults?: number;
|
||||
excludeCurrentTabs?: boolean;
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
lastVisitTime?: number; // Timestamp in milliseconds
|
||||
visitCount?: number;
|
||||
typedCount?: number;
|
||||
}
|
||||
|
||||
interface HistoryResult {
|
||||
items: HistoryItem[];
|
||||
totalCount: number;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
startTimeFormatted: string;
|
||||
endTimeFormatted: string;
|
||||
};
|
||||
query?: string;
|
||||
}
|
||||
|
||||
class HistoryTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.HISTORY;
|
||||
private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Parse a date string into milliseconds since epoch.
|
||||
* Returns null if the date string is invalid.
|
||||
* Supports:
|
||||
* - ISO date strings (e.g., "2023-10-31", "2023-10-31T14:30:00.000Z")
|
||||
* - Relative times: "1 day ago", "2 weeks ago", "3 months ago", "1 year ago"
|
||||
* - Special keywords: "now", "today", "yesterday"
|
||||
*/
|
||||
private parseDateString(dateStr: string | undefined | null): number | null {
|
||||
if (!dateStr) {
|
||||
// If an empty or null string is passed, it might mean "no specific date",
|
||||
// depending on how you want to treat it. Returning null is safer.
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lowerDateStr = dateStr.toLowerCase().trim();
|
||||
|
||||
if (lowerDateStr === 'now') return now.getTime();
|
||||
if (lowerDateStr === 'today') return startOfToday().getTime();
|
||||
if (lowerDateStr === 'yesterday') return startOfYesterday().getTime();
|
||||
|
||||
const relativeMatch = lowerDateStr.match(
|
||||
/^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/,
|
||||
);
|
||||
if (relativeMatch) {
|
||||
const amount = parseInt(relativeMatch[1], 10);
|
||||
const unit = relativeMatch[2];
|
||||
let resultDate: Date;
|
||||
if (unit.startsWith('day')) resultDate = subDays(now, amount);
|
||||
else if (unit.startsWith('week')) resultDate = subWeeks(now, amount);
|
||||
else if (unit.startsWith('month')) resultDate = subMonths(now, amount);
|
||||
else if (unit.startsWith('year')) resultDate = subYears(now, amount);
|
||||
else return null; // Should not happen with the regex
|
||||
return resultDate.getTime();
|
||||
}
|
||||
|
||||
// Try parsing as ISO or other common date string formats
|
||||
// Native Date constructor can be unreliable for non-standard formats.
|
||||
// date-fns' parseISO is good for ISO 8601.
|
||||
// For other formats, date-fns' parse function is more flexible.
|
||||
let parsedDate = parseISO(dateStr); // Handles "2023-10-31" or "2023-10-31T10:00:00"
|
||||
if (isValid(parsedDate)) {
|
||||
return parsedDate.getTime();
|
||||
}
|
||||
|
||||
// Fallback to new Date() for other potential formats, but with caution
|
||||
parsedDate = new Date(dateStr);
|
||||
if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) {
|
||||
return parsedDate.getTime();
|
||||
}
|
||||
|
||||
console.warn(`Could not parse date string: ${dateStr}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as a human-readable date string
|
||||
*/
|
||||
private formatDate(timestamp: number): string {
|
||||
// Using date-fns for consistent and potentially localized formatting
|
||||
return format(timestamp, 'yyyy-MM-dd HH:mm:ss');
|
||||
}
|
||||
|
||||
async execute(args: HistoryToolParams): Promise<ToolResult> {
|
||||
try {
|
||||
console.log('Executing HistoryTool with args:', args);
|
||||
|
||||
const {
|
||||
text = '',
|
||||
maxResults = 100, // Default to 100 results
|
||||
excludeCurrentTabs = false,
|
||||
} = args;
|
||||
|
||||
const now = Date.now();
|
||||
let startTimeMs: number;
|
||||
let endTimeMs: number;
|
||||
|
||||
// Parse startTime
|
||||
if (args.startTime) {
|
||||
const parsedStart = this.parseDateString(args.startTime);
|
||||
if (parsedStart === null) {
|
||||
return createErrorResponse(
|
||||
`Invalid format for start time: "${args.startTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
|
||||
);
|
||||
}
|
||||
startTimeMs = parsedStart;
|
||||
} else {
|
||||
// Default to 24 hours ago if startTime is not provided
|
||||
startTimeMs = now - HistoryTool.ONE_DAY_MS;
|
||||
}
|
||||
|
||||
// Parse endTime
|
||||
if (args.endTime) {
|
||||
const parsedEnd = this.parseDateString(args.endTime);
|
||||
if (parsedEnd === null) {
|
||||
return createErrorResponse(
|
||||
`Invalid format for end time: "${args.endTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
|
||||
);
|
||||
}
|
||||
endTimeMs = parsedEnd;
|
||||
} else {
|
||||
// Default to current time if endTime is not provided
|
||||
endTimeMs = now;
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
if (startTimeMs > endTimeMs) {
|
||||
return createErrorResponse('Start time cannot be after end time.');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query "${text}"`,
|
||||
);
|
||||
|
||||
const historyItems = await chrome.history.search({
|
||||
text,
|
||||
startTime: startTimeMs,
|
||||
endTime: endTimeMs,
|
||||
maxResults,
|
||||
});
|
||||
|
||||
console.log(`Found ${historyItems.length} history items before filtering current tabs.`);
|
||||
|
||||
let filteredItems = historyItems;
|
||||
if (excludeCurrentTabs && historyItems.length > 0) {
|
||||
const currentTabs = await chrome.tabs.query({});
|
||||
const openUrls = new Set<string>();
|
||||
|
||||
currentTabs.forEach((tab) => {
|
||||
if (tab.url) {
|
||||
openUrls.add(tab.url);
|
||||
}
|
||||
});
|
||||
|
||||
if (openUrls.size > 0) {
|
||||
filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url)));
|
||||
console.log(
|
||||
`Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result: HistoryResult = {
|
||||
items: filteredItems.map((item) => ({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
lastVisitTime: item.lastVisitTime,
|
||||
visitCount: item.visitCount,
|
||||
typedCount: item.typedCount,
|
||||
})),
|
||||
totalCount: filteredItems.length,
|
||||
timeRange: {
|
||||
startTime: startTimeMs,
|
||||
endTime: endTimeMs,
|
||||
startTimeFormatted: this.formatDate(startTimeMs),
|
||||
endTimeFormatted: this.formatDate(endTimeMs),
|
||||
},
|
||||
};
|
||||
|
||||
if (text) {
|
||||
result.query = text;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in HistoryTool.execute:', error);
|
||||
return createErrorResponse(
|
||||
`Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const historyTool = new HistoryTool();
|
@@ -0,0 +1,14 @@
|
||||
export { navigateTool, closeTabsTool, goBackOrForwardTool } from './common';
|
||||
export { windowTool } from './window';
|
||||
export { vectorSearchTabsContentTool as searchTabsContentTool } from './vector-search';
|
||||
export { screenshotTool } from './screenshot';
|
||||
export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher';
|
||||
export { clickTool, fillTool } from './interaction';
|
||||
export { networkRequestTool } from './network-request';
|
||||
export { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';
|
||||
export { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';
|
||||
export { keyboardTool } from './keyboard';
|
||||
export { historyTool } from './history';
|
||||
export { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark';
|
||||
export { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script';
|
||||
export { consoleTool } from './console';
|
@@ -0,0 +1,229 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { ExecutionWorld } from '@/common/constants';
|
||||
|
||||
interface InjectScriptParam {
|
||||
url?: string;
|
||||
}
|
||||
interface ScriptConfig {
|
||||
type: ExecutionWorld;
|
||||
jsScript: string;
|
||||
}
|
||||
|
||||
interface SendCommandToInjectScriptToolParam {
|
||||
tabId?: number;
|
||||
eventName: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
const injectedTabs = new Map();
|
||||
class InjectScriptTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.INJECT_SCRIPT;
|
||||
async execute(args: InjectScriptParam & ScriptConfig): Promise<ToolResult> {
|
||||
try {
|
||||
const { url, type, jsScript } = args;
|
||||
let tab;
|
||||
|
||||
if (!type || !jsScript) {
|
||||
return createErrorResponse('Param [type] and [jsScript] is required');
|
||||
}
|
||||
|
||||
if (url) {
|
||||
// If URL is provided, check if it's already open
|
||||
console.log(`Checking if URL is already open: ${url}`);
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
|
||||
// Find tab with matching URL
|
||||
const matchingTabs = allTabs.filter((t) => {
|
||||
// Normalize URLs for comparison (remove trailing slashes)
|
||||
const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
|
||||
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
return tabUrl === targetUrl;
|
||||
});
|
||||
|
||||
if (matchingTabs.length > 0) {
|
||||
// Use existing tab
|
||||
tab = matchingTabs[0];
|
||||
console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
|
||||
} else {
|
||||
// Create new tab with the URL
|
||||
console.log(`No existing tab found with URL: ${url}, creating new tab`);
|
||||
tab = await chrome.tabs.create({ url, active: true });
|
||||
|
||||
// Wait for page to load
|
||||
console.log('Waiting for page to load...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
} else {
|
||||
// Use active tab
|
||||
const tabs = await chrome.tabs.query({ active: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
tab = tabs[0];
|
||||
}
|
||||
|
||||
if (!tab.id) {
|
||||
return createErrorResponse('Tab has no ID');
|
||||
}
|
||||
|
||||
// Make sure tab is active
|
||||
await chrome.tabs.update(tab.id, { active: true });
|
||||
|
||||
const res = await handleInject(tab.id!, { ...args });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(res),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in InjectScriptTool.execute:', error);
|
||||
return createErrorResponse(
|
||||
`Inject script error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SendCommandToInjectScriptTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT;
|
||||
async execute(args: SendCommandToInjectScriptToolParam): Promise<ToolResult> {
|
||||
try {
|
||||
const { tabId, eventName, payload } = args;
|
||||
|
||||
if (!eventName) {
|
||||
return createErrorResponse('Param [eventName] is required');
|
||||
}
|
||||
|
||||
if (tabId) {
|
||||
const tabExists = await isTabExists(tabId);
|
||||
if (!tabExists) {
|
||||
return createErrorResponse('The tab:[tabId] is not exists');
|
||||
}
|
||||
}
|
||||
|
||||
let finalTabId: number | undefined = tabId;
|
||||
|
||||
if (finalTabId === undefined) {
|
||||
// Use active tab
|
||||
const tabs = await chrome.tabs.query({ active: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
finalTabId = tabs[0].id;
|
||||
}
|
||||
|
||||
if (!finalTabId) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
|
||||
if (!injectedTabs.has(finalTabId)) {
|
||||
throw new Error('No script injected in this tab.');
|
||||
}
|
||||
const result = await chrome.tabs.sendMessage(finalTabId, {
|
||||
action: eventName,
|
||||
payload,
|
||||
targetWorld: injectedTabs.get(finalTabId).type, // The bridge uses this to decide whether to forward to MAIN world.
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in InjectScriptTool.execute:', error);
|
||||
return createErrorResponse(
|
||||
`Inject script error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isTabExists(tabId: number) {
|
||||
try {
|
||||
await chrome.tabs.get(tabId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// An error is thrown if the tab doesn't exist.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handles the injection of user scripts into a specific tab.
|
||||
* @param {number} tabId - The ID of the target tab.
|
||||
* @param {object} scriptConfig - The configuration object for the script.
|
||||
*/
|
||||
async function handleInject(tabId: number, scriptConfig: ScriptConfig) {
|
||||
if (injectedTabs.has(tabId)) {
|
||||
// If already injected, run cleanup first to ensure a clean state.
|
||||
console.log(`Tab ${tabId} already has injections. Cleaning up first.`);
|
||||
await handleCleanup(tabId);
|
||||
}
|
||||
const { type, jsScript } = scriptConfig;
|
||||
const hasMain = type === ExecutionWorld.MAIN;
|
||||
|
||||
if (hasMain) {
|
||||
// The bridge is essential for MAIN world communication and cleanup.
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['inject-scripts/inject-bridge.js'],
|
||||
world: ExecutionWorld.ISOLATED,
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (code) => new Function(code)(),
|
||||
args: [jsScript],
|
||||
world: ExecutionWorld.MAIN,
|
||||
});
|
||||
} else {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (code) => new Function(code)(),
|
||||
args: [jsScript],
|
||||
world: ExecutionWorld.ISOLATED,
|
||||
});
|
||||
}
|
||||
injectedTabs.set(tabId, scriptConfig);
|
||||
console.log(`Scripts successfully injected into tab ${tabId}.`);
|
||||
return { injected: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Triggers the cleanup process in a specific tab.
|
||||
* @param {number} tabId - The ID of the target tab.
|
||||
*/
|
||||
async function handleCleanup(tabId: number) {
|
||||
if (!injectedTabs.has(tabId)) return;
|
||||
// Send cleanup signal. The bridge will forward it to the MAIN world.
|
||||
chrome.tabs
|
||||
.sendMessage(tabId, { type: 'chrome-mcp:cleanup' })
|
||||
.catch((err) =>
|
||||
console.warn(`Could not send cleanup message to tab ${tabId}. It might have been closed.`),
|
||||
);
|
||||
|
||||
injectedTabs.delete(tabId);
|
||||
console.log(`Cleanup signal sent to tab ${tabId}. State cleared.`);
|
||||
}
|
||||
|
||||
export const injectScriptTool = new InjectScriptTool();
|
||||
export const sendCommandToInjectScriptTool = new SendCommandToInjectScriptTool();
|
||||
|
||||
// --- Automatic Cleanup Listeners ---
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (injectedTabs.has(tabId)) {
|
||||
console.log(`Tab ${tabId} closed. Cleaning up state.`);
|
||||
injectedTabs.delete(tabId);
|
||||
}
|
||||
});
|
@@ -0,0 +1,167 @@
|
||||
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';
|
||||
|
||||
interface Coordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ClickToolParams {
|
||||
selector?: string; // CSS selector for the element to click
|
||||
coordinates?: Coordinates; // Coordinates to click at (x, y relative to viewport)
|
||||
waitForNavigation?: boolean; // Whether to wait for navigation to complete after click
|
||||
timeout?: number; // Timeout in milliseconds for waiting for the element or navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for clicking elements on web pages
|
||||
*/
|
||||
class ClickTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.CLICK;
|
||||
|
||||
/**
|
||||
* Execute click operation
|
||||
*/
|
||||
async execute(args: ClickToolParams): Promise<ToolResult> {
|
||||
const {
|
||||
selector,
|
||||
coordinates,
|
||||
waitForNavigation = false,
|
||||
timeout = TIMEOUTS.DEFAULT_WAIT * 5,
|
||||
} = args;
|
||||
|
||||
console.log(`Starting click operation with options:`, args);
|
||||
|
||||
if (!selector && !coordinates) {
|
||||
return createErrorResponse(
|
||||
ERROR_MESSAGES.INVALID_PARAMETERS + ': Either selector or coordinates must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
|
||||
}
|
||||
|
||||
const tab = tabs[0];
|
||||
if (!tab.id) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
|
||||
}
|
||||
|
||||
await this.injectContentScript(tab.id, ['inject-scripts/click-helper.js']);
|
||||
|
||||
// Send click message to content script
|
||||
const result = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.CLICK_ELEMENT,
|
||||
selector,
|
||||
coordinates,
|
||||
waitForNavigation,
|
||||
timeout,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: result.message || 'Click operation successful',
|
||||
elementInfo: result.elementInfo,
|
||||
navigationOccurred: result.navigationOccurred,
|
||||
clickMethod: coordinates ? 'coordinates' : 'selector',
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in click operation:', error);
|
||||
return createErrorResponse(
|
||||
`Error performing click: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clickTool = new ClickTool();
|
||||
|
||||
interface FillToolParams {
|
||||
selector: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for filling form elements on web pages
|
||||
*/
|
||||
class FillTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.FILL;
|
||||
|
||||
/**
|
||||
* Execute fill operation
|
||||
*/
|
||||
async execute(args: FillToolParams): Promise<ToolResult> {
|
||||
const { selector, value } = args;
|
||||
|
||||
console.log(`Starting fill operation with options:`, args);
|
||||
|
||||
if (!selector) {
|
||||
return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Selector must be provided');
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Value must be provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
|
||||
}
|
||||
|
||||
const tab = tabs[0];
|
||||
if (!tab.id) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
|
||||
}
|
||||
|
||||
await this.injectContentScript(tab.id, ['inject-scripts/fill-helper.js']);
|
||||
|
||||
// Send fill message to content script
|
||||
const result = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.FILL_ELEMENT,
|
||||
selector,
|
||||
value,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return createErrorResponse(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: result.message || 'Fill operation successful',
|
||||
elementInfo: result.elementInfo,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in fill operation:', error);
|
||||
return createErrorResponse(
|
||||
`Error filling element: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fillTool = new FillTool();
|
@@ -0,0 +1,82 @@
|
||||
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';
|
||||
|
||||
interface KeyboardToolParams {
|
||||
keys: string; // Required: string representing keys or key combinations to simulate (e.g., "Enter", "Ctrl+C")
|
||||
selector?: string; // Optional: CSS selector for target element to send keyboard events to
|
||||
delay?: number; // Optional: delay between keystrokes in milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for simulating keyboard input on web pages
|
||||
*/
|
||||
class KeyboardTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.KEYBOARD;
|
||||
|
||||
/**
|
||||
* Execute keyboard operation
|
||||
*/
|
||||
async execute(args: KeyboardToolParams): Promise<ToolResult> {
|
||||
const { keys, selector, delay = TIMEOUTS.KEYBOARD_DELAY } = args;
|
||||
|
||||
console.log(`Starting keyboard operation with options:`, args);
|
||||
|
||||
if (!keys) {
|
||||
return createErrorResponse(
|
||||
ERROR_MESSAGES.INVALID_PARAMETERS + ': Keys parameter must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
|
||||
}
|
||||
|
||||
const tab = tabs[0];
|
||||
if (!tab.id) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
|
||||
}
|
||||
|
||||
await this.injectContentScript(tab.id, ['inject-scripts/keyboard-helper.js']);
|
||||
|
||||
// Send keyboard simulation message to content script
|
||||
const result = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.SIMULATE_KEYBOARD,
|
||||
keys,
|
||||
selector,
|
||||
delay,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return createErrorResponse(result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: result.message || 'Keyboard operation successful',
|
||||
targetElement: result.targetElement,
|
||||
results: result.results,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in keyboard operation:', error);
|
||||
return createErrorResponse(
|
||||
`Error simulating keyboard events: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const keyboardTool = new KeyboardTool();
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,988 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { LIMITS, NETWORK_FILTERS } from '@/common/constants';
|
||||
|
||||
// Static resource file extensions
|
||||
const STATIC_RESOURCE_EXTENSIONS = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.svg',
|
||||
'.webp',
|
||||
'.ico',
|
||||
'.bmp', // Images
|
||||
'.css',
|
||||
'.scss',
|
||||
'.less', // Styles
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx', // Scripts
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
'.eot',
|
||||
'.otf', // Fonts
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.wmv',
|
||||
'.flv',
|
||||
'.ogg',
|
||||
'.wav', // Media
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx', // Documents
|
||||
];
|
||||
|
||||
// Ad and analytics domain list
|
||||
const AD_ANALYTICS_DOMAINS = NETWORK_FILTERS.EXCLUDED_DOMAINS;
|
||||
|
||||
interface NetworkCaptureStartToolParams {
|
||||
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
|
||||
maxCaptureTime?: number; // Maximum capture time (milliseconds)
|
||||
inactivityTimeout?: number; // Inactivity timeout (milliseconds)
|
||||
includeStatic?: boolean; // Whether to include static resources
|
||||
}
|
||||
|
||||
interface NetworkRequestInfo {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
type: string;
|
||||
requestTime: number;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBody?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
responseTime?: number;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
responseSize?: number;
|
||||
responseType?: string;
|
||||
responseBody?: string;
|
||||
errorText?: string;
|
||||
specificRequestHeaders?: Record<string, string>;
|
||||
specificResponseHeaders?: Record<string, string>;
|
||||
mimeType?: string; // Response MIME type
|
||||
}
|
||||
|
||||
interface CaptureInfo {
|
||||
tabId: number;
|
||||
tabUrl: string;
|
||||
tabTitle: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
requests: Record<string, NetworkRequestInfo>;
|
||||
maxCaptureTime: number;
|
||||
inactivityTimeout: number;
|
||||
includeStatic: boolean;
|
||||
limitReached?: boolean; // Whether request count limit is reached
|
||||
}
|
||||
|
||||
/**
|
||||
* Network Capture Start Tool V2 - Uses Chrome webRequest API to start capturing network requests
|
||||
*/
|
||||
class NetworkCaptureStartTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START;
|
||||
public static instance: NetworkCaptureStartTool | null = null;
|
||||
public captureData: Map<number, CaptureInfo> = new Map(); // tabId -> capture data
|
||||
private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
|
||||
private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
|
||||
private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last activity
|
||||
private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests
|
||||
public static MAX_REQUESTS_PER_CAPTURE = LIMITS.MAX_NETWORK_REQUESTS; // Maximum capture request count
|
||||
private listeners: { [key: string]: (details: any) => void } = {};
|
||||
|
||||
// Static resource MIME types list (for filtering)
|
||||
private static STATIC_MIME_TYPES_TO_FILTER = [
|
||||
'image/', // All image types
|
||||
'font/', // All font types
|
||||
'audio/', // All audio types
|
||||
'video/', // All video types
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/javascript',
|
||||
'application/x-javascript',
|
||||
'application/pdf',
|
||||
'application/zip',
|
||||
'application/octet-stream', // Usually for downloads or generic binary data
|
||||
];
|
||||
|
||||
// API response MIME types list (these types are usually not filtered)
|
||||
private static API_MIME_TYPES = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'application/x-www-form-urlencoded',
|
||||
'application/graphql',
|
||||
'application/grpc',
|
||||
'application/protobuf',
|
||||
'application/x-protobuf',
|
||||
'application/x-json',
|
||||
'application/ld+json',
|
||||
'application/problem+json',
|
||||
'application/problem+xml',
|
||||
'application/soap+xml',
|
||||
'application/vnd.api+json',
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (NetworkCaptureStartTool.instance) {
|
||||
return NetworkCaptureStartTool.instance;
|
||||
}
|
||||
NetworkCaptureStartTool.instance = this;
|
||||
|
||||
// Listen for tab close events
|
||||
chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
|
||||
// Listen for tab creation events
|
||||
chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tab close events
|
||||
*/
|
||||
private handleTabRemoved(tabId: number) {
|
||||
if (this.captureData.has(tabId)) {
|
||||
console.log(`NetworkCaptureV2: Tab ${tabId} was closed, cleaning up resources.`);
|
||||
this.cleanupCapture(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tab creation events
|
||||
* If a new tab is opened from a tab being captured, automatically start capturing the new tab's requests
|
||||
*/
|
||||
private async handleTabCreated(tab: chrome.tabs.Tab) {
|
||||
try {
|
||||
// Check if there are any tabs currently capturing
|
||||
if (this.captureData.size === 0) return;
|
||||
|
||||
// Get the openerTabId of the new tab (ID of the tab that opened this tab)
|
||||
const openerTabId = tab.openerTabId;
|
||||
if (!openerTabId) return;
|
||||
|
||||
// Check if the opener tab is currently capturing
|
||||
if (!this.captureData.has(openerTabId)) return;
|
||||
|
||||
// Get the new tab's ID
|
||||
const newTabId = tab.id;
|
||||
if (!newTabId) return;
|
||||
|
||||
console.log(
|
||||
`NetworkCaptureV2: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,
|
||||
);
|
||||
|
||||
// Get the opener tab's capture settings
|
||||
const openerCaptureInfo = this.captureData.get(openerTabId);
|
||||
if (!openerCaptureInfo) return;
|
||||
|
||||
// Wait a short time to ensure the tab is ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Start capturing requests for the new tab
|
||||
await this.startCaptureForTab(newTabId, {
|
||||
maxCaptureTime: openerCaptureInfo.maxCaptureTime,
|
||||
inactivityTimeout: openerCaptureInfo.inactivityTimeout,
|
||||
includeStatic: openerCaptureInfo.includeStatic,
|
||||
});
|
||||
|
||||
console.log(`NetworkCaptureV2: Successfully extended capture to new tab ${newTabId}`);
|
||||
} catch (error) {
|
||||
console.error(`NetworkCaptureV2: Error extending capture to new tab:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a request should be filtered (based on URL)
|
||||
*/
|
||||
private shouldFilterRequest(url: string, includeStatic: boolean): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Check if it's an ad or analytics domain
|
||||
if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) {
|
||||
console.log(`NetworkCaptureV2: Filtering ad/analytics domain: ${urlObj.hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not including static resources, check extensions
|
||||
if (!includeStatic) {
|
||||
const path = urlObj.pathname.toLowerCase();
|
||||
if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
|
||||
console.log(`NetworkCaptureV2: Filtering static resource by extension: ${path}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('NetworkCaptureV2: Error filtering URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter based on MIME type
|
||||
*/
|
||||
private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {
|
||||
if (!mimeType) return false;
|
||||
|
||||
// Always keep API response types
|
||||
if (NetworkCaptureStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If not including static resources, filter out static resource MIME types
|
||||
if (!includeStatic) {
|
||||
// Filter static resource MIME types
|
||||
if (
|
||||
NetworkCaptureStartTool.STATIC_MIME_TYPES_TO_FILTER.some((type) =>
|
||||
mimeType.startsWith(type),
|
||||
)
|
||||
) {
|
||||
console.log(`NetworkCaptureV2: Filtering static resource by MIME type: ${mimeType}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter all MIME types starting with text/ (except those already in API_MIME_TYPES)
|
||||
if (mimeType.startsWith('text/')) {
|
||||
console.log(`NetworkCaptureV2: Filtering text response: ${mimeType}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity time and reset inactivity timer
|
||||
*/
|
||||
private updateLastActivityTime(tabId: number): void {
|
||||
const captureInfo = this.captureData.get(tabId);
|
||||
if (!captureInfo) return;
|
||||
|
||||
this.lastActivityTime.set(tabId, Date.now());
|
||||
|
||||
// Reset inactivity timer
|
||||
if (this.inactivityTimers.has(tabId)) {
|
||||
clearTimeout(this.inactivityTimers.get(tabId)!);
|
||||
}
|
||||
|
||||
if (captureInfo.inactivityTimeout > 0) {
|
||||
this.inactivityTimers.set(
|
||||
tabId,
|
||||
setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for inactivity
|
||||
*/
|
||||
private checkInactivity(tabId: number): void {
|
||||
const captureInfo = this.captureData.get(tabId);
|
||||
if (!captureInfo) return;
|
||||
|
||||
const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime;
|
||||
const now = Date.now();
|
||||
const inactiveTime = now - lastActivity;
|
||||
|
||||
if (inactiveTime >= captureInfo.inactivityTimeout) {
|
||||
console.log(
|
||||
`NetworkCaptureV2: No activity for ${inactiveTime}ms, stopping capture for tab ${tabId}`,
|
||||
);
|
||||
this.stopCaptureByInactivity(tabId);
|
||||
} else {
|
||||
// If inactivity time hasn't been reached yet, continue checking
|
||||
const remainingTime = captureInfo.inactivityTimeout - inactiveTime;
|
||||
this.inactivityTimers.set(
|
||||
tabId,
|
||||
setTimeout(() => this.checkInactivity(tabId), remainingTime),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capture due to inactivity
|
||||
*/
|
||||
private async stopCaptureByInactivity(tabId: number): Promise<void> {
|
||||
const captureInfo = this.captureData.get(tabId);
|
||||
if (!captureInfo) return;
|
||||
|
||||
console.log(`NetworkCaptureV2: Stopping capture due to inactivity for tab ${tabId}`);
|
||||
await this.stopCapture(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up capture resources
|
||||
*/
|
||||
private cleanupCapture(tabId: number): void {
|
||||
// Clear timers
|
||||
if (this.captureTimers.has(tabId)) {
|
||||
clearTimeout(this.captureTimers.get(tabId)!);
|
||||
this.captureTimers.delete(tabId);
|
||||
}
|
||||
|
||||
if (this.inactivityTimers.has(tabId)) {
|
||||
clearTimeout(this.inactivityTimers.get(tabId)!);
|
||||
this.inactivityTimers.delete(tabId);
|
||||
}
|
||||
|
||||
// Remove data
|
||||
this.lastActivityTime.delete(tabId);
|
||||
this.captureData.delete(tabId);
|
||||
this.requestCounters.delete(tabId);
|
||||
|
||||
console.log(`NetworkCaptureV2: Cleaned up all resources for tab ${tabId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up request listeners
|
||||
*/
|
||||
private setupListeners(): void {
|
||||
// Before request is sent
|
||||
this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => {
|
||||
const captureInfo = this.captureData.get(details.tabId);
|
||||
if (!captureInfo) return;
|
||||
|
||||
if (this.shouldFilterRequest(details.url, captureInfo.includeStatic)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCount = this.requestCounters.get(details.tabId) || 0;
|
||||
if (currentCount >= NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE) {
|
||||
console.log(
|
||||
`NetworkCaptureV2: Request limit (${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${details.tabId}, ignoring new request: ${details.url}`,
|
||||
);
|
||||
captureInfo.limitReached = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.requestCounters.set(details.tabId, currentCount + 1);
|
||||
this.updateLastActivityTime(details.tabId);
|
||||
|
||||
if (!captureInfo.requests[details.requestId]) {
|
||||
captureInfo.requests[details.requestId] = {
|
||||
requestId: details.requestId,
|
||||
url: details.url,
|
||||
method: details.method,
|
||||
type: details.type,
|
||||
requestTime: details.timeStamp,
|
||||
};
|
||||
|
||||
if (details.requestBody) {
|
||||
const requestBody = this.processRequestBody(details.requestBody);
|
||||
if (requestBody) {
|
||||
captureInfo.requests[details.requestId].requestBody = requestBody;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`NetworkCaptureV2: Captured request ${currentCount + 1}/${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE} for tab ${details.tabId}: ${details.method} ${details.url}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Send request headers
|
||||
this.listeners.onSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => {
|
||||
const captureInfo = this.captureData.get(details.tabId);
|
||||
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
||||
|
||||
if (details.requestHeaders) {
|
||||
const headers: Record<string, string> = {};
|
||||
details.requestHeaders.forEach((header) => {
|
||||
headers[header.name] = header.value || '';
|
||||
});
|
||||
captureInfo.requests[details.requestId].requestHeaders = headers;
|
||||
}
|
||||
};
|
||||
|
||||
// Receive response headers
|
||||
this.listeners.onHeadersReceived = (details: chrome.webRequest.WebResponseHeadersDetails) => {
|
||||
const captureInfo = this.captureData.get(details.tabId);
|
||||
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
||||
|
||||
const requestInfo = captureInfo.requests[details.requestId];
|
||||
|
||||
requestInfo.status = details.statusCode;
|
||||
requestInfo.statusText = details.statusLine;
|
||||
requestInfo.responseTime = details.timeStamp;
|
||||
requestInfo.mimeType = details.responseHeaders?.find(
|
||||
(h) => h.name.toLowerCase() === 'content-type',
|
||||
)?.value;
|
||||
|
||||
// Secondary filtering based on MIME type
|
||||
if (
|
||||
requestInfo.mimeType &&
|
||||
this.shouldFilterByMimeType(requestInfo.mimeType, captureInfo.includeStatic)
|
||||
) {
|
||||
delete captureInfo.requests[details.requestId];
|
||||
|
||||
const currentCount = this.requestCounters.get(details.tabId) || 0;
|
||||
if (currentCount > 0) {
|
||||
this.requestCounters.set(details.tabId, currentCount - 1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`NetworkCaptureV2: Filtered request by MIME type (${requestInfo.mimeType}): ${requestInfo.url}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.responseHeaders) {
|
||||
const headers: Record<string, string> = {};
|
||||
details.responseHeaders.forEach((header) => {
|
||||
headers[header.name] = header.value || '';
|
||||
});
|
||||
requestInfo.responseHeaders = headers;
|
||||
}
|
||||
|
||||
this.updateLastActivityTime(details.tabId);
|
||||
};
|
||||
|
||||
// Request completed
|
||||
this.listeners.onCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => {
|
||||
const captureInfo = this.captureData.get(details.tabId);
|
||||
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
||||
|
||||
const requestInfo = captureInfo.requests[details.requestId];
|
||||
if ('responseSize' in details) {
|
||||
requestInfo.responseSize = details.fromCache ? 0 : (details as any).responseSize;
|
||||
}
|
||||
|
||||
this.updateLastActivityTime(details.tabId);
|
||||
};
|
||||
|
||||
// Request failed
|
||||
this.listeners.onErrorOccurred = (details: chrome.webRequest.WebResponseErrorDetails) => {
|
||||
const captureInfo = this.captureData.get(details.tabId);
|
||||
if (!captureInfo || !captureInfo.requests[details.requestId]) return;
|
||||
|
||||
const requestInfo = captureInfo.requests[details.requestId];
|
||||
requestInfo.errorText = details.error;
|
||||
|
||||
this.updateLastActivityTime(details.tabId);
|
||||
};
|
||||
|
||||
// Register all listeners
|
||||
chrome.webRequest.onBeforeRequest.addListener(
|
||||
this.listeners.onBeforeRequest,
|
||||
{ urls: ['<all_urls>'] },
|
||||
['requestBody'],
|
||||
);
|
||||
|
||||
chrome.webRequest.onSendHeaders.addListener(
|
||||
this.listeners.onSendHeaders,
|
||||
{ urls: ['<all_urls>'] },
|
||||
['requestHeaders'],
|
||||
);
|
||||
|
||||
chrome.webRequest.onHeadersReceived.addListener(
|
||||
this.listeners.onHeadersReceived,
|
||||
{ urls: ['<all_urls>'] },
|
||||
['responseHeaders'],
|
||||
);
|
||||
|
||||
chrome.webRequest.onCompleted.addListener(this.listeners.onCompleted, { urls: ['<all_urls>'] });
|
||||
|
||||
chrome.webRequest.onErrorOccurred.addListener(this.listeners.onErrorOccurred, {
|
||||
urls: ['<all_urls>'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all request listeners
|
||||
* Only remove listeners when all tab captures have stopped
|
||||
*/
|
||||
private removeListeners(): void {
|
||||
// Don't remove listeners if there are still tabs being captured
|
||||
if (this.captureData.size > 0) {
|
||||
console.log(
|
||||
`NetworkCaptureV2: Still capturing on ${this.captureData.size} tabs, not removing listeners.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`NetworkCaptureV2: No more active captures, removing all listeners.`);
|
||||
|
||||
if (this.listeners.onBeforeRequest) {
|
||||
chrome.webRequest.onBeforeRequest.removeListener(this.listeners.onBeforeRequest);
|
||||
}
|
||||
|
||||
if (this.listeners.onSendHeaders) {
|
||||
chrome.webRequest.onSendHeaders.removeListener(this.listeners.onSendHeaders);
|
||||
}
|
||||
|
||||
if (this.listeners.onHeadersReceived) {
|
||||
chrome.webRequest.onHeadersReceived.removeListener(this.listeners.onHeadersReceived);
|
||||
}
|
||||
|
||||
if (this.listeners.onCompleted) {
|
||||
chrome.webRequest.onCompleted.removeListener(this.listeners.onCompleted);
|
||||
}
|
||||
|
||||
if (this.listeners.onErrorOccurred) {
|
||||
chrome.webRequest.onErrorOccurred.removeListener(this.listeners.onErrorOccurred);
|
||||
}
|
||||
|
||||
// Clear listener object
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process request body data
|
||||
*/
|
||||
private processRequestBody(requestBody: chrome.webRequest.WebRequestBody): string | undefined {
|
||||
if (requestBody.raw && requestBody.raw.length > 0) {
|
||||
return '[Binary data]';
|
||||
} else if (requestBody.formData) {
|
||||
return JSON.stringify(requestBody.formData);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start network request capture for specified tab
|
||||
* @param tabId Tab ID
|
||||
* @param options Capture options
|
||||
*/
|
||||
private async startCaptureForTab(
|
||||
tabId: number,
|
||||
options: {
|
||||
maxCaptureTime: number;
|
||||
inactivityTimeout: number;
|
||||
includeStatic: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { maxCaptureTime, inactivityTimeout, includeStatic } = options;
|
||||
|
||||
// If already capturing, stop first
|
||||
if (this.captureData.has(tabId)) {
|
||||
console.log(
|
||||
`NetworkCaptureV2: Already capturing on tab ${tabId}. Stopping previous session.`,
|
||||
);
|
||||
await this.stopCapture(tabId);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get tab information
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
|
||||
// Initialize capture data
|
||||
this.captureData.set(tabId, {
|
||||
tabId: tabId,
|
||||
tabUrl: tab.url || '',
|
||||
tabTitle: tab.title || '',
|
||||
startTime: Date.now(),
|
||||
requests: {},
|
||||
maxCaptureTime,
|
||||
inactivityTimeout,
|
||||
includeStatic,
|
||||
limitReached: false,
|
||||
});
|
||||
|
||||
// Initialize request counter
|
||||
this.requestCounters.set(tabId, 0);
|
||||
|
||||
// Set up listeners
|
||||
this.setupListeners();
|
||||
|
||||
// Update last activity time
|
||||
this.updateLastActivityTime(tabId);
|
||||
|
||||
console.log(
|
||||
`NetworkCaptureV2: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,
|
||||
);
|
||||
|
||||
// Set maximum capture time
|
||||
if (maxCaptureTime > 0) {
|
||||
this.captureTimers.set(
|
||||
tabId,
|
||||
setTimeout(async () => {
|
||||
console.log(
|
||||
`NetworkCaptureV2: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,
|
||||
);
|
||||
await this.stopCapture(tabId);
|
||||
}, maxCaptureTime),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`NetworkCaptureV2: Error starting capture for tab ${tabId}:`, error);
|
||||
|
||||
// Clean up resources
|
||||
if (this.captureData.has(tabId)) {
|
||||
this.cleanupCapture(tabId);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capture
|
||||
* @param tabId Tab ID
|
||||
*/
|
||||
public async stopCapture(
|
||||
tabId: number,
|
||||
): Promise<{ success: boolean; message?: string; data?: any }> {
|
||||
const captureInfo = this.captureData.get(tabId);
|
||||
if (!captureInfo) {
|
||||
console.log(`NetworkCaptureV2: No capture in progress for tab ${tabId}`);
|
||||
return { success: false, message: `No capture in progress for tab ${tabId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
// Record end time
|
||||
captureInfo.endTime = Date.now();
|
||||
|
||||
// Extract common request and response headers
|
||||
const requestsArray = Object.values(captureInfo.requests);
|
||||
const commonRequestHeaders = this.analyzeCommonHeaders(requestsArray, 'requestHeaders');
|
||||
const commonResponseHeaders = this.analyzeCommonHeaders(requestsArray, 'responseHeaders');
|
||||
|
||||
// Process request data, remove common headers
|
||||
const processedRequests = requestsArray.map((req) => {
|
||||
const finalReq: NetworkRequestInfo = { ...req };
|
||||
|
||||
if (finalReq.requestHeaders) {
|
||||
finalReq.specificRequestHeaders = this.filterOutCommonHeaders(
|
||||
finalReq.requestHeaders,
|
||||
commonRequestHeaders,
|
||||
);
|
||||
delete finalReq.requestHeaders;
|
||||
} else {
|
||||
finalReq.specificRequestHeaders = {};
|
||||
}
|
||||
|
||||
if (finalReq.responseHeaders) {
|
||||
finalReq.specificResponseHeaders = this.filterOutCommonHeaders(
|
||||
finalReq.responseHeaders,
|
||||
commonResponseHeaders,
|
||||
);
|
||||
delete finalReq.responseHeaders;
|
||||
} else {
|
||||
finalReq.specificResponseHeaders = {};
|
||||
}
|
||||
|
||||
return finalReq;
|
||||
});
|
||||
|
||||
// Sort by time
|
||||
processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));
|
||||
|
||||
// Remove listeners
|
||||
this.removeListeners();
|
||||
|
||||
// Prepare result data
|
||||
const resultData = {
|
||||
captureStartTime: captureInfo.startTime,
|
||||
captureEndTime: captureInfo.endTime,
|
||||
totalDurationMs: captureInfo.endTime - captureInfo.startTime,
|
||||
settingsUsed: {
|
||||
maxCaptureTime: captureInfo.maxCaptureTime,
|
||||
inactivityTimeout: captureInfo.inactivityTimeout,
|
||||
includeStatic: captureInfo.includeStatic,
|
||||
maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,
|
||||
},
|
||||
commonRequestHeaders,
|
||||
commonResponseHeaders,
|
||||
requests: processedRequests,
|
||||
requestCount: processedRequests.length,
|
||||
totalRequestsReceived: this.requestCounters.get(tabId) || 0,
|
||||
requestLimitReached: captureInfo.limitReached || false,
|
||||
tabUrl: captureInfo.tabUrl,
|
||||
tabTitle: captureInfo.tabTitle,
|
||||
};
|
||||
|
||||
// Clean up resources
|
||||
this.cleanupCapture(tabId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: resultData,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`NetworkCaptureV2: Error stopping capture for tab ${tabId}:`, error);
|
||||
|
||||
// Ensure resources are cleaned up
|
||||
this.cleanupCapture(tabId);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Error stopping capture: ${error.message || String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze common request or response headers
|
||||
*/
|
||||
private analyzeCommonHeaders(
|
||||
requests: NetworkRequestInfo[],
|
||||
headerType: 'requestHeaders' | 'responseHeaders',
|
||||
): Record<string, string> {
|
||||
if (!requests || requests.length === 0) return {};
|
||||
|
||||
// Find headers that are included in all requests
|
||||
const commonHeaders: Record<string, string> = {};
|
||||
const firstRequestWithHeaders = requests.find(
|
||||
(req) => req[headerType] && Object.keys(req[headerType] || {}).length > 0,
|
||||
);
|
||||
|
||||
if (!firstRequestWithHeaders || !firstRequestWithHeaders[headerType]) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get all headers from the first request
|
||||
const headers = firstRequestWithHeaders[headerType] as Record<string, string>;
|
||||
const headerNames = Object.keys(headers);
|
||||
|
||||
// Check if each header exists in all requests with the same value
|
||||
for (const name of headerNames) {
|
||||
const value = headers[name];
|
||||
const isCommon = requests.every((req) => {
|
||||
const reqHeaders = req[headerType] as Record<string, string>;
|
||||
return reqHeaders && reqHeaders[name] === value;
|
||||
});
|
||||
|
||||
if (isCommon) {
|
||||
commonHeaders[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return commonHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out common headers
|
||||
*/
|
||||
private filterOutCommonHeaders(
|
||||
headers: Record<string, string>,
|
||||
commonHeaders: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
if (!headers || typeof headers !== 'object') return {};
|
||||
|
||||
const specificHeaders: Record<string, string> = {};
|
||||
// Use Object.keys to avoid ESLint no-prototype-builtins warning
|
||||
Object.keys(headers).forEach((name) => {
|
||||
if (!(name in commonHeaders) || headers[name] !== commonHeaders[name]) {
|
||||
specificHeaders[name] = headers[name];
|
||||
}
|
||||
});
|
||||
|
||||
return specificHeaders;
|
||||
}
|
||||
|
||||
async execute(args: NetworkCaptureStartToolParams): Promise<ToolResult> {
|
||||
const {
|
||||
url: targetUrl,
|
||||
maxCaptureTime = 3 * 60 * 1000, // Default 3 minutes
|
||||
inactivityTimeout = 60 * 1000, // Default 1 minute of inactivity before auto-stop
|
||||
includeStatic = false, // Default: don't include static resources
|
||||
} = args;
|
||||
|
||||
console.log(`NetworkCaptureStartTool: Executing with args:`, args);
|
||||
|
||||
try {
|
||||
// Get current tab or create new tab
|
||||
let tabToOperateOn: chrome.tabs.Tab;
|
||||
|
||||
if (targetUrl) {
|
||||
// Find tabs matching the URL
|
||||
const matchingTabs = await chrome.tabs.query({ url: targetUrl });
|
||||
|
||||
if (matchingTabs.length > 0) {
|
||||
// Use existing tab
|
||||
tabToOperateOn = matchingTabs[0];
|
||||
console.log(`NetworkCaptureV2: Found existing tab with URL: ${targetUrl}`);
|
||||
} else {
|
||||
// Create new tab
|
||||
console.log(`NetworkCaptureV2: Creating new tab with URL: ${targetUrl}`);
|
||||
tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });
|
||||
|
||||
// Wait for page to load
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
} else {
|
||||
// Use current active tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
tabToOperateOn = tabs[0];
|
||||
}
|
||||
|
||||
if (!tabToOperateOn?.id) {
|
||||
return createErrorResponse('Failed to identify or create a tab');
|
||||
}
|
||||
|
||||
// Use startCaptureForTab method to start capture
|
||||
try {
|
||||
await this.startCaptureForTab(tabToOperateOn.id, {
|
||||
maxCaptureTime,
|
||||
inactivityTimeout,
|
||||
includeStatic,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return createErrorResponse(
|
||||
`Failed to start capture for tab ${tabToOperateOn.id}: ${error.message || String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Network capture V2 started successfully, waiting for stop command.',
|
||||
tabId: tabToOperateOn.id,
|
||||
url: tabToOperateOn.url,
|
||||
maxCaptureTime,
|
||||
inactivityTimeout,
|
||||
includeStatic,
|
||||
maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('NetworkCaptureStartTool: Critical error:', error);
|
||||
return createErrorResponse(
|
||||
`Error in NetworkCaptureStartTool: ${error.message || String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network capture stop tool V2 - Stop webRequest API capture and return results
|
||||
*/
|
||||
class NetworkCaptureStopTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP;
|
||||
public static instance: NetworkCaptureStopTool | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (NetworkCaptureStopTool.instance) {
|
||||
return NetworkCaptureStopTool.instance;
|
||||
}
|
||||
NetworkCaptureStopTool.instance = this;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
console.log(`NetworkCaptureStopTool: Executing`);
|
||||
|
||||
try {
|
||||
const startTool = NetworkCaptureStartTool.instance;
|
||||
|
||||
if (!startTool) {
|
||||
return createErrorResponse('Network capture V2 start tool instance not found');
|
||||
}
|
||||
|
||||
// Get all tabs currently capturing
|
||||
const ongoingCaptures = Array.from(startTool.captureData.keys());
|
||||
console.log(
|
||||
`NetworkCaptureStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,
|
||||
);
|
||||
|
||||
if (ongoingCaptures.length === 0) {
|
||||
return createErrorResponse('No active network captures found in any tab.');
|
||||
}
|
||||
|
||||
// Get current active tab
|
||||
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const activeTabId = activeTabs[0]?.id;
|
||||
|
||||
// Determine the primary tab to stop
|
||||
let primaryTabId: number;
|
||||
|
||||
if (activeTabId && startTool.captureData.has(activeTabId)) {
|
||||
// If current active tab is capturing, prioritize stopping it
|
||||
primaryTabId = activeTabId;
|
||||
console.log(
|
||||
`NetworkCaptureStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,
|
||||
);
|
||||
} else if (ongoingCaptures.length === 1) {
|
||||
// If only one tab is capturing, stop it
|
||||
primaryTabId = ongoingCaptures[0];
|
||||
console.log(
|
||||
`NetworkCaptureStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,
|
||||
);
|
||||
} else {
|
||||
// If multiple tabs are capturing but current active tab is not among them, stop the first one
|
||||
primaryTabId = ongoingCaptures[0];
|
||||
console.log(
|
||||
`NetworkCaptureStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stopResult = await startTool.stopCapture(primaryTabId);
|
||||
|
||||
if (!stopResult.success) {
|
||||
return createErrorResponse(
|
||||
stopResult.message || `Failed to stop network capture for tab ${primaryTabId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// If multiple tabs are capturing, stop other tabs
|
||||
if (ongoingCaptures.length > 1) {
|
||||
const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);
|
||||
console.log(
|
||||
`NetworkCaptureStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,
|
||||
);
|
||||
|
||||
for (const tabId of otherTabIds) {
|
||||
try {
|
||||
await startTool.stopCapture(tabId);
|
||||
} catch (error) {
|
||||
console.error(`NetworkCaptureStopTool: Error stopping capture on tab ${tabId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Capture complete. ${stopResult.data?.requestCount || 0} requests captured.`,
|
||||
tabId: primaryTabId,
|
||||
tabUrl: stopResult.data?.tabUrl || 'N/A',
|
||||
tabTitle: stopResult.data?.tabTitle || 'Unknown Tab',
|
||||
requestCount: stopResult.data?.requestCount || 0,
|
||||
commonRequestHeaders: stopResult.data?.commonRequestHeaders || {},
|
||||
commonResponseHeaders: stopResult.data?.commonResponseHeaders || {},
|
||||
requests: stopResult.data?.requests || [],
|
||||
captureStartTime: stopResult.data?.captureStartTime,
|
||||
captureEndTime: stopResult.data?.captureEndTime,
|
||||
totalDurationMs: stopResult.data?.totalDurationMs,
|
||||
settingsUsed: stopResult.data?.settingsUsed || {},
|
||||
totalRequestsReceived: stopResult.data?.totalRequestsReceived || 0,
|
||||
requestLimitReached: stopResult.data?.requestLimitReached || false,
|
||||
remainingCaptures: Array.from(startTool.captureData.keys()),
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('NetworkCaptureStopTool: Critical error:', error);
|
||||
return createErrorResponse(
|
||||
`Error in NetworkCaptureStopTool: ${error.message || String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const networkCaptureStartTool = new NetworkCaptureStartTool();
|
||||
export const networkCaptureStopTool = new NetworkCaptureStopTool();
|
@@ -0,0 +1,80 @@
|
||||
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';
|
||||
|
||||
const DEFAULT_NETWORK_REQUEST_TIMEOUT = 30000; // For sending a single request via content script
|
||||
|
||||
interface NetworkRequestToolParams {
|
||||
url: string; // URL is always required
|
||||
method?: string; // Defaults to GET
|
||||
headers?: Record<string, string>; // User-provided headers
|
||||
body?: any; // User-provided body
|
||||
timeout?: number; // Timeout for the network request itself
|
||||
}
|
||||
|
||||
/**
|
||||
* NetworkRequestTool - Sends network requests based on provided parameters.
|
||||
*/
|
||||
class NetworkRequestTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.NETWORK_REQUEST;
|
||||
|
||||
async execute(args: NetworkRequestToolParams): Promise<ToolResult> {
|
||||
const {
|
||||
url,
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
body,
|
||||
timeout = DEFAULT_NETWORK_REQUEST_TIMEOUT,
|
||||
} = args;
|
||||
|
||||
console.log(`NetworkRequestTool: Executing with options:`, args);
|
||||
|
||||
if (!url) {
|
||||
return createErrorResponse('URL parameter is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
return createErrorResponse('No active tab found or tab has no ID.');
|
||||
}
|
||||
const activeTabId = tabs[0].id;
|
||||
|
||||
// Ensure content script is available in the target tab
|
||||
await this.injectContentScript(activeTabId, ['inject-scripts/network-helper.js']);
|
||||
|
||||
console.log(
|
||||
`NetworkRequestTool: Sending to content script: URL=${url}, Method=${method}, Headers=${Object.keys(headers).join(',')}, BodyType=${typeof body}`,
|
||||
);
|
||||
|
||||
const resultFromContentScript = await this.sendMessageToTab(activeTabId, {
|
||||
action: TOOL_MESSAGE_TYPES.NETWORK_SEND_REQUEST,
|
||||
url: url,
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body,
|
||||
timeout: timeout,
|
||||
});
|
||||
|
||||
console.log(`NetworkRequestTool: Response from content script:`, resultFromContentScript);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(resultFromContentScript),
|
||||
},
|
||||
],
|
||||
isError: !resultFromContentScript?.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('NetworkRequestTool: Error sending network request:', error);
|
||||
return createErrorResponse(
|
||||
`Error sending network request: ${error.message || String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const networkRequestTool = new NetworkRequestTool();
|
@@ -0,0 +1,388 @@
|
||||
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,
|
||||
cropAndResizeImage,
|
||||
stitchImages,
|
||||
compressImage,
|
||||
} from '../../../../utils/image-utils';
|
||||
|
||||
// Screenshot-specific constants
|
||||
const SCREENSHOT_CONSTANTS = {
|
||||
SCROLL_DELAY_MS: 350, // Time to wait after scroll for rendering and lazy loading
|
||||
CAPTURE_STITCH_DELAY_MS: 50, // Small delay between captures in a scroll sequence
|
||||
MAX_CAPTURE_PARTS: 50, // Maximum number of parts to capture (for infinite scroll pages)
|
||||
MAX_CAPTURE_HEIGHT_PX: 50000, // Maximum height in pixels to capture
|
||||
PIXEL_TOLERANCE: 1,
|
||||
SCRIPT_INIT_DELAY: 100, // Delay for script initialization
|
||||
} as const;
|
||||
|
||||
interface ScreenshotToolParams {
|
||||
name: string;
|
||||
selector?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
storeBase64?: boolean;
|
||||
fullPage?: boolean;
|
||||
savePng?: boolean;
|
||||
maxHeight?: number; // Maximum height to capture in pixels (for infinite scroll pages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for capturing screenshots of web pages
|
||||
*/
|
||||
class ScreenshotTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.SCREENSHOT;
|
||||
|
||||
/**
|
||||
* Execute screenshot operation
|
||||
*/
|
||||
async execute(args: ScreenshotToolParams): Promise<ToolResult> {
|
||||
const {
|
||||
name = 'screenshot',
|
||||
selector,
|
||||
storeBase64 = false,
|
||||
fullPage = false,
|
||||
savePng = true,
|
||||
} = args;
|
||||
|
||||
console.log(`Starting screenshot with options:`, args);
|
||||
|
||||
// Get current tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
|
||||
}
|
||||
const tab = tabs[0];
|
||||
|
||||
// Check URL restrictions
|
||||
if (
|
||||
tab.url?.startsWith('chrome://') ||
|
||||
tab.url?.startsWith('edge://') ||
|
||||
tab.url?.startsWith('https://chrome.google.com/webstore') ||
|
||||
tab.url?.startsWith('https://microsoftedge.microsoft.com/')
|
||||
) {
|
||||
return createErrorResponse(
|
||||
'Cannot capture special browser pages or web store pages due to security restrictions.',
|
||||
);
|
||||
}
|
||||
|
||||
let finalImageDataUrl: string | undefined;
|
||||
const results: any = { base64: null, fileSaved: false };
|
||||
let originalScroll = { x: 0, y: 0 };
|
||||
|
||||
try {
|
||||
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 },
|
||||
});
|
||||
|
||||
// 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...');
|
||||
finalImageDataUrl = await this._captureFullPage(tab.id!, args, pageDetails);
|
||||
} else if (selector) {
|
||||
this.logInfo(`Capturing element: ${selector}`);
|
||||
finalImageDataUrl = await this._captureElement(tab.id!, args, pageDetails.devicePixelRatio);
|
||||
} else {
|
||||
// Visible area only
|
||||
this.logInfo('Capturing visible area...');
|
||||
finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
|
||||
}
|
||||
|
||||
if (!finalImageDataUrl) {
|
||||
throw new Error('Failed to capture image data');
|
||||
}
|
||||
|
||||
// 2. Process output
|
||||
if (storeBase64 === true) {
|
||||
// Compress image for base64 output to reduce size
|
||||
const compressed = await compressImage(finalImageDataUrl, {
|
||||
scale: 0.7, // Reduce dimensions by 30%
|
||||
quality: 0.8, // 80% quality for good balance
|
||||
format: 'image/jpeg', // JPEG for better compression
|
||||
});
|
||||
|
||||
// Include base64 data in response (without prefix)
|
||||
const base64Data = compressed.dataUrl.replace(/^data:image\/[^;]+;base64,/, '');
|
||||
results.base64 = base64Data;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ base64Data, mimeType: compressed.mimeType }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (savePng === true) {
|
||||
// Save PNG file to downloads
|
||||
this.logInfo('Saving PNG...');
|
||||
try {
|
||||
// Generate filename
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${name.replace(/[^a-z0-9_-]/gi, '_') || 'screenshot'}_${timestamp}.png`;
|
||||
|
||||
// Use Chrome's download API to save the file
|
||||
const downloadId = await chrome.downloads.download({
|
||||
url: finalImageDataUrl,
|
||||
filename: filename,
|
||||
saveAs: false,
|
||||
});
|
||||
|
||||
results.downloadId = downloadId;
|
||||
results.filename = filename;
|
||||
results.fileSaved = true;
|
||||
|
||||
// Try to get the full file path
|
||||
try {
|
||||
// Wait a moment to ensure download info is updated
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Search for download item to get full path
|
||||
const [downloadItem] = await chrome.downloads.search({ id: downloadId });
|
||||
if (downloadItem && downloadItem.filename) {
|
||||
// Add full path to response
|
||||
results.fullPath = downloadItem.filename;
|
||||
}
|
||||
} catch (pathError) {
|
||||
console.warn('Could not get full file path:', pathError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PNG file:', error);
|
||||
results.saveError = String(error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during screenshot execution:', error);
|
||||
return createErrorResponse(
|
||||
`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);
|
||||
}
|
||||
}
|
||||
|
||||
this.logInfo('Screenshot completed!');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Screenshot [${name}] captured successfully`,
|
||||
tabId: tab.id,
|
||||
url: tab.url,
|
||||
name: name,
|
||||
...results,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log information
|
||||
*/
|
||||
private logInfo(message: string) {
|
||||
console.log(`[Screenshot Tool] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture specific element
|
||||
*/
|
||||
async _captureElement(
|
||||
tabId: number,
|
||||
options: ScreenshotToolParams,
|
||||
pageDpr: number,
|
||||
): Promise<string> {
|
||||
const elementDetails = await this.sendMessageToTab(tabId, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_ELEMENT_DETAILS,
|
||||
selector: options.selector,
|
||||
});
|
||||
|
||||
const dpr = elementDetails.devicePixelRatio || pageDpr || 1;
|
||||
|
||||
// Element rect is viewport-relative, in CSS pixels
|
||||
// captureVisibleTab captures in physical pixels
|
||||
const cropRectPx = {
|
||||
x: elementDetails.rect.x * dpr,
|
||||
y: elementDetails.rect.y * dpr,
|
||||
width: elementDetails.rect.width * dpr,
|
||||
height: elementDetails.rect.height * dpr,
|
||||
};
|
||||
|
||||
// Small delay to ensure element is fully rendered after scrollIntoView
|
||||
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
|
||||
|
||||
const visibleCaptureDataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' });
|
||||
if (!visibleCaptureDataUrl) {
|
||||
throw new Error('Failed to capture visible tab for element cropping');
|
||||
}
|
||||
|
||||
const croppedCanvas = await cropAndResizeImage(
|
||||
visibleCaptureDataUrl,
|
||||
cropRectPx,
|
||||
dpr,
|
||||
options.width, // Target output width in CSS pixels
|
||||
options.height, // Target output height in CSS pixels
|
||||
);
|
||||
return canvasToDataURL(croppedCanvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture full page
|
||||
*/
|
||||
async _captureFullPage(
|
||||
tabId: number,
|
||||
options: ScreenshotToolParams,
|
||||
initialPageDetails: any,
|
||||
): Promise<string> {
|
||||
const dpr = initialPageDetails.devicePixelRatio;
|
||||
const totalWidthCss = options.width || initialPageDetails.totalWidth; // Use option width if provided
|
||||
const totalHeightCss = initialPageDetails.totalHeight; // Full page always uses actual height
|
||||
|
||||
// Apply maximum height limit for infinite scroll pages
|
||||
const maxHeightPx = options.maxHeight || SCREENSHOT_CONSTANTS.MAX_CAPTURE_HEIGHT_PX;
|
||||
const limitedHeightCss = Math.min(totalHeightCss, maxHeightPx / dpr);
|
||||
|
||||
const totalWidthPx = totalWidthCss * dpr;
|
||||
const totalHeightPx = limitedHeightCss * dpr;
|
||||
|
||||
// Viewport dimensions (CSS pixels) - logged for debugging
|
||||
this.logInfo(
|
||||
`Viewport size: ${initialPageDetails.viewportWidth}x${initialPageDetails.viewportHeight} CSS pixels`,
|
||||
);
|
||||
this.logInfo(
|
||||
`Page dimensions: ${totalWidthCss}x${totalHeightCss} CSS pixels (limited to ${limitedHeightCss} height)`,
|
||||
);
|
||||
|
||||
const viewportHeightCss = initialPageDetails.viewportHeight;
|
||||
|
||||
const capturedParts = [];
|
||||
let currentScrollYCss = 0;
|
||||
let capturedHeightPx = 0;
|
||||
let partIndex = 0;
|
||||
|
||||
while (capturedHeightPx < totalHeightPx && partIndex < SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) {
|
||||
this.logInfo(
|
||||
`Capturing part ${partIndex + 1}... (${Math.round((capturedHeightPx / totalHeightPx) * 100)}%)`,
|
||||
);
|
||||
|
||||
if (currentScrollYCss > 0) {
|
||||
// Don't scroll for the first part if already at top
|
||||
const scrollResp = await this.sendMessageToTab(tabId, {
|
||||
action: TOOL_MESSAGE_TYPES.SCREENSHOT_SCROLL_PAGE,
|
||||
x: 0,
|
||||
y: currentScrollYCss,
|
||||
scrollDelay: SCREENSHOT_CONSTANTS.SCROLL_DELAY_MS,
|
||||
});
|
||||
// Update currentScrollYCss based on actual scroll achieved
|
||||
currentScrollYCss = scrollResp.newScrollY;
|
||||
}
|
||||
|
||||
// Ensure rendering after scroll
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS),
|
||||
);
|
||||
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' });
|
||||
if (!dataUrl) throw new Error('captureVisibleTab returned empty during full page capture');
|
||||
|
||||
const yOffsetPx = currentScrollYCss * dpr;
|
||||
capturedParts.push({ dataUrl, y: yOffsetPx });
|
||||
|
||||
const imgForHeight = await createImageBitmapFromUrl(dataUrl); // To get actual captured height
|
||||
const lastPartEffectiveHeightPx = Math.min(imgForHeight.height, totalHeightPx - yOffsetPx);
|
||||
|
||||
capturedHeightPx = yOffsetPx + lastPartEffectiveHeightPx;
|
||||
|
||||
if (capturedHeightPx >= totalHeightPx - SCREENSHOT_CONSTANTS.PIXEL_TOLERANCE) break;
|
||||
|
||||
currentScrollYCss += viewportHeightCss;
|
||||
// Prevent overscrolling past the document height for the next scroll command
|
||||
if (
|
||||
currentScrollYCss > totalHeightCss - viewportHeightCss &&
|
||||
currentScrollYCss < totalHeightCss
|
||||
) {
|
||||
currentScrollYCss = totalHeightCss - viewportHeightCss;
|
||||
}
|
||||
partIndex++;
|
||||
}
|
||||
|
||||
// Check if we hit any limits
|
||||
if (partIndex >= SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) {
|
||||
this.logInfo(
|
||||
`Reached maximum number of capture parts (${SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS}). This may be an infinite scroll page.`,
|
||||
);
|
||||
}
|
||||
if (totalHeightCss > limitedHeightCss) {
|
||||
this.logInfo(
|
||||
`Page height (${totalHeightCss}px) exceeds maximum capture height (${maxHeightPx / dpr}px). Capturing limited portion.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logInfo('Stitching image...');
|
||||
const finalCanvas = await stitchImages(capturedParts, totalWidthPx, totalHeightPx);
|
||||
|
||||
// If user specified width but not height (or vice versa for full page), resize maintaining aspect ratio
|
||||
let outputCanvas = finalCanvas;
|
||||
if (options.width && !options.height) {
|
||||
const targetWidthPx = options.width * dpr;
|
||||
const aspectRatio = finalCanvas.height / finalCanvas.width;
|
||||
const targetHeightPx = targetWidthPx * aspectRatio;
|
||||
outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);
|
||||
const ctx = outputCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);
|
||||
}
|
||||
} else if (options.height && !options.width) {
|
||||
const targetHeightPx = options.height * dpr;
|
||||
const aspectRatio = finalCanvas.width / finalCanvas.height;
|
||||
const targetWidthPx = targetHeightPx * aspectRatio;
|
||||
outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);
|
||||
const ctx = outputCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);
|
||||
}
|
||||
} else if (options.width && options.height) {
|
||||
// Both specified, direct resize
|
||||
const targetWidthPx = options.width * dpr;
|
||||
const targetHeightPx = options.height * dpr;
|
||||
outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);
|
||||
const ctx = outputCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);
|
||||
}
|
||||
}
|
||||
|
||||
return canvasToDataURL(outputCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
export const screenshotTool = new ScreenshotTool();
|
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Vectorized tab content search tool
|
||||
* Uses vector database for efficient semantic search
|
||||
*/
|
||||
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { ContentIndexer } from '@/utils/content-indexer';
|
||||
import { LIMITS, ERROR_MESSAGES } from '@/common/constants';
|
||||
import type { SearchResult } from '@/utils/vector-database';
|
||||
|
||||
interface VectorSearchResult {
|
||||
tabId: number;
|
||||
url: string;
|
||||
title: string;
|
||||
semanticScore: number;
|
||||
matchedSnippet: string;
|
||||
chunkSource: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for vectorized search of tab content using semantic similarity
|
||||
*/
|
||||
class VectorSearchTabsContentTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT;
|
||||
private contentIndexer: ContentIndexer;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.contentIndexer = new ContentIndexer({
|
||||
autoIndex: true,
|
||||
maxChunksPerPage: LIMITS.MAX_SEARCH_RESULTS,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async initializeIndexer(): Promise<void> {
|
||||
try {
|
||||
await this.contentIndexer.initialize();
|
||||
this.isInitialized = true;
|
||||
console.log('VectorSearchTabsContentTool: Content indexer initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('VectorSearchTabsContentTool: Failed to initialize content indexer:', error);
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args: { query: string }): Promise<ToolResult> {
|
||||
try {
|
||||
const { query } = args;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return createErrorResponse(
|
||||
ERROR_MESSAGES.INVALID_PARAMETERS + ': Query parameter is required and cannot be empty',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`VectorSearchTabsContentTool: Starting vector search with query: "${query}"`);
|
||||
|
||||
// Check semantic engine status
|
||||
if (!this.contentIndexer.isSemanticEngineReady()) {
|
||||
if (this.contentIndexer.isSemanticEngineInitializing()) {
|
||||
return createErrorResponse(
|
||||
'Vector search engine is still initializing (model downloading). Please wait a moment and try again.',
|
||||
);
|
||||
} else {
|
||||
// Try to initialize
|
||||
console.log('VectorSearchTabsContentTool: Initializing content indexer...');
|
||||
await this.initializeIndexer();
|
||||
|
||||
// Check semantic engine status again
|
||||
if (!this.contentIndexer.isSemanticEngineReady()) {
|
||||
return createErrorResponse('Failed to initialize vector search engine');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute vector search, get more results for deduplication
|
||||
const searchResults = await this.contentIndexer.searchContent(query, 50);
|
||||
|
||||
// Convert search results format
|
||||
const vectorSearchResults = this.convertSearchResults(searchResults);
|
||||
|
||||
// Deduplicate by tab, keep only the highest similarity fragment per tab
|
||||
const deduplicatedResults = this.deduplicateByTab(vectorSearchResults);
|
||||
|
||||
// Sort by similarity and get top 10 results
|
||||
const topResults = deduplicatedResults
|
||||
.sort((a, b) => b.semanticScore - a.semanticScore)
|
||||
.slice(0, 10);
|
||||
|
||||
// Get index statistics
|
||||
const stats = this.contentIndexer.getStats();
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
totalTabsSearched: stats.totalTabs,
|
||||
matchedTabsCount: topResults.length,
|
||||
vectorSearchEnabled: true,
|
||||
indexStats: {
|
||||
totalDocuments: stats.totalDocuments,
|
||||
totalTabs: stats.totalTabs,
|
||||
indexedPages: stats.indexedPages,
|
||||
semanticEngineReady: stats.semanticEngineReady,
|
||||
semanticEngineInitializing: stats.semanticEngineInitializing,
|
||||
},
|
||||
matchedTabs: topResults.map((result) => ({
|
||||
tabId: result.tabId,
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
semanticScore: result.semanticScore,
|
||||
matchedSnippets: [result.matchedSnippet],
|
||||
chunkSource: result.chunkSource,
|
||||
timestamp: result.timestamp,
|
||||
})),
|
||||
};
|
||||
|
||||
console.log(
|
||||
`VectorSearchTabsContentTool: Found ${topResults.length} results with vector search`,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('VectorSearchTabsContentTool: Search failed:', error);
|
||||
return createErrorResponse(
|
||||
`Vector search failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all tabs are indexed
|
||||
*/
|
||||
private async ensureTabsIndexed(tabs: chrome.tabs.Tab[]): Promise<void> {
|
||||
const indexPromises = tabs
|
||||
.filter((tab) => tab.id)
|
||||
.map(async (tab) => {
|
||||
try {
|
||||
await this.contentIndexer.indexTabContent(tab.id!);
|
||||
} catch (error) {
|
||||
console.warn(`VectorSearchTabsContentTool: Failed to index tab ${tab.id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(indexPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert search results format
|
||||
*/
|
||||
private convertSearchResults(searchResults: SearchResult[]): VectorSearchResult[] {
|
||||
return searchResults.map((result) => ({
|
||||
tabId: result.document.tabId,
|
||||
url: result.document.url,
|
||||
title: result.document.title,
|
||||
semanticScore: result.similarity,
|
||||
matchedSnippet: this.extractSnippet(result.document.chunk.text),
|
||||
chunkSource: result.document.chunk.source,
|
||||
timestamp: result.document.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate by tab, keep only the highest similarity fragment per tab
|
||||
*/
|
||||
private deduplicateByTab(results: VectorSearchResult[]): VectorSearchResult[] {
|
||||
const tabMap = new Map<number, VectorSearchResult>();
|
||||
|
||||
for (const result of results) {
|
||||
const existingResult = tabMap.get(result.tabId);
|
||||
|
||||
// If this tab has no result yet, or current result has higher similarity, update it
|
||||
if (!existingResult || result.semanticScore > existingResult.semanticScore) {
|
||||
tabMap.set(result.tabId, result);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tabMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text snippet for display
|
||||
*/
|
||||
private extractSnippet(text: string, maxLength: number = 200): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Try to truncate at sentence boundary
|
||||
const truncated = text.substring(0, maxLength);
|
||||
const lastSentenceEnd = Math.max(
|
||||
truncated.lastIndexOf('.'),
|
||||
truncated.lastIndexOf('!'),
|
||||
truncated.lastIndexOf('?'),
|
||||
truncated.lastIndexOf('。'),
|
||||
truncated.lastIndexOf('!'),
|
||||
truncated.lastIndexOf('?'),
|
||||
);
|
||||
|
||||
if (lastSentenceEnd > maxLength * 0.7) {
|
||||
return truncated.substring(0, lastSentenceEnd + 1);
|
||||
}
|
||||
|
||||
// If no suitable sentence boundary found, truncate at word boundary
|
||||
const lastSpaceIndex = truncated.lastIndexOf(' ');
|
||||
if (lastSpaceIndex > maxLength * 0.8) {
|
||||
return truncated.substring(0, lastSpaceIndex) + '...';
|
||||
}
|
||||
|
||||
return truncated + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index statistics
|
||||
*/
|
||||
public async getIndexStats() {
|
||||
if (!this.isInitialized) {
|
||||
// Don't automatically initialize - just return basic stats
|
||||
return {
|
||||
totalDocuments: 0,
|
||||
totalTabs: 0,
|
||||
indexSize: 0,
|
||||
indexedPages: 0,
|
||||
isInitialized: false,
|
||||
semanticEngineReady: false,
|
||||
semanticEngineInitializing: false,
|
||||
};
|
||||
}
|
||||
return this.contentIndexer.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually rebuild index
|
||||
*/
|
||||
public async rebuildIndex(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initializeIndexer();
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear existing indexes
|
||||
await this.contentIndexer.clearAllIndexes();
|
||||
|
||||
// Get all tabs and reindex
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const allTabs: chrome.tabs.Tab[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
if (window.tabs) {
|
||||
allTabs.push(...window.tabs);
|
||||
}
|
||||
}
|
||||
|
||||
const validTabs = allTabs.filter(
|
||||
(tab) =>
|
||||
tab.id &&
|
||||
tab.url &&
|
||||
!tab.url.startsWith('chrome://') &&
|
||||
!tab.url.startsWith('chrome-extension://') &&
|
||||
!tab.url.startsWith('edge://') &&
|
||||
!tab.url.startsWith('about:'),
|
||||
);
|
||||
|
||||
await this.ensureTabsIndexed(validTabs);
|
||||
|
||||
console.log(`VectorSearchTabsContentTool: Rebuilt index for ${validTabs.length} tabs`);
|
||||
} catch (error) {
|
||||
console.error('VectorSearchTabsContentTool: Failed to rebuild index:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually index specified tab
|
||||
*/
|
||||
public async indexTab(tabId: number): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initializeIndexer();
|
||||
}
|
||||
|
||||
await this.contentIndexer.indexTabContent(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove index for specified tab
|
||||
*/
|
||||
public async removeTabIndex(tabId: number): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.contentIndexer.removeTabIndex(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// Export tool instance
|
||||
export const vectorSearchTabsContentTool = new VectorSearchTabsContentTool();
|
@@ -0,0 +1,229 @@
|
||||
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';
|
||||
|
||||
interface WebFetcherToolParams {
|
||||
htmlContent?: boolean; // get the visible HTML content of the current page. default: false
|
||||
textContent?: boolean; // get the visible text content of the current page. default: true
|
||||
url?: string; // optional URL to fetch content from (if not provided, uses active tab)
|
||||
selector?: string; // optional CSS selector to get content from a specific element
|
||||
}
|
||||
|
||||
class WebFetcherTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.WEB_FETCHER;
|
||||
|
||||
/**
|
||||
* Execute web fetcher operation
|
||||
*/
|
||||
async execute(args: WebFetcherToolParams): Promise<ToolResult> {
|
||||
// Handle mutually exclusive parameters: if htmlContent is true, textContent is forced to false
|
||||
const htmlContent = args.htmlContent === true;
|
||||
const textContent = htmlContent ? false : args.textContent !== false; // Default is true, unless htmlContent is true or textContent is explicitly set to false
|
||||
const url = args.url;
|
||||
const selector = args.selector;
|
||||
|
||||
console.log(`Starting web fetcher with options:`, {
|
||||
htmlContent,
|
||||
textContent,
|
||||
url,
|
||||
selector,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get tab to fetch content from
|
||||
let tab;
|
||||
|
||||
if (url) {
|
||||
// If URL is provided, check if it's already open
|
||||
console.log(`Checking if URL is already open: ${url}`);
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
|
||||
// Find tab with matching URL
|
||||
const matchingTabs = allTabs.filter((t) => {
|
||||
// Normalize URLs for comparison (remove trailing slashes)
|
||||
const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
|
||||
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
return tabUrl === targetUrl;
|
||||
});
|
||||
|
||||
if (matchingTabs.length > 0) {
|
||||
// Use existing tab
|
||||
tab = matchingTabs[0];
|
||||
console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
|
||||
} else {
|
||||
// Create new tab with the URL
|
||||
console.log(`No existing tab found with URL: ${url}, creating new tab`);
|
||||
tab = await chrome.tabs.create({ url, active: true });
|
||||
|
||||
// Wait for page to load
|
||||
console.log('Waiting for page to load...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
} else {
|
||||
// Use active tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
tab = tabs[0];
|
||||
}
|
||||
|
||||
if (!tab.id) {
|
||||
return createErrorResponse('Tab has no ID');
|
||||
}
|
||||
|
||||
// Make sure tab is active
|
||||
await chrome.tabs.update(tab.id, { active: true });
|
||||
|
||||
// Prepare result object
|
||||
const result: any = {
|
||||
success: true,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
};
|
||||
|
||||
await this.injectContentScript(tab.id, ['inject-scripts/web-fetcher-helper.js']);
|
||||
|
||||
// Get HTML content if requested
|
||||
if (htmlContent) {
|
||||
const htmlResponse = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_HTML_CONTENT,
|
||||
selector: selector,
|
||||
});
|
||||
|
||||
if (htmlResponse.success) {
|
||||
result.htmlContent = htmlResponse.htmlContent;
|
||||
} else {
|
||||
console.error('Failed to get HTML content:', htmlResponse.error);
|
||||
result.htmlContentError = htmlResponse.error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get text content if requested (and htmlContent is not true)
|
||||
if (textContent) {
|
||||
const textResponse = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,
|
||||
selector: selector,
|
||||
});
|
||||
|
||||
if (textResponse.success) {
|
||||
result.textContent = textResponse.textContent;
|
||||
|
||||
// Include article metadata if available
|
||||
if (textResponse.article) {
|
||||
result.article = {
|
||||
title: textResponse.article.title,
|
||||
byline: textResponse.article.byline,
|
||||
siteName: textResponse.article.siteName,
|
||||
excerpt: textResponse.article.excerpt,
|
||||
lang: textResponse.article.lang,
|
||||
};
|
||||
}
|
||||
|
||||
// Include page metadata if available
|
||||
if (textResponse.metadata) {
|
||||
result.metadata = textResponse.metadata;
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to get text content:', textResponse.error);
|
||||
result.textContentError = textResponse.error;
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive elements feature has been removed
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in web fetcher:', error);
|
||||
return createErrorResponse(
|
||||
`Error fetching web content: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const webFetcherTool = new WebFetcherTool();
|
||||
|
||||
interface GetInteractiveElementsToolParams {
|
||||
textQuery?: string; // Text to search for within interactive elements (fuzzy search)
|
||||
selector?: string; // CSS selector to filter interactive elements
|
||||
includeCoordinates?: boolean; // Include element coordinates in the response (default: true)
|
||||
types?: string[]; // Types of interactive elements to include (default: all types)
|
||||
}
|
||||
|
||||
class GetInteractiveElementsTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS;
|
||||
|
||||
/**
|
||||
* Execute get interactive elements operation
|
||||
*/
|
||||
async execute(args: GetInteractiveElementsToolParams): Promise<ToolResult> {
|
||||
const { textQuery, selector, includeCoordinates = true, types } = args;
|
||||
|
||||
console.log(`Starting get interactive elements with options:`, args);
|
||||
|
||||
try {
|
||||
// Get current tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
|
||||
const tab = tabs[0];
|
||||
if (!tab.id) {
|
||||
return createErrorResponse('Active tab has no ID');
|
||||
}
|
||||
|
||||
// Ensure content script is injected
|
||||
await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']);
|
||||
|
||||
// Send message to content script
|
||||
const result = await this.sendMessageToTab(tab.id, {
|
||||
action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS,
|
||||
textQuery,
|
||||
selector,
|
||||
includeCoordinates,
|
||||
types,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to get interactive elements');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elements: result.elements,
|
||||
count: result.elements.length,
|
||||
query: {
|
||||
textQuery,
|
||||
selector,
|
||||
types: types || 'all',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in get interactive elements operation:', error);
|
||||
return createErrorResponse(
|
||||
`Error getting interactive elements: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getInteractiveElementsTool = new GetInteractiveElementsTool();
|
@@ -0,0 +1,54 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
class WindowTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS;
|
||||
async execute(): Promise<ToolResult> {
|
||||
try {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let tabCount = 0;
|
||||
|
||||
const structuredWindows = windows.map((window) => {
|
||||
const tabs =
|
||||
window.tabs?.map((tab) => {
|
||||
tabCount++;
|
||||
return {
|
||||
tabId: tab.id || 0,
|
||||
url: tab.url || '',
|
||||
title: tab.title || '',
|
||||
active: tab.active || false,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return {
|
||||
windowId: window.id || 0,
|
||||
tabs: tabs,
|
||||
};
|
||||
});
|
||||
|
||||
const result = {
|
||||
windowCount: windows.length,
|
||||
tabCount: tabCount,
|
||||
windows: structuredWindows,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in WindowTool.execute:', error);
|
||||
return createErrorResponse(
|
||||
`Error getting windows and tabs information: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const windowTool = new WindowTool();
|
33
app/chrome-extension/entrypoints/background/tools/index.ts
Normal file
33
app/chrome-extension/entrypoints/background/tools/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createErrorResponse } from '@/common/tool-handler';
|
||||
import { ERROR_MESSAGES } from '@/common/constants';
|
||||
import * as browserTools from './browser';
|
||||
|
||||
const tools = { ...browserTools };
|
||||
const toolsMap = new Map(Object.values(tools).map((tool) => [tool.name, tool]));
|
||||
|
||||
/**
|
||||
* Tool call parameter interface
|
||||
*/
|
||||
export interface ToolCallParam {
|
||||
name: string;
|
||||
args: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool execution
|
||||
*/
|
||||
export const handleCallTool = async (param: ToolCallParam) => {
|
||||
const tool = toolsMap.get(param.name);
|
||||
if (!tool) {
|
||||
return createErrorResponse(`Tool ${param.name} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await tool.execute(param.args);
|
||||
} catch (error) {
|
||||
console.error(`Tool execution failed for ${param.name}:`, error);
|
||||
return createErrorResponse(
|
||||
error instanceof Error ? error.message : ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user