Files
qwen-code/packages/chrome-qwen-bridge/extension/background/service-worker.js
2025-12-22 08:22:19 +08:00

1273 lines
37 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;
// Cache the latest available commands so late listeners (e.g. sidepanel opened later)
// can fetch them via GET_STATUS.
let lastAvailableCommands = [];
// Cache latest MCP tools list (from notifications/tools/list_changed)
let lastMcpTools = [];
// Static list of internal Chrome browser MCP tools exposed by this extension
const INTERNAL_MCP_TOOLS = [
{
name: 'browser_read_page',
description:
'Read content of the current active tab (url, title, text, links, images).',
},
{
name: 'browser_capture_screenshot',
description:
'Capture a screenshot (PNG) of the current visible tab (base64).',
},
{
name: 'browser_get_network_logs',
description:
'Get recent Network.* events from Chrome debugger for the active tab.',
},
{
name: 'browser_get_console_logs',
description: 'Get recent console logs from the active tab.',
},
];
// Heuristic: detect if user intent asks to read current page
function shouldTriggerReadPage(text) {
if (!text) return false;
const t = String(text).toLowerCase();
const keywords = [
'read this page',
'read the page',
'read current page',
'read page',
'读取当前页面',
'读取页面',
'读取网页',
'读这个页面',
];
return keywords.some((k) => t.includes(k));
}
// Heuristic: detect if user intent asks for console logs
function shouldTriggerConsoleLogs(text) {
if (!text) return false;
const t = String(text).toLowerCase();
const keywords = [
'console log',
'console logs',
'get console',
'show console',
'browser console',
'console 日志',
'console日志',
'控制台日志',
'获取控制台',
'查看控制台',
'读取日志',
'日志信息',
'获取日志',
'查看日志',
'log信息',
'错误日志',
'error log',
];
return keywords.some((k) => t.includes(k));
}
// Heuristic: detect if user intent asks for screenshot
function shouldTriggerScreenshot(text) {
if (!text) return false;
const t = String(text).toLowerCase();
const keywords = [
'screenshot',
'capture screen',
'take screenshot',
'screen capture',
'截图',
'截屏',
'屏幕截图',
'页面截图',
];
return keywords.some((k) => t.includes(k));
}
// Heuristic: detect if user intent asks for network logs
function shouldTriggerNetworkLogs(text) {
if (!text) return false;
const t = String(text).toLowerCase();
const keywords = [
'network log',
'network logs',
'network request',
'api call',
'http request',
'网络日志',
'网络请求',
'api请求',
'请求日志',
'接口请求',
'接口日志',
'xhr',
'fetch',
'请求记录',
'网络记录',
];
return keywords.some((k) => t.includes(k));
}
// 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);
}
// Heuristic: when a prompt completes, native returns a response with a stop reason.
// We use that as the end-of-stream signal so the UI can finalize the assistant message.
try {
if (
message?.data &&
(message.data.stopReason ||
message.data.stop_reason ||
message.data.status === 'done')
) {
broadcastToUI({ type: 'streamEnd' });
}
} catch (_) {
// ignore
}
} 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
// Default 30s, but ACP session creation and MCP discovery can take longer
let timeoutMs = 30000;
if (message && message.type === 'start_qwen') timeoutMs = 180000; // 3 minutes for startup + MCP discovery
if (
message &&
(message.type === 'qwen_prompt' || message.type === 'qwen_request')
)
timeoutMs = 180000;
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, timeoutMs);
});
}
// 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 {
// Notify UI tool start
try {
broadcastToUI({
type: 'toolProgress',
data: { name: requestType, stage: 'start' },
});
} catch (_) {}
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,
});
// Notify UI tool end (success)
try {
broadcastToUI({
type: 'toolProgress',
data: { name: requestType, stage: 'end', ok: true },
});
} catch (_) {}
} catch (error) {
console.error('Browser request error:', error);
nativePort.postMessage({
type: 'browser_response',
browserRequestId,
error: error.message,
});
// Notify UI tool end (failure)
try {
broadcastToUI({
type: 'toolProgress',
data: {
name: requestType,
stage: 'end',
ok: false,
error: String(error?.message || error),
},
});
} catch (_) {}
}
}
// 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 === 'available_commands_update') {
// Cache and forward available commands list to UI for visibility/debugging
lastAvailableCommands = Array.isArray(update.availableCommands)
? update.availableCommands
: [];
broadcastToUI({
type: 'availableCommands',
data: { availableCommands: lastAvailableCommands },
});
} else if (update?.sessionUpdate === 'user_message_chunk') {
// Ignore echo of the user's own message to avoid duplicates in UI.
// The sidepanel already appends the user message on submit.
// If needed in the future, we can gate this by a feature flag.
return;
} 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',
});
} else if (eventData?.type === 'auth_update') {
const authUri = eventData.authUri;
// Forward auth update to UI and try to open auth URL
broadcastToUI({ type: 'authUpdate', data: { authUri } });
if (authUri) {
try {
chrome.tabs.create({ url: authUri });
} catch (_) {}
}
} else if (eventData?.type === 'tools_list_changed') {
// Forward MCP tools list to UI and cache it
lastMcpTools = Array.isArray(eventData.tools) ? eventData.tools : [];
broadcastToUI({ type: 'mcpTools', data: { tools: lastMcpTools } });
} else if (eventData?.type === 'host_info') {
console.log('[Host] Info', eventData);
broadcastToUI({ type: 'hostInfo', data: eventData });
} else if (eventData?.type === 'cli_stderr') {
console.log('[Qwen STDERR]', eventData.line);
broadcastToUI({ type: 'hostLog', data: { line: eventData.line } });
}
// 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,
internalTools: INTERNAL_MCP_TOOLS,
});
// Broadcast internal tools so UI can render tools panel
try {
broadcastToUI({
type: 'internalMcpTools',
data: { tools: INTERNAL_MCP_TOOLS },
});
} catch (_) {}
})
.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,
availableCommands: lastAvailableCommands,
mcpTools: lastMcpTools,
internalTools: INTERNAL_MCP_TOOLS,
});
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') {
try {
await sendToNativeHost({
type: 'start_qwen',
cwd: request.data?.cwd || '/',
});
qwenCliStatus = 'running';
} catch (startError) {
// If CLI is already running (but session might still be initializing),
// treat it as running and continue
if (
startError.message &&
startError.message.includes('already running')
) {
console.log('Qwen CLI already running, continuing...');
qwenCliStatus = 'running';
} else {
throw startError;
}
}
}
// Fallback: if user intent asks to read page (and MCP might not be available),
// directly read the page via content script and send to Qwen for analysis.
try {
if (shouldTriggerReadPage(text)) {
broadcastToUI({
type: 'toolProgress',
data: { name: 'read_page', stage: 'start' },
});
const data = await getBrowserPageContent();
// start stream for qwen_request path
broadcastToUI({ type: 'streamStart' });
await sendToNativeHost({
type: 'qwen_request',
action: 'analyze_page',
data: data,
userPrompt: text, // Include user's full request for context
});
// do not send original prompt to avoid duplication
broadcastToUI({
type: 'toolProgress',
data: { name: 'read_page', stage: 'end', ok: true },
});
sendResponse({ success: true });
return;
}
} catch (e) {
console.warn('Fallback read_page failed:', e);
broadcastToUI({
type: 'toolProgress',
data: {
name: 'read_page',
stage: 'end',
ok: false,
error: String((e && e.message) || e),
},
});
// continue to send prompt normally
}
// Fallback: get console logs
try {
const shouldGetConsole = shouldTriggerConsoleLogs(text);
console.log(
'[Fallback] shouldTriggerConsoleLogs:',
shouldGetConsole,
'text:',
text,
);
if (shouldGetConsole) {
console.log('[Fallback] Triggering console logs...');
broadcastToUI({
type: 'toolProgress',
data: { name: 'console_logs', stage: 'start' },
});
const data = await getBrowserConsoleLogs();
console.log('[Fallback] Console logs data:', data);
const logs = data.logs || [];
const formatted = logs
.slice(-50)
.map((log) => `[${log.type}] ${log.message}`)
.join('\n');
broadcastToUI({ type: 'streamStart' });
await sendToNativeHost({
type: 'qwen_request',
action: 'process_text',
data: {
text: `Console logs (last ${Math.min(logs.length, 50)} entries):\n${formatted || '(no logs captured)'}`,
context: 'console logs from browser',
},
userPrompt: text, // Include user's full request
});
broadcastToUI({
type: 'toolProgress',
data: { name: 'console_logs', stage: 'end', ok: true },
});
sendResponse({ success: true });
return;
}
} catch (e) {
console.error('[Fallback] Console logs failed:', e);
broadcastToUI({
type: 'toolProgress',
data: {
name: 'console_logs',
stage: 'end',
ok: false,
error: String((e && e.message) || e),
},
});
}
// Fallback: capture screenshot
try {
if (shouldTriggerScreenshot(text)) {
broadcastToUI({
type: 'toolProgress',
data: { name: 'screenshot', stage: 'start' },
});
const screenshot = await getBrowserScreenshot();
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
});
broadcastToUI({ type: 'streamStart' });
await sendToNativeHost({
type: 'qwen_request',
action: 'analyze_screenshot',
data: {
dataUrl: screenshot.dataUrl,
url: tabs[0]?.url || 'unknown',
},
userPrompt: text, // Include user's full request
});
broadcastToUI({
type: 'toolProgress',
data: { name: 'screenshot', stage: 'end', ok: true },
});
sendResponse({ success: true });
return;
}
} catch (e) {
console.warn('Fallback screenshot failed:', e);
broadcastToUI({
type: 'toolProgress',
data: {
name: 'screenshot',
stage: 'end',
ok: false,
error: String((e && e.message) || e),
},
});
}
// Fallback: get network logs
try {
const shouldGetNetwork = shouldTriggerNetworkLogs(text);
console.log(
'[Fallback] shouldTriggerNetworkLogs:',
shouldGetNetwork,
'text:',
text,
);
if (shouldGetNetwork) {
console.log('[Fallback] Triggering network logs...');
broadcastToUI({
type: 'toolProgress',
data: { name: 'network_logs', stage: 'start' },
});
const logs = await getNetworkLogs(null);
console.log('[Fallback] Network logs count:', logs?.length);
const summary = logs.slice(-50).map((log) => ({
method: log.method,
url: log.params?.request?.url || log.params?.documentURL,
status: log.params?.response?.status,
timestamp: log.timestamp,
}));
broadcastToUI({ type: 'streamStart' });
await sendToNativeHost({
type: 'qwen_request',
action: 'process_text',
data: {
text: `Network logs (last ${summary.length} entries):\n${JSON.stringify(summary, null, 2)}`,
context: 'network request logs from browser',
},
userPrompt: text, // Include user's full request
});
broadcastToUI({
type: 'toolProgress',
data: { name: 'network_logs', stage: 'end', ok: true },
});
sendResponse({ success: true });
return;
}
} catch (e) {
console.error('[Fallback] Network logs failed:', e);
broadcastToUI({
type: 'toolProgress',
data: {
name: 'network_logs',
stage: 'end',
ok: false,
error: String((e && e.message) || e),
},
});
}
// Send the prompt with retry logic for session initialization
// Notify UI that a new stream is starting right before sending prompt
broadcastToUI({ type: 'streamStart' });
// Helper to send prompt with retries (session might still be initializing)
const sendPromptWithRetry = async (maxRetries = 3, delayMs = 2000) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await sendToNativeHost({
type: 'qwen_prompt',
text: text,
});
return; // Success
} catch (err) {
const isSessionError =
err.message &&
(err.message.includes('No active session') ||
err.message.includes('session'));
if (isSessionError && attempt < maxRetries) {
console.log(
`Session not ready, retry ${attempt}/${maxRetries} in ${delayMs}ms...`,
);
await new Promise((r) => setTimeout(r, delayMs));
} else {
throw err;
}
}
}
};
await sendPromptWithRetry();
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
const send = async () => {
try {
// Ensure native host connection
if (!isConnected) {
await connectToNativeHost();
}
// Ensure CLI is running
if (qwenCliStatus !== 'running') {
await sendToNativeHost({
type: 'start_qwen',
cwd: request.data?.cwd || '/',
});
qwenCliStatus = 'running';
}
// Inform UI that a stream is starting
try {
broadcastToUI({ type: 'streamStart' });
} catch (_) {}
const response = await sendToNativeHost({
type: 'qwen_request',
action: request.action,
data: request.data,
});
sendResponse({ success: true, data: response });
} catch (error) {
try {
broadcastToUI({
type: 'toolProgress',
data: {
name: request.action || 'request',
stage: 'end',
ok: false,
error: String(error?.message || error),
},
});
broadcastToUI({
type: 'error',
data: { message: String(error?.message || error) },
});
} catch (_) {}
const errMsg =
error && error && error.message
? error && error.message
: String(error);
sendResponse({ success: false, error: errMsg });
}
};
send();
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
}
if (request.type === 'GET_CONSOLE_LOGS') {
// Get console logs via content script
getBrowserConsoleLogs()
.then((res) => {
sendResponse({ success: true, data: res.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
}
// Ensure clicking the action icon opens the side panel (Chrome API)
try {
if (
chrome.sidePanel &&
typeof chrome.sidePanel.setPanelBehavior === 'function'
) {
// Open side panel when the action icon is clicked
// This is the recommended way in recent Chrome versions
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
}
} catch (e) {
console.warn('Failed to set side panel behavior:', e);
}
});
// Open side panel when extension icon is clicked
chrome.action.onClicked.addListener((tab) => {
try {
const openForWindow = (winId) => {
try {
chrome.sidePanel.open({ windowId: winId });
} catch (e) {
console.error('Failed to open side panel:', e);
}
};
if (tab && typeof tab.windowId === 'number') {
openForWindow(tab.windowId);
} else {
// Fallback: get current window and open
chrome.windows.getCurrent({}, (win) => {
if (win && typeof win.id === 'number') {
openForWindow(win.id);
} else {
console.error('No active window to open side panel');
}
});
}
} catch (e) {
console.error('onClicked handler error:', e);
}
});
// Also configure side panel behavior on startup (in addition to onInstalled)
try {
if (
chrome.sidePanel &&
typeof chrome.sidePanel.setPanelBehavior === 'function'
) {
chrome.runtime.onStartup.addListener(() => {
try {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
} catch (e) {
console.warn('setPanelBehavior onStartup failed:', e);
}
});
}
} catch (_) {}
// Export for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
connectToNativeHost,
sendToNativeHost,
};
}