Files
qwen-code/packages/chrome-qwen-bridge/extension/background/service-worker.js
2025-12-20 18:51:49 +08:00

694 lines
20 KiB
JavaScript

/**
* Background Service Worker for Qwen CLI Bridge
* Handles communication between extension components and native host
*/
// Native messaging host name
const NATIVE_HOST_NAME = 'com.qwen.cli.bridge';
// Connection state
let nativePort = null;
let isConnected = false;
let qwenCliStatus = 'disconnected';
let pendingRequests = new Map();
let requestId = 0;
// Connection management
function connectToNativeHost() {
if (nativePort) {
return Promise.resolve(nativePort);
}
return new Promise((resolve, reject) => {
try {
console.log('Attempting to connect to Native Host:', NATIVE_HOST_NAME);
nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME);
// Check for immediate errors
if (chrome.runtime.lastError) {
console.error('Chrome runtime error:', chrome.runtime.lastError);
reject(new Error(chrome.runtime.lastError.message));
return;
}
nativePort.onMessage.addListener((message) => {
// 简化日志输出,直接显示 data 内容
if (message.type === 'event' && message.data) {
console.log('[Native Event]', message.data.type, message.data.update || message.data);
} else if (message.type === 'response') {
console.log('[Native Response]', 'id:', message.id, message.success ? '✓' : '✗', message.data || message.error);
} else {
console.log('[Native Message]', message.type, message.data || message);
}
handleNativeMessage(message);
});
nativePort.onDisconnect.addListener(() => {
const error = chrome.runtime.lastError;
console.log('Native host disconnected');
if (error) {
console.error('Disconnect error:', error);
console.error('Disconnect error message:', error.message);
}
nativePort = null;
isConnected = false;
qwenCliStatus = 'disconnected';
// Reject all pending requests
for (const [id, handler] of pendingRequests) {
handler.reject(new Error('Native host disconnected'));
}
pendingRequests.clear();
// Notify popup of disconnection
chrome.runtime.sendMessage({
type: 'STATUS_UPDATE',
status: 'disconnected'
}).catch(() => {}); // Ignore errors if popup is closed
});
// Send initial handshake
console.log('Sending handshake...');
nativePort.postMessage({ type: 'handshake', version: '1.0.0' });
// Set timeout for handshake response
const handshakeTimeout = setTimeout(() => {
console.error('Handshake timeout - no response from Native Host');
if (nativePort) {
nativePort.disconnect();
}
reject(new Error('Handshake timeout'));
}, 5000);
// Store timeout so we can clear it when we get response
nativePort._handshakeTimeout = handshakeTimeout;
isConnected = true;
qwenCliStatus = 'connected';
resolve(nativePort);
} catch (error) {
console.error('Failed to connect to native host:', error);
reject(error);
}
});
}
// Handle messages from native host
function handleNativeMessage(message) {
if (message.type === 'handshake_response') {
console.log('Handshake successful:', message);
// Clear handshake timeout
if (nativePort && nativePort._handshakeTimeout) {
clearTimeout(nativePort._handshakeTimeout);
delete nativePort._handshakeTimeout;
}
// Native host is connected, but Qwen CLI might not be running yet
// 'disconnected' from host means Qwen CLI is not running, but we ARE connected to native host
const hostQwenStatus = message.qwenStatus || 'disconnected';
// Set our status to 'connected' (to native host), or 'running' if Qwen CLI is already running
qwenCliStatus = hostQwenStatus === 'running' ? 'running' : 'connected';
// Notify popup of connection
chrome.runtime.sendMessage({
type: 'STATUS_UPDATE',
status: qwenCliStatus,
capabilities: message.capabilities,
qwenInstalled: message.qwenInstalled,
qwenVersion: message.qwenVersion
}).catch(() => {});
} else if (message.type === 'browser_request') {
// Handle browser requests from Qwen CLI via Native Host
handleBrowserRequest(message);
} else if (message.type === 'permission_request') {
// Forward permission request from Native Host to UI
console.log('[Permission Request]', message);
broadcastToUI({
type: 'permissionRequest',
data: {
requestId: message.requestId,
sessionId: message.sessionId,
toolCall: message.toolCall,
options: message.options
}
});
} else if (message.type === 'response' && message.id !== undefined) {
// Handle response to a specific request
const handler = pendingRequests.get(message.id);
if (handler) {
if (message.error) {
handler.reject(new Error(message.error));
} else {
handler.resolve(message.data);
}
pendingRequests.delete(message.id);
}
} else if (message.type === 'event') {
// Handle events from Qwen CLI
handleQwenEvent(message);
}
}
// Send request to native host
async function sendToNativeHost(message) {
if (!nativePort || !isConnected) {
await connectToNativeHost();
}
return new Promise((resolve, reject) => {
const id = ++requestId;
pendingRequests.set(id, { resolve, reject });
nativePort.postMessage({
...message,
id
});
// Set timeout for request
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000); // 30 second timeout
});
}
// Handle browser requests from Qwen CLI (via Native Host)
async function handleBrowserRequest(message) {
const { browserRequestId, requestType, params } = message;
console.log('Browser request:', requestType, params);
try {
let data;
switch (requestType) {
case 'read_page':
data = await getBrowserPageContent();
break;
case 'capture_screenshot':
data = await getBrowserScreenshot();
break;
case 'get_network_logs':
data = await getBrowserNetworkLogs();
break;
case 'get_console_logs':
data = await getBrowserConsoleLogs();
break;
default:
throw new Error(`Unknown browser request type: ${requestType}`);
}
// Send response back to native host
nativePort.postMessage({
type: 'browser_response',
browserRequestId,
data
});
} catch (error) {
console.error('Browser request error:', error);
nativePort.postMessage({
type: 'browser_response',
browserRequestId,
error: error.message
});
}
}
// Get current page content
async function getBrowserPageContent() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (!tab) {
throw new Error('No active tab found');
}
// Check if we can access this page
if (tab.url && (tab.url.startsWith('chrome://') ||
tab.url.startsWith('chrome-extension://') ||
tab.url.startsWith('edge://') ||
tab.url.startsWith('about:'))) {
throw new Error('Cannot access browser internal page');
}
// Try to inject content script
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content/content-script.js']
});
} catch (injectError) {
console.log('Script injection skipped:', injectError.message);
}
// Request page data from content script
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tab.id, { type: 'EXTRACT_DATA' }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message + '. Try refreshing the page.'));
} else if (response && response.success) {
resolve({
url: tab.url,
title: tab.title,
content: response.data?.content || { text: '', markdown: '' },
links: response.data?.links || [],
images: response.data?.images || []
});
} else {
reject(new Error(response?.error || 'Failed to extract page data'));
}
});
});
}
// Capture screenshot of current tab
async function getBrowserScreenshot() {
return new Promise((resolve, reject) => {
chrome.tabs.captureVisibleTab(null, { format: 'png' }, (dataUrl) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve({ dataUrl });
}
});
});
}
// Get network logs
async function getBrowserNetworkLogs() {
// Use the existing getNetworkLogs function
const logs = await getNetworkLogs(null);
return { logs };
}
// Get console logs (requires content script)
async function getBrowserConsoleLogs() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (!tab) {
throw new Error('No active tab found');
}
// Check if we can access this page
if (tab.url && (tab.url.startsWith('chrome://') ||
tab.url.startsWith('chrome-extension://') ||
tab.url.startsWith('edge://') ||
tab.url.startsWith('about:'))) {
throw new Error('Cannot access browser internal page');
}
// Try to inject content script
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content/content-script.js']
});
} catch (injectError) {
console.log('Script injection skipped:', injectError.message);
}
// Request console logs from content script
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tab.id, { type: 'GET_CONSOLE_LOGS' }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.success) {
resolve({ logs: response.data || [] });
} else {
reject(new Error(response?.error || 'Failed to get console logs'));
}
});
});
}
// Handle events from Qwen CLI (ACP events)
function handleQwenEvent(event) {
const eventData = event.data;
// 简化日志:显示事件类型和关键信息
if (eventData?.type === 'session_update') {
const update = eventData.update;
console.log('[Qwen]', update?.sessionUpdate, update?.content?.text?.slice(0, 50) || update);
} else {
console.log('[Qwen]', eventData?.type, eventData);
}
// Map ACP events to UI-compatible messages
if (eventData?.type === 'session_update') {
const update = eventData.update;
if (update?.sessionUpdate === 'agent_message_chunk') {
// Stream chunk
broadcastToUI({
type: 'streamChunk',
data: { chunk: update.content?.text || '' }
});
} else if (update?.sessionUpdate === 'user_message_chunk') {
// User message (usually echo)
broadcastToUI({
type: 'message',
data: {
role: 'user',
content: update.content?.text || '',
timestamp: Date.now()
}
});
} else if (update?.sessionUpdate === 'tool_call' || update?.sessionUpdate === 'tool_call_update') {
// Tool call
broadcastToUI({
type: 'toolCall',
data: update
});
} else if (update?.sessionUpdate === 'plan') {
// Plan update
broadcastToUI({
type: 'plan',
data: { entries: update.entries }
});
}
} else if (eventData?.type === 'qwen_stopped') {
qwenCliStatus = 'stopped';
broadcastToUI({
type: 'STATUS_UPDATE',
status: 'stopped'
});
}
// Also forward raw event for compatibility
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, {
type: 'QWEN_EVENT',
event: eventData
}).catch(() => {});
});
});
}
// Broadcast message to all UI components (side panel, popup, etc.)
function broadcastToUI(message) {
chrome.runtime.sendMessage(message).catch(() => {});
}
// Message handlers from extension components
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log('Message received:', request, 'from:', sender);
if (request.type === 'CONNECT') {
// Connect to native host
connectToNativeHost()
.then(() => {
sendResponse({ success: true, status: qwenCliStatus });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true; // Will respond asynchronously
}
if (request.type === 'GET_STATUS') {
// Get current connection status
sendResponse({
connected: isConnected,
status: qwenCliStatus
});
return false;
}
// Handle sendMessage from side panel (for chat)
if (request.type === 'sendMessage') {
const text = request.data?.text;
if (!text) {
sendResponse({ success: false, error: 'No text provided' });
return false;
}
// First ensure Qwen CLI is started
const startAndSend = async () => {
try {
// Check if connected
if (!isConnected) {
await connectToNativeHost();
}
// Start Qwen CLI if not running
if (qwenCliStatus !== 'running') {
broadcastToUI({ type: 'streamStart' });
await sendToNativeHost({
type: 'start_qwen',
cwd: request.data?.cwd || '/'
});
qwenCliStatus = 'running';
}
// Send the prompt
await sendToNativeHost({
type: 'qwen_prompt',
text: text
});
sendResponse({ success: true });
} catch (error) {
console.error('sendMessage error:', error);
broadcastToUI({
type: 'error',
data: { message: error.message }
});
sendResponse({ success: false, error: error.message });
}
};
startAndSend();
return true; // Will respond asynchronously
}
// Handle cancel streaming
if (request.type === 'cancelStreaming') {
sendToNativeHost({ type: 'qwen_cancel' })
.then(() => {
broadcastToUI({ type: 'streamEnd' });
sendResponse({ success: true });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true;
}
// Handle permission response
if (request.type === 'permissionResponse') {
sendToNativeHost({
type: 'permission_response',
requestId: request.data?.requestId,
optionId: request.data?.optionId
})
.then(() => sendResponse({ success: true }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true;
}
if (request.type === 'EXTRACT_PAGE_DATA') {
// Request page data from content script
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
if (tabs[0]) {
const tab = tabs[0];
// Check if we can inject content script (skip chrome:// and other protected pages)
if (tab.url && (tab.url.startsWith('chrome://') ||
tab.url.startsWith('chrome-extension://') ||
tab.url.startsWith('edge://') ||
tab.url.startsWith('about:'))) {
sendResponse({
success: false,
error: 'Cannot access this page (browser internal page)'
});
return;
}
// Try to inject content script first in case it's not loaded
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content/content-script.js']
});
} catch (injectError) {
// Script might already be injected or page doesn't allow injection
console.log('Script injection skipped:', injectError.message);
}
chrome.tabs.sendMessage(tab.id, {
type: 'EXTRACT_DATA'
}, (response) => {
if (chrome.runtime.lastError) {
sendResponse({
success: false,
error: chrome.runtime.lastError.message + '. Try refreshing the page.'
});
} else {
sendResponse(response);
}
});
} else {
sendResponse({
success: false,
error: 'No active tab found'
});
}
});
return true; // Will respond asynchronously
}
if (request.type === 'SEND_TO_QWEN') {
// Send data to Qwen CLI via native host
sendToNativeHost({
type: 'qwen_request',
action: request.action,
data: request.data
})
.then(response => {
sendResponse({ success: true, data: response });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true; // Will respond asynchronously
}
if (request.type === 'START_QWEN_CLI') {
// Request native host to start Qwen CLI
sendToNativeHost({
type: 'start_qwen',
config: request.config || {}
})
.then(response => {
qwenCliStatus = 'running';
sendResponse({ success: true, data: response });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true; // Will respond asynchronously
}
if (request.type === 'STOP_QWEN_CLI') {
// Request native host to stop Qwen CLI
sendToNativeHost({
type: 'stop_qwen'
})
.then(response => {
qwenCliStatus = 'stopped';
sendResponse({ success: true, data: response });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true; // Will respond asynchronously
}
if (request.type === 'CAPTURE_SCREENSHOT') {
// Capture screenshot of active tab
chrome.tabs.captureVisibleTab(null, { format: 'png' }, (dataUrl) => {
if (chrome.runtime.lastError) {
sendResponse({
success: false,
error: chrome.runtime.lastError.message
});
} else {
sendResponse({
success: true,
data: dataUrl
});
}
});
return true; // Will respond asynchronously
}
if (request.type === 'GET_NETWORK_LOGS') {
// Get network logs (requires debugger API)
getNetworkLogs(sender.tab?.id)
.then(logs => {
sendResponse({ success: true, data: logs });
})
.catch(error => {
sendResponse({ success: false, error: error.message });
});
return true; // Will respond asynchronously
}
});
// Network logging using debugger API
const debuggerTargets = new Map();
async function getNetworkLogs(tabId) {
if (!tabId) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
tabId = tabs[0]?.id;
if (!tabId) throw new Error('No active tab found');
}
// Check if debugger is already attached
if (!debuggerTargets.has(tabId)) {
await chrome.debugger.attach({ tabId }, '1.3');
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
// Store network logs
debuggerTargets.set(tabId, { logs: [] });
// Listen for network events
chrome.debugger.onEvent.addListener((source, method, params) => {
if (source.tabId === tabId) {
const target = debuggerTargets.get(tabId);
if (target && method.startsWith('Network.')) {
target.logs.push({
method,
params,
timestamp: Date.now()
});
}
}
});
}
const target = debuggerTargets.get(tabId);
return target?.logs || [];
}
// Clean up debugger on tab close
chrome.tabs.onRemoved.addListener((tabId) => {
if (debuggerTargets.has(tabId)) {
chrome.debugger.detach({ tabId });
debuggerTargets.delete(tabId);
}
});
// Listen for extension installation or update
chrome.runtime.onInstalled.addListener((details) => {
console.log('Extension installed/updated:', details);
if (details.reason === 'install') {
// Just log the installation, don't auto-open options
console.log('Extension installed for the first time');
// Users can access options from popup menu
}
});
// Open side panel when extension icon is clicked
chrome.action.onClicked.addListener((tab) => {
chrome.sidePanel.open({ windowId: tab.windowId });
});
// Export for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
connectToNativeHost,
sendToNativeHost
};
}