chore(chrome-qwen-bridge): connect & them

This commit is contained in:
yiliang114
2025-12-20 18:51:49 +08:00
parent a1f893f0c6
commit cc3cfb5d65
8 changed files with 910 additions and 217 deletions

28
package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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({

View File

@@ -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 || [];

View File

@@ -3,138 +3,57 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Qwen CLI Bridge</title>
<link rel="stylesheet" href="sidepanel.css">
<title>Qwen Code</title>
<style>
/* Base reset and full-height container */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #1e1e1e;
color: #e0e0e0;
}
/* Loading state */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #333;
border-top-color: #615fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: #888;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div class="logo">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<h1>Qwen CLI Bridge</h1>
</div>
<div class="status-indicator" id="statusIndicator">
<span class="status-dot"></span>
<span class="status-text">Disconnected</span>
</div>
</header>
<!-- Connection Section -->
<section class="section connection-section">
<h2>Connection</h2>
<div class="connection-controls">
<button id="connectBtn" class="btn btn-primary">
Connect to Qwen CLI
</button>
<button id="startQwenBtn" class="btn btn-secondary" disabled>
Start Qwen CLI
</button>
</div>
<div id="connectionError" class="error-message" style="display: none;"></div>
</section>
<!-- Actions Section -->
<section class="section actions-section">
<h2>Quick Actions</h2>
<div class="action-buttons">
<button id="extractDataBtn" class="action-btn" disabled>
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Extract Page Data
</button>
<button id="captureScreenBtn" class="action-btn" disabled>
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Capture Screenshot
</button>
<button id="analyzePageBtn" class="action-btn" disabled>
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Analyze with AI
</button>
<button id="getSelectedBtn" class="action-btn" disabled>
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Send Selected Text
</button>
<button id="networkLogsBtn" class="action-btn" disabled>
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
Network Logs
</button>
<button id="consoleLogsBtn" class="action-btn" disabled>
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Console Logs
</button>
</div>
</section>
<!-- Response Section -->
<section class="section response-section" id="responseSection" style="display: none;">
<h2>Response</h2>
<div class="response-container">
<div class="response-header">
<span id="responseType" class="response-type"></span>
<button id="copyResponseBtn" class="btn-icon" title="Copy to clipboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<pre id="responseContent" class="response-content"></pre>
</div>
</section>
<!-- Settings Section -->
<section class="section settings-section">
<details>
<summary>Advanced Settings</summary>
<div class="settings-content">
<div class="setting-item">
<label for="mcpServers">MCP Servers:</label>
<input type="text" id="mcpServers" placeholder="chrome-devtools,playwright" />
</div>
<div class="setting-item">
<label for="httpPort">HTTP Port:</label>
<input type="number" id="httpPort" placeholder="8080" value="8080" />
</div>
<div class="setting-item">
<label for="autoConnect">
<input type="checkbox" id="autoConnect" />
Auto-connect on startup
</label>
</div>
<button id="saveSettingsBtn" class="btn btn-small">Save Settings</button>
</div>
</details>
</section>
<!-- Footer -->
<footer class="footer">
<a href="#" id="openOptionsBtn">Options</a>
<span></span>
<a href="#" id="helpBtn">Help</a>
<span></span>
<span class="version">v1.0.0</span>
</footer>
<div id="root">
<div class="loading-container">
<div class="loading-spinner"></div>
<div class="loading-text">Loading Qwen Code...</div>
</div>
</div>
<script src="sidepanel.js"></script>
<script src="sidepanel-app.js"></script>
</body>
</html>

View File

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

View File

@@ -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"
}
}
}

View File

@@ -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"
]
}
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "extension"]
}