diff --git a/package-lock.json b/package-lock.json index 7911e73d..85a932f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3142,6 +3142,10 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@qwen-code/chrome-bridge": { + "resolved": "packages/chrome-qwen-bridge", + "link": true + }, "node_modules/@qwen-code/qwen-code": { "resolved": "packages/cli", "link": true @@ -13864,7 +13868,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.26.0" @@ -17492,6 +17495,29 @@ "zod": "^3.24.1" } }, + "packages/chrome-qwen-bridge": { + "name": "@qwen-code/chrome-bridge", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "autoprefixer": "^10.4.22", + "esbuild": "^0.25.3", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "packages/cli": { "name": "@qwen-code/qwen-code", "version": "0.5.1", diff --git a/packages/chrome-qwen-bridge/extension/background/service-worker.js b/packages/chrome-qwen-bridge/extension/background/service-worker.js index 935848b9..95fc80cb 100644 --- a/packages/chrome-qwen-bridge/extension/background/service-worker.js +++ b/packages/chrome-qwen-bridge/extension/background/service-worker.js @@ -32,7 +32,14 @@ function connectToNativeHost() { } nativePort.onMessage.addListener((message) => { - console.log('Native message received:', 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); }); @@ -41,6 +48,7 @@ function connectToNativeHost() { console.log('Native host disconnected'); if (error) { console.error('Disconnect error:', error); + console.error('Disconnect error message:', error.message); } nativePort = null; isConnected = false; @@ -97,14 +105,35 @@ function handleNativeMessage(message) { delete nativePort._handshakeTimeout; } - qwenCliStatus = message.qwenStatus || 'connected'; + // 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 + 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); @@ -147,24 +176,226 @@ async function sendToNativeHost(message) { }); } -// Handle events from Qwen CLI -function handleQwenEvent(event) { - console.log('Qwen event:', event); +// Handle browser requests from Qwen CLI (via Native Host) +async function handleBrowserRequest(message) { + const { browserRequestId, requestType, params } = message; + console.log('Browser request:', requestType, params); - // Forward event to content scripts and popup + 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: event.data - }).catch(() => {}); // Ignore errors for tabs without content script + event: eventData + }).catch(() => {}); }); }); +} - chrome.runtime.sendMessage({ - type: 'QWEN_EVENT', - event: event.data - }).catch(() => {}); +// Broadcast message to all UI components (side panel, popup, etc.) +function broadcastToUI(message) { + chrome.runtime.sendMessage(message).catch(() => {}); } // Message handlers from extension components @@ -192,17 +423,114 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 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 }, (tabs) => { + chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { if (tabs[0]) { - chrome.tabs.sendMessage(tabs[0].id, { + 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 + error: chrome.runtime.lastError.message + '. Try refreshing the page.' }); } else { sendResponse(response); diff --git a/packages/chrome-qwen-bridge/extension/content/content-script.js b/packages/chrome-qwen-bridge/extension/content/content-script.js index b0cf3bb3..6dc6e232 100644 --- a/packages/chrome-qwen-bridge/extension/content/content-script.js +++ b/packages/chrome-qwen-bridge/extension/content/content-script.js @@ -389,6 +389,14 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { }); break; + case 'GET_CONSOLE_LOGS': + // Get captured console logs + sendResponse({ + success: true, + data: consoleLogs.slice() // Return a copy + }); + break; + case 'GET_SELECTED_TEXT': // Get currently selected text sendResponse({ diff --git a/packages/chrome-qwen-bridge/extension/popup/popup.js b/packages/chrome-qwen-bridge/extension/popup/popup.js index 2a799c55..008a7cd2 100644 --- a/packages/chrome-qwen-bridge/extension/popup/popup.js +++ b/packages/chrome-qwen-bridge/extension/popup/popup.js @@ -318,10 +318,33 @@ getSelectedBtn.addEventListener('click', async () => { 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 this page (browser internal page)'); + } + + // Try to inject content script first + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['content/content-script.js'] + }); + } catch (injectError) { + console.log('Script injection skipped:', injectError.message); + } + // Get selected text from content script - const response = await chrome.tabs.sendMessage(tab.id, { - type: 'GET_SELECTED_TEXT' - }); + let response; + try { + response = await chrome.tabs.sendMessage(tab.id, { + type: 'GET_SELECTED_TEXT' + }); + } catch (msgError) { + throw new Error('Cannot connect to page. Please refresh the page and try again.'); + } if (response.success && response.data) { // Send to Qwen CLI @@ -379,10 +402,33 @@ consoleLogsBtn.addEventListener('click', async () => { 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 this page (browser internal page)'); + } + + // Try to inject content script first + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['content/content-script.js'] + }); + } catch (injectError) { + console.log('Script injection skipped:', injectError.message); + } + // Get console logs from content script - const response = await chrome.tabs.sendMessage(tab.id, { - type: 'EXTRACT_DATA' - }); + let response; + try { + response = await chrome.tabs.sendMessage(tab.id, { + type: 'EXTRACT_DATA' + }); + } catch (msgError) { + throw new Error('Cannot connect to page. Please refresh the page and try again.'); + } if (response.success) { const consoleLogs = response.data.consoleLogs || []; diff --git a/packages/chrome-qwen-bridge/extension/sidepanel/sidepanel.html b/packages/chrome-qwen-bridge/extension/sidepanel/sidepanel.html index 902b7efb..d971f75d 100644 --- a/packages/chrome-qwen-bridge/extension/sidepanel/sidepanel.html +++ b/packages/chrome-qwen-bridge/extension/sidepanel/sidepanel.html @@ -3,138 +3,57 @@ - Qwen CLI Bridge - + Qwen Code + -
- -
- -
- - Disconnected -
-
- - -
-

Connection

-
- - -
- -
- - -
-

Quick Actions

-
- - - - - - - - - - - -
-
- - - - - -
-
- Advanced Settings -
-
- - -
-
- - -
-
- -
- -
-
-
- - - +
+
+
+
Loading Qwen Code...
+
- - + diff --git a/packages/chrome-qwen-bridge/native-host/host.js b/packages/chrome-qwen-bridge/native-host/host.js index 420a419b..c6c15f8f 100755 --- a/packages/chrome-qwen-bridge/native-host/host.js +++ b/packages/chrome-qwen-bridge/native-host/host.js @@ -10,6 +10,7 @@ const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const http = require('http'); // ============================================================================ // Logging @@ -36,12 +37,14 @@ function logDebug(message) { // ============================================================================ function sendMessageToExtension(message) { + log(`Sending to extension: ${JSON.stringify(message).slice(0, 100)}`); const buffer = Buffer.from(JSON.stringify(message)); const length = Buffer.allocUnsafe(4); length.writeUInt32LE(buffer.length, 0); process.stdout.write(length); process.stdout.write(buffer); + log('Message sent successfully'); } function readMessagesFromExtension() { @@ -126,12 +129,30 @@ class AcpConnection { try { log(`Starting Qwen CLI with ACP mode in ${cwd}`); - this.process = spawn('qwen', ['--experimental-acp'], { - cwd, - shell: true, - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'] - }); + // Chrome 环境没有用户 PATH,需要手动设置 + const env = { + ...process.env, + PATH: + '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:' + + (process.env.PATH || ''), + }; + + this.process = spawn( + '/Users/yiliang/.npm-global/bin/qwen', + [ + '--experimental-acp', + '--allowed-mcp-server-names', + 'chrome-browser', + '--debug', + ], + { + cwd, + env, + shell: true, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); if (!this.process || !this.process.pid) { this.process = null; @@ -169,7 +190,7 @@ class AcpConnection { sendMessageToExtension({ type: 'event', - data: { type: 'qwen_stopped', code } + data: { type: 'qwen_stopped', code }, }); }); @@ -199,8 +220,8 @@ class AcpConnection { status: 'running', pid: this.process.pid, sessionId: this.sessionId, - agentInfo: initResult.data.agentInfo - } + agentInfo: initResult.data.agentInfo, + }, }; } catch (error) { logError(`Failed to start Qwen CLI: ${error.message}`); @@ -265,8 +286,8 @@ class AcpConnection { data: { type: 'session_update', sessionId: params.sessionId, - update: params.update - } + update: params.update, + }, }); break; @@ -275,8 +296,8 @@ class AcpConnection { type: 'event', data: { type: 'auth_update', - authUri: params._meta?.authUri - } + authUri: params._meta?.authUri, + }, }); break; @@ -294,7 +315,7 @@ class AcpConnection { requestId: id, sessionId: params.sessionId, toolCall: params.toolCall, - options: params.options + options: params.options, }); break; @@ -308,9 +329,32 @@ class AcpConnection { this.handleFileWriteRequest(id, params); break; + // Browser MCP Tools + case 'browser/read_page': + // Get current page content from browser + this.handleBrowserReadPage(id, params); + break; + + case 'browser/capture_screenshot': + // Capture screenshot of current tab + this.handleBrowserCaptureScreenshot(id, params); + break; + + case 'browser/get_network_logs': + // Get network logs from browser + this.handleBrowserGetNetworkLogs(id, params); + break; + + case 'browser/get_console_logs': + // Get console logs from browser + this.handleBrowserGetConsoleLogs(id, params); + break; + default: log(`Unknown ACP request: ${method}`); - this.sendAcpResponse(id, { error: { code: -32601, message: 'Method not found' } }); + this.sendAcpResponse(id, { + error: { code: -32601, message: 'Method not found' }, + }); } } @@ -320,7 +364,7 @@ class AcpConnection { this.sendAcpResponse(id, { result: { content } }); } catch (err) { this.sendAcpResponse(id, { - error: { code: -32000, message: `Failed to read file: ${err.message}` } + error: { code: -32000, message: `Failed to read file: ${err.message}` }, }); } } @@ -331,7 +375,85 @@ class AcpConnection { this.sendAcpResponse(id, { result: null }); } catch (err) { this.sendAcpResponse(id, { - error: { code: -32000, message: `Failed to write file: ${err.message}` } + error: { + code: -32000, + message: `Failed to write file: ${err.message}`, + }, + }); + } + } + + // Browser request handlers + async handleBrowserReadPage(id, params) { + try { + const data = await sendBrowserRequest('read_page', params); + this.sendAcpResponse(id, { + result: { + url: data.url, + title: data.title, + content: data.content, + links: data.links, + images: data.images, + }, + }); + } catch (err) { + this.sendAcpResponse(id, { + error: { code: -32000, message: `Failed to read page: ${err.message}` }, + }); + } + } + + async handleBrowserCaptureScreenshot(id, params) { + try { + const data = await sendBrowserRequest('capture_screenshot', params); + this.sendAcpResponse(id, { + result: { + dataUrl: data.dataUrl, + format: 'png', + }, + }); + } catch (err) { + this.sendAcpResponse(id, { + error: { + code: -32000, + message: `Failed to capture screenshot: ${err.message}`, + }, + }); + } + } + + async handleBrowserGetNetworkLogs(id, params) { + try { + const data = await sendBrowserRequest('get_network_logs', params); + this.sendAcpResponse(id, { + result: { + logs: data.logs || [], + }, + }); + } catch (err) { + this.sendAcpResponse(id, { + error: { + code: -32000, + message: `Failed to get network logs: ${err.message}`, + }, + }); + } + } + + async handleBrowserGetConsoleLogs(id, params) { + try { + const data = await sendBrowserRequest('get_console_logs', params); + this.sendAcpResponse(id, { + result: { + logs: data.logs || [], + }, + }); + } catch (err) { + this.sendAcpResponse(id, { + error: { + code: -32000, + message: `Failed to get console logs: ${err.message}`, + }, }); } } @@ -356,7 +478,7 @@ class AcpConnection { jsonrpc: '2.0', id, method, - params + params, }); } catch (err) { this.pendingRequests.delete(id); @@ -377,7 +499,7 @@ class AcpConnection { this.sendAcpMessage({ jsonrpc: '2.0', id, - ...response + ...response, }); } @@ -385,7 +507,7 @@ class AcpConnection { this.sendAcpMessage({ jsonrpc: '2.0', method, - params + params, }); } @@ -396,9 +518,15 @@ class AcpConnection { clientCapabilities: { fs: { readTextFile: true, - writeTextFile: true - } - } + writeTextFile: true, + }, + browser: { + readPage: true, + captureScreenshot: true, + getNetworkLogs: true, + getConsoleLogs: true, + }, + }, }); log(`Qwen CLI initialized: ${JSON.stringify(result)}`); @@ -411,9 +539,28 @@ class AcpConnection { async createSession(cwd) { try { + // Get the path to browser-mcp-server.js + const browserMcpServerPath = path.join( + __dirname, + 'browser-mcp-server.js', + ); + + log(`Creating session with MCP server: ${browserMcpServerPath}`); + + const mcpServersConfig = [ + { + name: 'chrome-browser', + command: '/usr/local/bin/node', + args: [browserMcpServerPath], + env: [], + }, + ]; + + log(`MCP servers config: ${JSON.stringify(mcpServersConfig)}`); + const result = await this.sendAcpRequest('session/new', { cwd, - mcpServers: [] + mcpServers: mcpServersConfig, }); this.sessionId = result.sessionId; @@ -433,7 +580,7 @@ class AcpConnection { try { const result = await this.sendAcpRequest('session/prompt', { sessionId: this.sessionId, - prompt: [{ type: 'text', text }] + prompt: [{ type: 'text', text }], }); return { success: true, data: result }; @@ -450,7 +597,7 @@ class AcpConnection { try { this.sendAcpNotification('session/cancel', { - sessionId: this.sessionId + sessionId: this.sessionId, }); return { success: true }; } catch (err) { @@ -461,8 +608,10 @@ class AcpConnection { respondToPermission(requestId, optionId) { this.sendAcpResponse(requestId, { result: { - outcome: optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } - } + outcome: optionId + ? { outcome: 'selected', optionId } + : { outcome: 'cancelled' }, + }, }); } @@ -487,11 +636,59 @@ class AcpConnection { return { status: this.status, sessionId: this.sessionId, - pid: this.process?.pid || null + pid: this.process?.pid || null, }; } } +// ============================================================================ +// Browser Request Bridge (Native Host <-> Chrome Extension) +// ============================================================================ + +// Pending browser requests from Qwen CLI that need Chrome Extension responses +const pendingBrowserRequests = new Map(); +let browserRequestId = 0; + +/** + * Send a request to Chrome Extension and wait for response + */ +function sendBrowserRequest(requestType, params) { + return new Promise((resolve, reject) => { + const id = ++browserRequestId; + pendingBrowserRequests.set(id, { resolve, reject }); + + sendMessageToExtension({ + type: 'browser_request', + browserRequestId: id, + requestType, + params, + }); + + // Timeout after 30 seconds + setTimeout(() => { + if (pendingBrowserRequests.has(id)) { + pendingBrowserRequests.delete(id); + reject(new Error(`Browser request ${requestType} timed out`)); + } + }, 30000); + }); +} + +/** + * Handle browser response from Chrome Extension + */ +function handleBrowserResponse(message) { + const pending = pendingBrowserRequests.get(message.browserRequestId); + if (pending) { + pendingBrowserRequests.delete(message.browserRequestId); + if (message.error) { + pending.reject(new Error(message.error)); + } else { + pending.resolve(message.data); + } + } +} + // ============================================================================ // Global State // ============================================================================ @@ -502,10 +699,14 @@ const acpConnection = new AcpConnection(); async function checkQwenInstallation() { return new Promise((resolve) => { try { - const checkProcess = spawn('qwen', ['--version'], { - shell: true, - windowsHide: true - }); + const checkProcess = spawn( + '/Users/yiliang/.npm-global/bin/qwen', + ['--version'], + { + shell: true, + windowsHide: true, + }, + ); let output = ''; checkProcess.stdout.on('data', (data) => { @@ -538,19 +739,52 @@ async function checkQwenInstallation() { // Message Handlers // ============================================================================ +/** + * Build a prompt string from action and data + */ +function buildPromptFromAction(action, data) { + switch (action) { + case 'analyze_page': + return `Please analyze the following webpage data and provide insights:\n\nURL: ${data.url}\nTitle: ${data.title}\n\nContent:\n${data.content?.text || data.content?.markdown || 'No content available'}\n\nPlease provide a summary and any notable observations.`; + + case 'analyze_screenshot': + return `Please analyze the screenshot from this URL: ${data.url}\n\n[Screenshot data provided as base64 image]`; + + case 'ai_analyze': + return ( + data.prompt || + `Please analyze the following webpage:\n\nURL: ${data.pageData?.url}\nTitle: ${data.pageData?.title}\n\nContent:\n${data.pageData?.content?.text || 'No content available'}` + ); + + case 'process_text': + return `Please process the following ${data.context || 'text'}:\n\n${data.text}`; + + default: + // For unknown actions, just stringify the data + return `Action: ${action}\nData: ${JSON.stringify(data, null, 2)}`; + } +} + async function handleExtensionMessage(message) { log(`Received from extension: ${JSON.stringify(message)}`); + + // Handle browser response (async response from extension for browser requests) + if (message.type === 'browser_response') { + handleBrowserResponse(message); + return; + } + let response; switch (message.type) { case 'handshake': - const installInfo = await checkQwenInstallation(); + // 立即响应,不等待 qwen 版本检查 response = { type: 'handshake_response', version: '1.0.0', - qwenInstalled: installInfo.installed, - qwenVersion: installInfo.version, - qwenStatus: acpConnection.getStatus().status + qwenInstalled: true, // 假设已安装,后续会验证 + qwenVersion: 'checking...', + qwenStatus: acpConnection.getStatus().status, }; break; @@ -560,7 +794,7 @@ async function handleExtensionMessage(message) { response = { type: 'response', id: message.id, - ...startResult + ...startResult, }; break; @@ -569,7 +803,7 @@ async function handleExtensionMessage(message) { response = { type: 'response', id: message.id, - ...stopResult + ...stopResult, }; break; @@ -578,7 +812,7 @@ async function handleExtensionMessage(message) { response = { type: 'response', id: message.id, - ...promptResult + ...promptResult, }; break; @@ -587,7 +821,7 @@ async function handleExtensionMessage(message) { response = { type: 'response', id: message.id, - ...cancelResult + ...cancelResult, }; break; @@ -596,10 +830,31 @@ async function handleExtensionMessage(message) { response = { type: 'response', id: message.id, - success: true + success: true, }; break; + case 'qwen_request': + // Handle generic requests from extension (analyze_page, analyze_screenshot, etc.) + // Convert action + data to a prompt for Qwen CLI + const promptText = buildPromptFromAction(message.action, message.data); + if (acpConnection.status !== 'running') { + response = { + type: 'response', + id: message.id, + success: false, + error: 'Qwen CLI is not running. Please start it first.', + }; + } else { + const actionResult = await acpConnection.prompt(promptText); + response = { + type: 'response', + id: message.id, + ...actionResult, + }; + } + break; + case 'get_status': const status = acpConnection.getStatus(); const installStatus = await checkQwenInstallation(); @@ -609,8 +864,8 @@ async function handleExtensionMessage(message) { data: { ...status, qwenInstalled: installStatus.installed, - qwenVersion: installStatus.version - } + qwenVersion: installStatus.version, + }, }; break; @@ -618,13 +873,96 @@ async function handleExtensionMessage(message) { response = { type: 'response', id: message.id, - error: `Unknown message type: ${message.type}` + error: `Unknown message type: ${message.type}`, }; } sendMessageToExtension(response); } +// ============================================================================ +// HTTP Bridge Server (for browser-mcp-server.js to call) +// ============================================================================ + +const HTTP_PORT = 18765; +let httpServer = null; + +function startHttpBridgeServer() { + if (httpServer) return; + + httpServer = http.createServer(async (req, res) => { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', async () => { + try { + const request = JSON.parse(body); + log(`HTTP Bridge request: ${request.method}`); + + let result; + switch (request.method) { + case 'read_page': + result = await sendBrowserRequest( + 'read_page', + request.params || {}, + ); + break; + case 'capture_screenshot': + result = await sendBrowserRequest( + 'capture_screenshot', + request.params || {}, + ); + break; + case 'get_network_logs': + result = await sendBrowserRequest( + 'get_network_logs', + request.params || {}, + ); + break; + case 'get_console_logs': + result = await sendBrowserRequest( + 'get_console_logs', + request.params || {}, + ); + break; + default: + throw new Error(`Unknown method: ${request.method}`); + } + + res.writeHead(200); + res.end(JSON.stringify({ success: true, data: result })); + } catch (err) { + log(`HTTP Bridge error: ${err.message}`); + res.writeHead(500); + res.end(JSON.stringify({ success: false, error: err.message })); + } + }); + }); + + httpServer.listen(HTTP_PORT, '127.0.0.1', () => { + log(`HTTP Bridge server started on port ${HTTP_PORT}`); + }); + + httpServer.on('error', (err) => { + logError(`HTTP Bridge server error: ${err.message}`); + }); +} + // ============================================================================ // Cleanup // ============================================================================ @@ -632,6 +970,10 @@ async function handleExtensionMessage(message) { function cleanup() { log('Cleaning up...'); acpConnection.stop(); + if (httpServer) { + httpServer.close(); + httpServer = null; + } } process.on('SIGINT', () => { @@ -649,4 +991,5 @@ process.on('SIGTERM', () => { // ============================================================================ log('Native host started (ACP mode)'); +startHttpBridgeServer(); readMessagesFromExtension(); diff --git a/packages/chrome-qwen-bridge/package.json b/packages/chrome-qwen-bridge/package.json index 5ee9d8a7..dc7bc8c9 100644 --- a/packages/chrome-qwen-bridge/package.json +++ b/packages/chrome-qwen-bridge/package.json @@ -19,6 +19,7 @@ ], "author": "Qwen Team", "license": "Apache-2.0", + "type": "module", "files": [ "extension/", "native-host/", @@ -26,18 +27,33 @@ ], "scripts": { "dev": "./debug.sh", + "build:ui": "node esbuild.config.js", + "build:ui:watch": "node esbuild.config.js --watch", + "build": "npm run build:ui", "install:extension": "./first-install.sh", "install:host": "cd native-host && ./smart-install.sh", "install:all": "./first-install.sh", "dev:chrome": "open -a 'Google Chrome' --args --load-extension=$PWD/extension --auto-open-devtools-for-tabs", - "dev:server": "qwen server --port 8080", - "build": "./build.sh", "package": "zip -r chrome-qwen-bridge.zip extension/", "clean": "rm -rf dist *.zip /tmp/qwen-bridge-host.log /tmp/qwen-server.log .extension-id", - "logs": "tail -f /tmp/qwen-bridge-host.log", - "logs:qwen": "tail -f /tmp/qwen-server.log" + "logs": "tail -f /tmp/qwen-bridge-host.log" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" + }, + "dependencies": { + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "autoprefixer": "^10.4.22", + "esbuild": "^0.25.3", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/packages/chrome-qwen-bridge/tsconfig.json b/packages/chrome-qwen-bridge/tsconfig.json index b32f9e3b..3d3f5f00 100644 --- a/packages/chrome-qwen-bridge/tsconfig.json +++ b/packages/chrome-qwen-bridge/tsconfig.json @@ -1,16 +1,23 @@ { - "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./", - "composite": true + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, - "include": [ - "extension/**/*", - "native-host/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "extension"] +}