first commit

This commit is contained in:
nasir@endelospay.com
2025-08-12 02:54:17 +05:00
commit d97cad1736
225 changed files with 137626 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import { initNativeHostListener } from './native-host';
import {
initSemanticSimilarityListener,
initializeSemanticEngineIfCached,
} from './semantic-similarity';
import { initStorageManagerListener } from './storage-manager';
import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
/**
* Background script entry point
* Initializes all background services and listeners
*/
export default defineBackground(() => {
// Initialize core services
initNativeHostListener();
initSemanticSimilarityListener();
initStorageManagerListener();
// Conditionally initialize semantic similarity engine if model cache exists
initializeSemanticEngineIfCached()
.then((initialized) => {
if (initialized) {
console.log('Background: Semantic similarity engine initialized from cache');
} else {
console.log(
'Background: Semantic similarity engine initialization skipped (no cache found)',
);
}
})
.catch((error) => {
console.warn('Background: Failed to conditionally initialize semantic engine:', error);
});
// Initial cleanup on startup
cleanupModelCache().catch((error) => {
console.warn('Background: Initial cache cleanup failed:', error);
});
});

View File

@@ -0,0 +1,237 @@
import { NativeMessageType } from 'chrome-mcp-shared';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import {
NATIVE_HOST,
ICONS,
NOTIFICATIONS,
STORAGE_KEYS,
ERROR_MESSAGES,
SUCCESS_MESSAGES,
} from '@/common/constants';
import { handleCallTool } from './tools';
let nativePort: chrome.runtime.Port | null = null;
export const HOST_NAME = NATIVE_HOST.NAME;
/**
* Server status management interface
*/
interface ServerStatus {
isRunning: boolean;
port?: number;
lastUpdated: number;
}
let currentServerStatus: ServerStatus = {
isRunning: false,
lastUpdated: Date.now(),
};
/**
* Save server status to chrome.storage
*/
async function saveServerStatus(status: ServerStatus): Promise<void> {
try {
await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status });
} catch (error) {
console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error);
}
}
/**
* Load server status from chrome.storage
*/
async function loadServerStatus(): Promise<ServerStatus> {
try {
const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]);
if (result[STORAGE_KEYS.SERVER_STATUS]) {
return result[STORAGE_KEYS.SERVER_STATUS];
}
} catch (error) {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
}
return {
isRunning: false,
lastUpdated: Date.now(),
};
}
/**
* Broadcast server status change to all listeners
*/
function broadcastServerStatusChange(status: ServerStatus): void {
chrome.runtime
.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED,
payload: status,
})
.catch(() => {
// Ignore errors if no listeners are present
});
}
/**
* Connect to the native messaging host
*/
export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
if (nativePort) {
return;
}
try {
nativePort = chrome.runtime.connectNative(HOST_NAME);
nativePort.onMessage.addListener(async (message) => {
// chrome.notifications.create({
// type: NOTIFICATIONS.TYPE,
// iconUrl: chrome.runtime.getURL(ICONS.NOTIFICATION),
// title: 'Message from native host',
// message: `Received data from host: ${JSON.stringify(message)}`,
// priority: NOTIFICATIONS.PRIORITY,
// });
if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) {
const requestId = message.requestId;
const requestPayload = message.payload;
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
status: 'success',
message: SUCCESS_MESSAGES.TOOL_EXECUTED,
data: requestPayload,
},
});
} else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) {
const requestId = message.requestId;
try {
const result = await handleCallTool(message.payload);
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
status: 'success',
message: SUCCESS_MESSAGES.TOOL_EXECUTED,
data: result,
},
});
} catch (error) {
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
status: 'error',
message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
error: error instanceof Error ? error.message : String(error),
},
});
}
} else if (message.type === NativeMessageType.SERVER_STARTED) {
const port = message.payload?.port;
currentServerStatus = {
isRunning: true,
port: port,
lastUpdated: Date.now(),
};
await saveServerStatus(currentServerStatus);
broadcastServerStatusChange(currentServerStatus);
console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`);
} else if (message.type === NativeMessageType.SERVER_STOPPED) {
currentServerStatus = {
isRunning: false,
port: currentServerStatus.port, // Keep last known port for reconnection
lastUpdated: Date.now(),
};
await saveServerStatus(currentServerStatus);
broadcastServerStatusChange(currentServerStatus);
console.log(SUCCESS_MESSAGES.SERVER_STOPPED);
} else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {
console.error('Error from native host:', message.payload?.message || 'Unknown error');
}
});
nativePort.onDisconnect.addListener(() => {
console.error(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);
nativePort = null;
});
nativePort.postMessage({ type: NativeMessageType.START, payload: { port } });
} catch (error) {
console.error(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);
}
}
/**
* Initialize native host listeners and load initial state
*/
export const initNativeHostListener = () => {
// Initialize server status from storage
loadServerStatus()
.then((status) => {
currentServerStatus = status;
})
.catch((error) => {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
});
chrome.runtime.onStartup.addListener(connectNativeHost);
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (
message === NativeMessageType.CONNECT_NATIVE ||
message.type === NativeMessageType.CONNECT_NATIVE
) {
const port =
typeof message === 'object' && message.port ? message.port : NATIVE_HOST.DEFAULT_PORT;
connectNativeHost(port);
sendResponse({ success: true, port });
return true;
}
if (message.type === NativeMessageType.PING_NATIVE) {
const connected = nativePort !== null;
sendResponse({ connected });
return true;
}
if (message.type === NativeMessageType.DISCONNECT_NATIVE) {
if (nativePort) {
nativePort.disconnect();
nativePort = null;
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'No active connection' });
}
return true;
}
if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) {
sendResponse({
success: true,
serverStatus: currentServerStatus,
connected: nativePort !== null,
});
return true;
}
if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) {
loadServerStatus()
.then((storedStatus) => {
currentServerStatus = storedStatus;
sendResponse({
success: true,
serverStatus: currentServerStatus,
connected: nativePort !== null,
});
})
.catch((error) => {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
sendResponse({
success: false,
error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED,
serverStatus: currentServerStatus,
connected: nativePort !== null,
});
});
return true;
}
});
};

View File

@@ -0,0 +1,373 @@
import type { ModelPreset } from '@/utils/semantic-similarity-engine';
import { OffscreenManager } from '@/utils/offscreen-manager';
import { BACKGROUND_MESSAGE_TYPES, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';
import { STORAGE_KEYS, ERROR_MESSAGES } from '@/common/constants';
import { hasAnyModelCache } from '@/utils/semantic-similarity-engine';
/**
* Model configuration state management interface
*/
interface ModelConfig {
modelPreset: ModelPreset;
modelVersion: 'full' | 'quantized' | 'compressed';
modelDimension: number;
}
let currentBackgroundModelConfig: ModelConfig | null = null;
/**
* Initialize semantic engine only if model cache exists
* This is called during plugin startup to avoid downloading models unnecessarily
*/
export async function initializeSemanticEngineIfCached(): Promise<boolean> {
try {
console.log('Background: Checking if semantic engine should be initialized from cache...');
const hasCachedModel = await hasAnyModelCache();
if (!hasCachedModel) {
console.log('Background: No cached models found, skipping semantic engine initialization');
return false;
}
console.log('Background: Found cached models, initializing semantic engine...');
await initializeDefaultSemanticEngine();
return true;
} catch (error) {
console.error('Background: Error during conditional semantic engine initialization:', error);
return false;
}
}
/**
* Initialize default semantic engine model
*/
export async function initializeDefaultSemanticEngine(): Promise<void> {
try {
console.log('Background: Initializing default semantic engine...');
// Update status to initializing
await updateModelStatus('initializing', 0);
const result = await chrome.storage.local.get([STORAGE_KEYS.SEMANTIC_MODEL, 'selectedVersion']);
const defaultModel =
(result[STORAGE_KEYS.SEMANTIC_MODEL] as ModelPreset) || 'multilingual-e5-small';
const defaultVersion =
(result.selectedVersion as 'full' | 'quantized' | 'compressed') || 'quantized';
const { PREDEFINED_MODELS } = await import('@/utils/semantic-similarity-engine');
const modelInfo = PREDEFINED_MODELS[defaultModel];
await OffscreenManager.getInstance().ensureOffscreenDocument();
const response = await chrome.runtime.sendMessage({
target: 'offscreen',
type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,
config: {
useLocalFiles: false,
modelPreset: defaultModel,
modelVersion: defaultVersion,
modelDimension: modelInfo.dimension,
forceOffscreen: true,
},
});
if (response && response.success) {
currentBackgroundModelConfig = {
modelPreset: defaultModel,
modelVersion: defaultVersion,
modelDimension: modelInfo.dimension,
};
console.log('Semantic engine initialized successfully:', currentBackgroundModelConfig);
// Update status to ready
await updateModelStatus('ready', 100);
// Also initialize ContentIndexer now that semantic engine is ready
try {
const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
const contentIndexer = getGlobalContentIndexer();
contentIndexer.startSemanticEngineInitialization();
console.log('ContentIndexer initialization triggered after semantic engine initialization');
} catch (indexerError) {
console.warn(
'Failed to initialize ContentIndexer after semantic engine initialization:',
indexerError,
);
}
} else {
const errorMessage = response?.error || ERROR_MESSAGES.TOOL_EXECUTION_FAILED;
await updateModelStatus('error', 0, errorMessage, 'unknown');
throw new Error(errorMessage);
}
} catch (error: any) {
console.error('Background: Failed to initialize default semantic engine:', error);
const errorMessage = error?.message || 'Unknown error during semantic engine initialization';
await updateModelStatus('error', 0, errorMessage, 'unknown');
// Don't throw error, let the extension continue running
}
}
/**
* Check if model switch is needed
*/
function needsModelSwitch(
modelPreset: ModelPreset,
modelVersion: 'full' | 'quantized' | 'compressed',
modelDimension?: number,
): boolean {
if (!currentBackgroundModelConfig) {
return true;
}
const keyFields = ['modelPreset', 'modelVersion', 'modelDimension'];
for (const field of keyFields) {
const newValue =
field === 'modelPreset'
? modelPreset
: field === 'modelVersion'
? modelVersion
: modelDimension;
if (newValue !== currentBackgroundModelConfig[field as keyof ModelConfig]) {
return true;
}
}
return false;
}
/**
* Handle model switching
*/
export async function handleModelSwitch(
modelPreset: ModelPreset,
modelVersion: 'full' | 'quantized' | 'compressed' = 'quantized',
modelDimension?: number,
previousDimension?: number,
): Promise<{ success: boolean; error?: string }> {
try {
const needsSwitch = needsModelSwitch(modelPreset, modelVersion, modelDimension);
if (!needsSwitch) {
await updateModelStatus('ready', 100);
return { success: true };
}
await updateModelStatus('downloading', 0);
try {
await OffscreenManager.getInstance().ensureOffscreenDocument();
} catch (offscreenError) {
console.error('Background: Failed to create offscreen document:', offscreenError);
const errorMessage = `Failed to create offscreen document: ${offscreenError}`;
await updateModelStatus('error', 0, errorMessage, 'unknown');
return { success: false, error: errorMessage };
}
const response = await chrome.runtime.sendMessage({
target: 'offscreen',
type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,
config: {
useLocalFiles: false,
modelPreset: modelPreset,
modelVersion: modelVersion,
modelDimension: modelDimension,
forceOffscreen: true,
},
});
if (response && response.success) {
currentBackgroundModelConfig = {
modelPreset: modelPreset,
modelVersion: modelVersion,
modelDimension: modelDimension!,
};
// Only reinitialize ContentIndexer when dimension changes
try {
if (modelDimension && previousDimension && modelDimension !== previousDimension) {
const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
const contentIndexer = getGlobalContentIndexer();
await contentIndexer.reinitialize();
}
} catch (indexerError) {
console.warn('Background: Failed to reinitialize ContentIndexer:', indexerError);
}
await updateModelStatus('ready', 100);
return { success: true };
} else {
const errorMessage = response?.error || 'Failed to switch model';
const errorType = analyzeErrorType(errorMessage);
await updateModelStatus('error', 0, errorMessage, errorType);
throw new Error(errorMessage);
}
} catch (error: any) {
console.error('Model switch failed:', error);
const errorMessage = error.message || 'Unknown error';
const errorType = analyzeErrorType(errorMessage);
await updateModelStatus('error', 0, errorMessage, errorType);
return { success: false, error: errorMessage };
}
}
/**
* Get model status
*/
export async function handleGetModelStatus(): Promise<{
success: boolean;
status?: any;
error?: string;
}> {
try {
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) {
console.error('Background: chrome.storage.local is not available for status query');
return {
success: true,
status: {
initializationStatus: 'idle',
downloadProgress: 0,
isDownloading: false,
lastUpdated: Date.now(),
},
};
}
const result = await chrome.storage.local.get(['modelState']);
const modelState = result.modelState || {
status: 'idle',
downloadProgress: 0,
isDownloading: false,
lastUpdated: Date.now(),
};
return {
success: true,
status: {
initializationStatus: modelState.status,
downloadProgress: modelState.downloadProgress,
isDownloading: modelState.isDownloading,
lastUpdated: modelState.lastUpdated,
errorMessage: modelState.errorMessage,
errorType: modelState.errorType,
},
};
} catch (error: any) {
console.error('Failed to get model status:', error);
return { success: false, error: error.message };
}
}
/**
* Update model status
*/
export async function updateModelStatus(
status: string,
progress: number,
errorMessage?: string,
errorType?: string,
): Promise<void> {
try {
// Check if chrome.storage is available
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) {
console.error('Background: chrome.storage.local is not available for status update');
return;
}
const modelState = {
status,
downloadProgress: progress,
isDownloading: status === 'downloading' || status === 'initializing',
lastUpdated: Date.now(),
errorMessage: errorMessage || '',
errorType: errorType || '',
};
await chrome.storage.local.set({ modelState });
} catch (error) {
console.error('Failed to update model status:', error);
}
}
/**
* Handle model status updates from offscreen document
*/
export async function handleUpdateModelStatus(
modelState: any,
): Promise<{ success: boolean; error?: string }> {
try {
// Check if chrome.storage is available
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) {
console.error('Background: chrome.storage.local is not available');
return { success: false, error: 'chrome.storage.local is not available' };
}
await chrome.storage.local.set({ modelState });
return { success: true };
} catch (error: any) {
console.error('Background: Failed to update model status:', error);
return { success: false, error: error.message };
}
}
/**
* Analyze error type based on error message
*/
function analyzeErrorType(errorMessage: string): 'network' | 'file' | 'unknown' {
const message = errorMessage.toLowerCase();
if (
message.includes('network') ||
message.includes('fetch') ||
message.includes('timeout') ||
message.includes('connection') ||
message.includes('cors') ||
message.includes('failed to fetch')
) {
return 'network';
}
if (
message.includes('corrupt') ||
message.includes('invalid') ||
message.includes('format') ||
message.includes('parse') ||
message.includes('decode') ||
message.includes('onnx')
) {
return 'file';
}
return 'unknown';
}
/**
* Initialize semantic similarity module message listeners
*/
export const initSemanticSimilarityListener = () => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === BACKGROUND_MESSAGE_TYPES.SWITCH_SEMANTIC_MODEL) {
handleModelSwitch(
message.modelPreset,
message.modelVersion,
message.modelDimension,
message.previousDimension,
)
.then((result: { success: boolean; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
} else if (message.type === BACKGROUND_MESSAGE_TYPES.GET_MODEL_STATUS) {
handleGetModelStatus()
.then((result: { success: boolean; status?: any; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
} else if (message.type === BACKGROUND_MESSAGE_TYPES.UPDATE_MODEL_STATUS) {
handleUpdateModelStatus(message.modelState)
.then((result: { success: boolean; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
} else if (message.type === BACKGROUND_MESSAGE_TYPES.INITIALIZE_SEMANTIC_ENGINE) {
initializeDefaultSemanticEngine()
.then(() => sendResponse({ success: true }))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
});
};

View File

@@ -0,0 +1,112 @@
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
/**
* Get storage statistics
*/
export async function handleGetStorageStats(): Promise<{
success: boolean;
stats?: any;
error?: string;
}> {
try {
// Get ContentIndexer statistics
const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
const contentIndexer = getGlobalContentIndexer();
// Note: Semantic engine initialization is now user-controlled
// ContentIndexer will be initialized when user manually triggers semantic engine initialization
// Get statistics
const stats = contentIndexer.getStats();
return {
success: true,
stats: {
indexedPages: stats.indexedPages || 0,
totalDocuments: stats.totalDocuments || 0,
totalTabs: stats.totalTabs || 0,
indexSize: stats.indexSize || 0,
isInitialized: stats.isInitialized || false,
semanticEngineReady: stats.semanticEngineReady || false,
semanticEngineInitializing: stats.semanticEngineInitializing || false,
},
};
} catch (error: any) {
console.error('Background: Failed to get storage stats:', error);
return {
success: false,
error: error.message,
stats: {
indexedPages: 0,
totalDocuments: 0,
totalTabs: 0,
indexSize: 0,
isInitialized: false,
semanticEngineReady: false,
semanticEngineInitializing: false,
},
};
}
}
/**
* Clear all data
*/
export async function handleClearAllData(): Promise<{ success: boolean; error?: string }> {
try {
// 1. Clear all ContentIndexer indexes
try {
const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
const contentIndexer = getGlobalContentIndexer();
await contentIndexer.clearAllIndexes();
console.log('Storage: ContentIndexer indexes cleared successfully');
} catch (indexerError) {
console.warn('Background: Failed to clear ContentIndexer indexes:', indexerError);
// Continue with other cleanup operations
}
// 2. Clear all VectorDatabase data
try {
const { clearAllVectorData } = await import('@/utils/vector-database');
await clearAllVectorData();
console.log('Storage: Vector database data cleared successfully');
} catch (vectorError) {
console.warn('Background: Failed to clear vector data:', vectorError);
// Continue with other cleanup operations
}
// 3. Clear related data in chrome.storage (preserve model preferences)
try {
const keysToRemove = ['vectorDatabaseStats', 'lastCleanupTime', 'contentIndexerStats'];
await chrome.storage.local.remove(keysToRemove);
console.log('Storage: Chrome storage data cleared successfully');
} catch (storageError) {
console.warn('Background: Failed to clear chrome storage data:', storageError);
}
return { success: true };
} catch (error: any) {
console.error('Background: Failed to clear all data:', error);
return { success: false, error: error.message };
}
}
/**
* Initialize storage manager module message listeners
*/
export const initStorageManagerListener = () => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === BACKGROUND_MESSAGE_TYPES.GET_STORAGE_STATS) {
handleGetStorageStats()
.then((result: { success: boolean; stats?: any; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
} else if (message.type === BACKGROUND_MESSAGE_TYPES.CLEAR_ALL_DATA) {
handleClearAllData()
.then((result: { success: boolean; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
});
};

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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';

View File

@@ -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);
}
});

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View 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,
);
}
};

View File

@@ -0,0 +1,4 @@
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {},
});

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,431 @@
import { SemanticSimilarityEngine } from '@/utils/semantic-similarity-engine';
import {
MessageTarget,
SendMessageType,
OFFSCREEN_MESSAGE_TYPES,
BACKGROUND_MESSAGE_TYPES,
} from '@/common/message-types';
// Global semantic similarity engine instance
let similarityEngine: SemanticSimilarityEngine | null = null;
interface OffscreenMessage {
target: MessageTarget | string;
type: SendMessageType | string;
}
interface SimilarityEngineInitMessage extends OffscreenMessage {
type: SendMessageType.SimilarityEngineInit;
config: any;
}
interface SimilarityEngineComputeBatchMessage extends OffscreenMessage {
type: SendMessageType.SimilarityEngineComputeBatch;
pairs: { text1: string; text2: string }[];
options?: Record<string, any>;
}
interface SimilarityEngineGetEmbeddingMessage extends OffscreenMessage {
type: 'similarityEngineCompute';
text: string;
options?: Record<string, any>;
}
interface SimilarityEngineGetEmbeddingsBatchMessage extends OffscreenMessage {
type: 'similarityEngineBatchCompute';
texts: string[];
options?: Record<string, any>;
}
interface SimilarityEngineStatusMessage extends OffscreenMessage {
type: 'similarityEngineStatus';
}
type MessageResponse = {
result?: string;
error?: string;
success?: boolean;
similarities?: number[];
embedding?: number[];
embeddings?: number[][];
isInitialized?: boolean;
currentConfig?: any;
};
// Listen for messages from the extension
chrome.runtime.onMessage.addListener(
(
message: OffscreenMessage,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: MessageResponse) => void,
) => {
if (message.target !== MessageTarget.Offscreen) {
return;
}
try {
switch (message.type) {
case SendMessageType.SimilarityEngineInit:
case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT: {
const initMsg = message as SimilarityEngineInitMessage;
console.log('Offscreen: Received similarity engine init message:', message.type);
handleSimilarityEngineInit(initMsg.config)
.then(() => sendResponse({ success: true }))
.catch((error) => sendResponse({ success: false, error: error.message }));
break;
}
case SendMessageType.SimilarityEngineComputeBatch: {
const computeMsg = message as SimilarityEngineComputeBatchMessage;
handleComputeSimilarityBatch(computeMsg.pairs, computeMsg.options)
.then((similarities) => sendResponse({ success: true, similarities }))
.catch((error) => sendResponse({ success: false, error: error.message }));
break;
}
case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_COMPUTE: {
const embeddingMsg = message as SimilarityEngineGetEmbeddingMessage;
handleGetEmbedding(embeddingMsg.text, embeddingMsg.options)
.then((embedding) => {
console.log('Offscreen: Sending embedding response:', {
length: embedding.length,
type: typeof embedding,
constructor: embedding.constructor.name,
isFloat32Array: embedding instanceof Float32Array,
firstFewValues: Array.from(embedding.slice(0, 5)),
});
const embeddingArray = Array.from(embedding);
console.log('Offscreen: Converted to array:', {
length: embeddingArray.length,
type: typeof embeddingArray,
isArray: Array.isArray(embeddingArray),
firstFewValues: embeddingArray.slice(0, 5),
});
sendResponse({ success: true, embedding: embeddingArray });
})
.catch((error) => sendResponse({ success: false, error: error.message }));
break;
}
case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE: {
const batchMsg = message as SimilarityEngineGetEmbeddingsBatchMessage;
handleGetEmbeddingsBatch(batchMsg.texts, batchMsg.options)
.then((embeddings) =>
sendResponse({
success: true,
embeddings: embeddings.map((emb) => Array.from(emb)),
}),
)
.catch((error) => sendResponse({ success: false, error: error.message }));
break;
}
case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_STATUS: {
handleGetEngineStatus()
.then((status: any) => sendResponse({ success: true, ...status }))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
break;
}
default:
sendResponse({ error: `Unknown message type: ${message.type}` });
}
} catch (error) {
if (error instanceof Error) {
sendResponse({ error: error.message });
} else {
sendResponse({ error: 'Unknown error occurred' });
}
}
// Return true to indicate we'll respond asynchronously
return true;
},
);
// Global variable to track current model state
let currentModelConfig: any = null;
/**
* Check if engine reinitialization is needed
*/
function needsReinitialization(newConfig: any): boolean {
if (!similarityEngine || !currentModelConfig) {
return true;
}
// Check if key configuration has changed
const keyFields = ['modelPreset', 'modelVersion', 'modelIdentifier', 'dimension'];
for (const field of keyFields) {
if (newConfig[field] !== currentModelConfig[field]) {
console.log(
`Offscreen: ${field} changed from ${currentModelConfig[field]} to ${newConfig[field]}`,
);
return true;
}
}
return false;
}
/**
* Progress callback function type
*/
type ProgressCallback = (progress: { status: string; progress: number; message?: string }) => void;
/**
* Initialize semantic similarity engine
*/
async function handleSimilarityEngineInit(config: any): Promise<void> {
console.log('Offscreen: Initializing semantic similarity engine with config:', config);
console.log('Offscreen: Config useLocalFiles:', config.useLocalFiles);
console.log('Offscreen: Config modelPreset:', config.modelPreset);
console.log('Offscreen: Config modelVersion:', config.modelVersion);
console.log('Offscreen: Config modelDimension:', config.modelDimension);
console.log('Offscreen: Config modelIdentifier:', config.modelIdentifier);
// Check if reinitialization is needed
const needsReinit = needsReinitialization(config);
console.log('Offscreen: Needs reinitialization:', needsReinit);
if (!needsReinit) {
console.log('Offscreen: Using existing engine (no changes detected)');
await updateModelStatus('ready', 100);
return;
}
// If engine already exists, clean up old instance first (support model switching)
if (similarityEngine) {
console.log('Offscreen: Cleaning up existing engine for model switch...');
try {
// Properly call dispose method to clean up all resources
await similarityEngine.dispose();
console.log('Offscreen: Previous engine disposed successfully');
} catch (error) {
console.warn('Offscreen: Failed to dispose previous engine:', error);
}
similarityEngine = null;
currentModelConfig = null;
// Clear vector data in IndexedDB to ensure data consistency
try {
console.log('Offscreen: Clearing IndexedDB vector data for model switch...');
await clearVectorIndexedDB();
console.log('Offscreen: IndexedDB vector data cleared successfully');
} catch (error) {
console.warn('Offscreen: Failed to clear IndexedDB vector data:', error);
}
}
try {
// Update status to initializing
await updateModelStatus('initializing', 10);
// Create progress callback function
const progressCallback: ProgressCallback = async (progress) => {
console.log('Offscreen: Progress update:', progress);
await updateModelStatus(progress.status, progress.progress);
};
// Create engine instance and pass progress callback
similarityEngine = new SemanticSimilarityEngine(config);
console.log('Offscreen: Starting engine initialization with progress tracking...');
// Use enhanced initialization method (if progress callback is supported)
if (typeof (similarityEngine as any).initializeWithProgress === 'function') {
await (similarityEngine as any).initializeWithProgress(progressCallback);
} else {
// Fallback to standard initialization method
console.log('Offscreen: Using standard initialization (no progress callback support)');
await updateModelStatus('downloading', 30);
await similarityEngine.initialize();
await updateModelStatus('ready', 100);
}
// Save current configuration
currentModelConfig = { ...config };
console.log('Offscreen: Semantic similarity engine initialized successfully');
} catch (error) {
console.error('Offscreen: Failed to initialize semantic similarity engine:', error);
// Update status to error
const errorMessage = error instanceof Error ? error.message : 'Unknown initialization error';
const errorType = analyzeErrorType(errorMessage);
await updateModelStatus('error', 0, errorMessage, errorType);
// Clean up failed instance
similarityEngine = null;
currentModelConfig = null;
throw error;
}
}
/**
* Clear vector data in IndexedDB
*/
async function clearVectorIndexedDB(): Promise<void> {
try {
// Clear vector search related IndexedDB databases
const dbNames = ['VectorSearchDB', 'ContentIndexerDB', 'SemanticSimilarityDB'];
for (const dbName of dbNames) {
try {
// Try to delete database
const deleteRequest = indexedDB.deleteDatabase(dbName);
await new Promise<void>((resolve, _reject) => {
deleteRequest.onsuccess = () => {
console.log(`Offscreen: Successfully deleted database: ${dbName}`);
resolve();
};
deleteRequest.onerror = () => {
console.warn(`Offscreen: Failed to delete database: ${dbName}`, deleteRequest.error);
resolve(); // 不阻塞其他数据库的清理
};
deleteRequest.onblocked = () => {
console.warn(`Offscreen: Database deletion blocked: ${dbName}`);
resolve(); // 不阻塞其他数据库的清理
};
});
} catch (error) {
console.warn(`Offscreen: Error deleting database ${dbName}:`, error);
}
}
} catch (error) {
console.error('Offscreen: Failed to clear vector IndexedDB:', error);
throw error;
}
}
// Analyze error type
function analyzeErrorType(errorMessage: string): 'network' | 'file' | 'unknown' {
const message = errorMessage.toLowerCase();
if (
message.includes('network') ||
message.includes('fetch') ||
message.includes('timeout') ||
message.includes('connection') ||
message.includes('cors') ||
message.includes('failed to fetch')
) {
return 'network';
}
if (
message.includes('corrupt') ||
message.includes('invalid') ||
message.includes('format') ||
message.includes('parse') ||
message.includes('decode') ||
message.includes('onnx')
) {
return 'file';
}
return 'unknown';
}
// Helper function to update model status
async function updateModelStatus(
status: string,
progress: number,
errorMessage?: string,
errorType?: string,
) {
try {
const modelState = {
status,
downloadProgress: progress,
isDownloading: status === 'downloading' || status === 'initializing',
lastUpdated: Date.now(),
errorMessage: errorMessage || '',
errorType: errorType || '',
};
// In offscreen document, update storage through message passing to background script
// because offscreen document may not have direct chrome.storage access
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
await chrome.storage.local.set({ modelState });
} else {
// If chrome.storage is not available, pass message to background script
console.log('Offscreen: chrome.storage not available, sending message to background');
try {
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.UPDATE_MODEL_STATUS,
modelState: modelState,
});
} catch (messageError) {
console.error('Offscreen: Failed to send status update message:', messageError);
}
}
} catch (error) {
console.error('Offscreen: Failed to update model status:', error);
}
}
/**
* Batch compute semantic similarity
*/
async function handleComputeSimilarityBatch(
pairs: { text1: string; text2: string }[],
options: Record<string, any> = {},
): Promise<number[]> {
if (!similarityEngine) {
throw new Error('Similarity engine not initialized. Please reinitialize the engine.');
}
console.log(`Offscreen: Computing similarities for ${pairs.length} pairs`);
const similarities = await similarityEngine.computeSimilarityBatch(pairs, options);
console.log('Offscreen: Similarity computation completed');
return similarities;
}
/**
* Get embedding vector for single text
*/
async function handleGetEmbedding(
text: string,
options: Record<string, any> = {},
): Promise<Float32Array> {
if (!similarityEngine) {
throw new Error('Similarity engine not initialized. Please reinitialize the engine.');
}
console.log(`Offscreen: Getting embedding for text: "${text.substring(0, 50)}..."`);
const embedding = await similarityEngine.getEmbedding(text, options);
console.log('Offscreen: Embedding computation completed');
return embedding;
}
/**
* Batch get embedding vectors for texts
*/
async function handleGetEmbeddingsBatch(
texts: string[],
options: Record<string, any> = {},
): Promise<Float32Array[]> {
if (!similarityEngine) {
throw new Error('Similarity engine not initialized. Please reinitialize the engine.');
}
console.log(`Offscreen: Getting embeddings for ${texts.length} texts`);
const embeddings = await similarityEngine.getEmbeddingsBatch(texts, options);
console.log('Offscreen: Batch embedding computation completed');
return embeddings;
}
/**
* Get engine status
*/
async function handleGetEngineStatus(): Promise<{
isInitialized: boolean;
currentConfig: any;
}> {
return {
isInitialized: !!similarityEngine,
currentConfig: currentModelConfig,
};
}
console.log('Offscreen: Semantic similarity engine handler loaded');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,287 @@
<template>
<div v-if="visible" class="confirmation-dialog" @click.self="$emit('cancel')">
<div class="dialog-content">
<div class="dialog-header">
<span class="dialog-icon">{{ icon }}</span>
<h3 class="dialog-title">{{ title }}</h3>
</div>
<div class="dialog-body">
<p class="dialog-message">{{ message }}</p>
<ul v-if="items && items.length > 0" class="dialog-list">
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
<div v-if="warning" class="dialog-warning">
<strong>{{ warning }}</strong>
</div>
</div>
<div class="dialog-actions">
<button class="dialog-button cancel-button" @click="$emit('cancel')">
{{ cancelText }}
</button>
<button
class="dialog-button confirm-button"
:disabled="isConfirming"
@click="$emit('confirm')"
>
{{ isConfirming ? confirmingText : confirmText }}
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { getMessage } from '@/utils/i18n';
interface Props {
visible: boolean;
title: string;
message: string;
items?: string[];
warning?: string;
icon?: string;
confirmText?: string;
cancelText?: string;
confirmingText?: string;
isConfirming?: boolean;
}
interface Emits {
(e: 'confirm'): void;
(e: 'cancel'): void;
}
withDefaults(defineProps<Props>(), {
icon: '⚠️',
confirmText: getMessage('confirmButton'),
cancelText: getMessage('cancelButton'),
confirmingText: getMessage('processingStatus'),
isConfirming: false,
});
defineEmits<Emits>();
</script>
<style scoped>
.confirmation-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(8px);
animation: dialogFadeIn 0.3s ease-out;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(8px);
}
}
.dialog-content {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 360px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: dialogSlideIn 0.3s ease-out;
border: 1px solid rgba(255, 255, 255, 0.2);
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translateY(-30px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.dialog-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.dialog-icon {
font-size: 24px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.dialog-title {
font-size: 18px;
font-weight: 600;
color: #2d3748;
margin: 0;
}
.dialog-body {
margin-bottom: 24px;
}
.dialog-message {
font-size: 14px;
color: #4a5568;
margin: 0 0 16px 0;
line-height: 1.6;
}
.dialog-list {
margin: 16px 0;
padding-left: 24px;
background: linear-gradient(135deg, #f7fafc, #edf2f7);
border-radius: 6px;
padding: 12px 12px 12px 32px;
border-left: 3px solid #667eea;
}
.dialog-list li {
font-size: 13px;
color: #718096;
margin-bottom: 6px;
line-height: 1.4;
}
.dialog-list li:last-child {
margin-bottom: 0;
}
.dialog-warning {
font-size: 13px;
color: #e53e3e;
margin: 16px 0 0 0;
padding: 12px;
background: linear-gradient(135deg, rgba(245, 101, 101, 0.1), rgba(229, 62, 62, 0.05));
border-radius: 6px;
border-left: 3px solid #e53e3e;
border: 1px solid rgba(245, 101, 101, 0.2);
}
.dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.dialog-button {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
}
.cancel-button {
background: linear-gradient(135deg, #e2e8f0, #cbd5e0);
color: #4a5568;
border: 1px solid #cbd5e0;
}
.cancel-button:hover {
background: linear-gradient(135deg, #cbd5e0, #a0aec0);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(160, 174, 192, 0.3);
}
.confirm-button {
background: linear-gradient(135deg, #f56565, #e53e3e);
color: white;
border: 1px solid #e53e3e;
}
.confirm-button:hover:not(:disabled) {
background: linear-gradient(135deg, #e53e3e, #c53030);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4);
}
.confirm-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 响应式设计 */
@media (max-width: 420px) {
.dialog-content {
padding: 20px;
max-width: 320px;
}
.dialog-header {
gap: 10px;
margin-bottom: 16px;
}
.dialog-icon {
font-size: 20px;
}
.dialog-title {
font-size: 16px;
}
.dialog-message {
font-size: 13px;
}
.dialog-list {
padding: 10px 10px 10px 28px;
}
.dialog-list li {
font-size: 12px;
}
.dialog-warning {
font-size: 12px;
padding: 10px;
}
.dialog-actions {
gap: 8px;
flex-direction: column-reverse;
}
.dialog-button {
width: 100%;
padding: 12px 16px;
}
}
/* 焦点样式 */
.dialog-button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
}
.cancel-button:focus {
box-shadow: 0 0 0 3px rgba(160, 174, 192, 0.3);
}
.confirm-button:focus {
box-shadow: 0 0 0 3px rgba(245, 101, 101, 0.3);
}
</style>

View File

@@ -0,0 +1,320 @@
<template>
<div class="model-cache-section">
<h2 class="section-title">{{ getMessage('modelCacheManagementLabel') }}</h2>
<!-- Cache Statistics Grid -->
<div class="stats-grid">
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('cacheSizeLabel') }}</p>
<span class="stats-icon orange">
<DatabaseIcon />
</span>
</div>
<p class="stats-value">{{ cacheStats?.totalSizeMB || 0 }} MB</p>
</div>
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('cacheEntriesLabel') }}</p>
<span class="stats-icon purple">
<VectorIcon />
</span>
</div>
<p class="stats-value">{{ cacheStats?.entryCount || 0 }}</p>
</div>
</div>
<!-- Cache Entries Details -->
<div v-if="cacheStats && cacheStats.entries.length > 0" class="cache-details">
<h3 class="cache-details-title">{{ getMessage('cacheDetailsLabel') }}</h3>
<div class="cache-entries">
<div v-for="entry in cacheStats.entries" :key="entry.url" class="cache-entry">
<div class="entry-info">
<div class="entry-url">{{ getModelNameFromUrl(entry.url) }}</div>
<div class="entry-details">
<span class="entry-size">{{ entry.sizeMB }} MB</span>
<span class="entry-age">{{ entry.age }}</span>
<span v-if="entry.expired" class="entry-expired">{{ getMessage('expiredLabel') }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- No Cache Message -->
<div v-else-if="cacheStats && cacheStats.entries.length === 0" class="no-cache">
<p>{{ getMessage('noCacheDataMessage') }}</p>
</div>
<!-- Loading State -->
<div v-else-if="!cacheStats" class="loading-cache">
<p>{{ getMessage('loadingCacheInfoStatus') }}</p>
</div>
<!-- Progress Indicator -->
<ProgressIndicator
v-if="isManagingCache"
:visible="isManagingCache"
:text="isManagingCache ? getMessage('processingCacheStatus') : ''"
:showSpinner="true"
/>
<!-- Action Buttons -->
<div class="cache-actions">
<div class="secondary-button" :disabled="isManagingCache" @click="$emit('cleanup-cache')">
<span class="stats-icon"><DatabaseIcon /></span>
<span>{{
isManagingCache ? getMessage('cleaningStatus') : getMessage('cleanExpiredCacheButton')
}}</span>
</div>
<div class="danger-button" :disabled="isManagingCache" @click="$emit('clear-all-cache')">
<span class="stats-icon"><TrashIcon /></span>
<span>{{ isManagingCache ? getMessage('clearingStatus') : getMessage('clearAllCacheButton') }}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import ProgressIndicator from './ProgressIndicator.vue';
import { DatabaseIcon, VectorIcon, TrashIcon } from './icons';
import { getMessage } from '@/utils/i18n';
interface CacheEntry {
url: string;
size: number;
sizeMB: number;
timestamp: number;
age: string;
expired: boolean;
}
interface CacheStats {
totalSize: number;
totalSizeMB: number;
entryCount: number;
entries: CacheEntry[];
}
interface Props {
cacheStats: CacheStats | null;
isManagingCache: boolean;
}
interface Emits {
(e: 'cleanup-cache'): void;
(e: 'clear-all-cache'): void;
}
defineProps<Props>();
defineEmits<Emits>();
const getModelNameFromUrl = (url: string) => {
// Extract model name from HuggingFace URL
const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)/);
if (match) {
return match[1];
}
return url.split('/').pop() || url;
};
</script>
<style scoped>
.model-cache-section {
margin-bottom: 24px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.stats-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 16px;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.stats-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stats-icon {
padding: 8px;
border-radius: 8px;
width: 36px;
height: 36px;
}
.stats-icon.orange {
background: #fed7aa;
color: #ea580c;
}
.stats-icon.purple {
background: #e9d5ff;
color: #9333ea;
}
.stats-value {
font-size: 30px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.cache-details {
margin-bottom: 16px;
}
.cache-details-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
}
.cache-entries {
display: flex;
flex-direction: column;
gap: 8px;
}
.cache-entry {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
}
.entry-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.entry-url {
font-weight: 500;
color: #1f2937;
font-size: 14px;
}
.entry-details {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
}
.entry-size {
background: #dbeafe;
color: #1e40af;
padding: 2px 6px;
border-radius: 4px;
}
.entry-age {
color: #6b7280;
}
.entry-expired {
background: #fee2e2;
color: #dc2626;
padding: 2px 6px;
border-radius: 4px;
}
.no-cache,
.loading-cache {
text-align: center;
color: #6b7280;
padding: 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 16px;
}
.cache-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.secondary-button {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
user-select: none;
cursor: pointer;
}
.secondary-button:hover:not(:disabled) {
background: #e2e8f0;
border-color: #94a3b8;
}
.secondary-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.danger-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: white;
border: 1px solid #d1d5db;
color: #374151;
font-weight: 600;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.danger-button:hover:not(:disabled) {
border-color: #ef4444;
color: #dc2626;
}
.danger-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div v-if="visible" class="progress-section">
<div class="progress-indicator">
<div class="spinner" v-if="showSpinner"></div>
<span class="progress-text">{{ text }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
interface Props {
visible?: boolean;
text: string;
showSpinner?: boolean;
}
withDefaults(defineProps<Props>(), {
visible: true,
showSpinner: true,
});
</script>
<style scoped>
.progress-section {
margin-top: 16px;
animation: slideIn 0.3s ease-out;
}
.progress-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1));
border-radius: 8px;
border-left: 4px solid #667eea;
backdrop-filter: blur(10px);
border: 1px solid rgba(102, 126, 234, 0.2);
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(102, 126, 234, 0.2);
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.progress-text {
font-size: 14px;
color: #4a5568;
font-weight: 500;
line-height: 1.4;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 420px) {
.progress-indicator {
padding: 12px;
gap: 8px;
}
.spinner {
width: 16px;
height: 16px;
border-width: 2px;
}
.progress-text {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
:class="className"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-default',
});
</script>

View File

@@ -0,0 +1,24 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
:class="className"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.052-.143Z"
clip-rule="evenodd"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-small',
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
:class="className"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-default',
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
:class="className"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-default',
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
:class="className"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-16.5 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-default',
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
:class="className"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-default',
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
:class="className"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 4.5a4.5 4.5 0 0 1 6 0M9 4.5V3a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 3v1.5M9 4.5a4.5 4.5 0 0 0-4.5 4.5v7.5A1.5 1.5 0 0 0 6 18h12a1.5 1.5 0 0 0 1.5-1.5V9a4.5 4.5 0 0 0-4.5-4.5M12 12l2.25 2.25M12 12l-2.25-2.25M12 12v6"
/>
</svg>
</template>
<script lang="ts" setup>
interface Props {
className?: string;
}
withDefaults(defineProps<Props>(), {
className: 'icon-default',
});
</script>

View File

@@ -0,0 +1,7 @@
export { default as DocumentIcon } from './DocumentIcon.vue';
export { default as DatabaseIcon } from './DatabaseIcon.vue';
export { default as BoltIcon } from './BoltIcon.vue';
export { default as TrashIcon } from './TrashIcon.vue';
export { default as CheckIcon } from './CheckIcon.vue';
export { default as TabIcon } from './TabIcon.vue';
export { default as VectorIcon } from './VectorIcon.vue';

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');

View File

@@ -0,0 +1,246 @@
/* 现代化全局样式 */
:root {
/* 字体系统 */
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
font-weight: 400;
/* 颜色系统 */
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary-color: #667eea;
--primary-dark: #5a67d8;
--secondary-color: #764ba2;
--success-color: #48bb78;
--warning-color: #ed8936;
--error-color: #f56565;
--info-color: #4299e1;
--text-primary: #2d3748;
--text-secondary: #4a5568;
--text-muted: #718096;
--text-light: #a0aec0;
--bg-primary: #ffffff;
--bg-secondary: #f7fafc;
--bg-tertiary: #edf2f7;
--bg-overlay: rgba(255, 255, 255, 0.95);
--border-color: #e2e8f0;
--border-light: #f1f5f9;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
/* 间距系统 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--spacing-3xl: 32px;
/* 圆角系统 */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
/* 动画 */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* 字体渲染优化 */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
/* 重置样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
padding: 0;
width: 400px;
min-height: 500px;
max-height: 600px;
overflow: hidden;
font-family: inherit;
background: var(--bg-secondary);
color: var(--text-primary);
}
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
/* 链接样式 */
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--primary-dark);
}
/* 按钮基础样式重置 */
button {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: none;
background: none;
cursor: pointer;
transition: all var(--transition-normal);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* 输入框基础样式 */
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-primary);
color: var(--text-primary);
transition: all var(--transition-fast);
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* 选择文本样式 */
::selection {
background: rgba(102, 126, 234, 0.2);
color: var(--text-primary);
}
/* 焦点可见性 */
:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* 动画关键帧 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 响应式断点 */
@media (max-width: 420px) {
:root {
--spacing-xs: 3px;
--spacing-sm: 6px;
--spacing-md: 10px;
--spacing-lg: 14px;
--spacing-xl: 18px;
--spacing-2xl: 22px;
--spacing-3xl: 28px;
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
:root {
--border-color: #000000;
--text-muted: #000000;
}
}
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}