chore(chrome-qwen-bridge): connect

This commit is contained in:
yiliang114
2025-12-20 15:54:20 +08:00
parent a60c5c6697
commit a1f893f0c6
3 changed files with 546 additions and 323 deletions

View File

@@ -73,32 +73,18 @@ EOF
echo -e "${GREEN}${NC} Native Host 已配置"
# 第三步:检查 Qwen CLI(可选)
# 第三步:检查 Qwen CLI
echo -e "\n${BLUE}[3/5]${NC} 检查 Qwen CLI..."
QWEN_AVAILABLE=false
if command -v qwen &> /dev/null; then
QWEN_AVAILABLE=true
echo -e "${GREEN}${NC} Qwen CLI $(qwen --version 2>/dev/null || echo "已安装")"
# 尝试启动 Qwen server
if ! lsof -i:8080 &> /dev/null; then
echo -e "${CYAN}${NC} 启动 Qwen server (端口 8080)..."
qwen server --port 8080 > /tmp/qwen-server.log 2>&1 &
QWEN_PID=$!
sleep 2
if kill -0 $QWEN_PID 2>/dev/null; then
echo -e "${GREEN}${NC} Qwen server 已启动 (PID: $QWEN_PID)"
else
echo -e "${YELLOW}!${NC} Qwen server 启动失败,继续运行..."
QWEN_AVAILABLE=false
fi
else
echo -e "${YELLOW}!${NC} 端口 8080 已被占用"
fi
QWEN_VERSION=$(qwen --version 2>/dev/null || echo "已安装")
echo -e "${GREEN}${NC} Qwen CLI ${QWEN_VERSION}"
echo -e "${CYAN}${NC} 使用 ACP 模式与 Chrome 插件通信"
else
echo -e "${YELLOW}!${NC} Qwen CLI 未安装(插件基础功能仍可使用)"
echo -e " 安装方法: npm install -g @anthropic-ai/qwen-code"
fi
# 第四步:启动测试页面
@@ -338,7 +324,7 @@ echo -e " • 测试页面: ${BLUE}http://localhost:3000/qwen-test.html${NC}"
echo -e " • 插件: 已加载到工具栏"
if [ "$QWEN_AVAILABLE" = true ]; then
echo -e " • Qwen Server: ${BLUE}http://localhost:8080${NC}"
echo -e " • Qwen CLI: 可用 (ACP 模式)"
fi
echo ""
@@ -347,10 +333,6 @@ echo -e " • 插件日志: Chrome DevTools Console"
echo -e " • 后台脚本: chrome://extensions → Service Worker"
echo -e " • Native Host: /tmp/qwen-bridge-host.log"
if [ "$QWEN_AVAILABLE" = true ]; then
echo -e " • Qwen 日志: /tmp/qwen-server.log"
fi
echo ""
echo -e "${YELLOW}按 Ctrl+C 停止所有服务${NC}"
echo ""
@@ -361,7 +343,6 @@ cleanup() {
# 停止进程
[ ! -z "$TEST_PID" ] && kill $TEST_PID 2>/dev/null
[ ! -z "$QWEN_PID" ] && kill $QWEN_PID 2>/dev/null
echo -e "${GREEN}${NC} 已停止所有服务"
exit 0

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# 添加必要的 PATH
export PATH="/usr/local/bin:/Users/yiliang/.npm-global/bin:$PATH"
LOG="/var/folders/sy/9mwf8c3n2b57__q35fyxwdhh0000gp/T/qwen-wrapper.log"
echo "$(date): Wrapper started" >> "$LOG"
echo "$(date): PATH=$PATH" >> "$LOG"
# 使用完整路径运行 node
exec /usr/local/bin/node "$(dirname "$0")/host.js" 2>> "$LOG"

View File

@@ -3,6 +3,7 @@
/**
* Native Messaging Host for Qwen CLI Bridge
* This script acts as a bridge between the Chrome extension and Qwen CLI
* Uses ACP (Agent Communication Protocol) for communication with Qwen CLI
*/
const { spawn } = require('child_process');
@@ -10,8 +11,31 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
// Native Messaging protocol helpers
function sendMessage(message) {
// ============================================================================
// Logging
// ============================================================================
const LOG_FILE = path.join(os.tmpdir(), 'qwen-bridge-host.log');
function log(message, level = 'INFO') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] [${level}] ${message}\n`;
fs.appendFileSync(LOG_FILE, logLine);
}
function logError(message) {
log(message, 'ERROR');
}
function logDebug(message) {
log(message, 'DEBUG');
}
// ============================================================================
// Native Messaging Protocol (Chrome Extension <-> Native Host)
// ============================================================================
function sendMessageToExtension(message) {
const buffer = Buffer.from(JSON.stringify(message));
const length = Buffer.allocUnsafe(4);
length.writeUInt32LE(buffer.length, 0);
@@ -20,56 +44,462 @@ function sendMessage(message) {
process.stdout.write(buffer);
}
function readMessages() {
function readMessagesFromExtension() {
let messageLength = null;
let chunks = [];
process.stdin.on('readable', () => {
let chunk;
// Keep stdin open and in flowing mode
process.stdin.resume();
while ((chunk = process.stdin.read()) !== null) {
chunks.push(chunk);
process.stdin.on('data', (chunk) => {
log(`Received ${chunk.length} bytes from extension`);
chunks.push(chunk);
while (true) {
const buffer = Buffer.concat(chunks);
// Read message length if we haven't yet
// Need at least 4 bytes for length
if (messageLength === null) {
if (buffer.length >= 4) {
messageLength = buffer.readUInt32LE(0);
chunks = [buffer.slice(4)];
}
if (buffer.length < 4) break;
messageLength = buffer.readUInt32LE(0);
chunks = [buffer.slice(4)];
log(`Message length: ${messageLength}`);
continue;
}
// Read message if we have the full length
if (messageLength !== null) {
const fullBuffer = Buffer.concat(chunks);
// Check if we have the full message
const fullBuffer = Buffer.concat(chunks);
if (fullBuffer.length < messageLength) break;
if (fullBuffer.length >= messageLength) {
const messageBuffer = fullBuffer.slice(0, messageLength);
const message = JSON.parse(messageBuffer.toString());
// Extract and parse message
const messageBuffer = fullBuffer.slice(0, messageLength);
try {
const message = JSON.parse(messageBuffer.toString());
log(`Received message: ${JSON.stringify(message)}`);
// Reset for next message
chunks = [fullBuffer.slice(messageLength)];
messageLength = null;
// Reset for next message
chunks = [fullBuffer.slice(messageLength)];
messageLength = null;
// Handle the message
handleMessage(message);
}
// Handle the message
handleExtensionMessage(message);
} catch (err) {
logError(`Failed to parse message: ${err.message}`);
chunks = [fullBuffer.slice(messageLength)];
messageLength = null;
}
}
});
process.stdin.on('end', () => {
log('stdin ended');
cleanup();
process.exit();
});
process.stdin.on('error', (err) => {
logError(`stdin error: ${err.message}`);
});
}
// Qwen CLI process management
let qwenProcess = null;
let qwenStatus = 'disconnected';
let qwenCapabilities = [];
// ============================================================================
// ACP Protocol (Native Host <-> Qwen CLI)
// ============================================================================
const ACP_PROTOCOL_VERSION = 1;
class AcpConnection {
constructor() {
this.process = null;
this.status = 'disconnected';
this.sessionId = null;
this.pendingRequests = new Map();
this.nextRequestId = 1;
this.inputBuffer = '';
}
async start(cwd = process.cwd()) {
if (this.process) {
return { success: false, error: 'Qwen CLI is already running' };
}
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']
});
if (!this.process || !this.process.pid) {
this.process = null;
this.status = 'stopped';
return { success: false, error: 'Failed to start Qwen CLI process' };
}
this.status = 'starting';
// Handle stdout (ACP messages from Qwen CLI)
this.process.stdout.on('data', (data) => {
this.handleAcpData(data.toString());
});
// Handle stderr (logs from Qwen CLI)
this.process.stderr.on('data', (data) => {
const message = data.toString().trim();
if (message) {
log(`Qwen stderr: ${message}`);
}
});
// Handle process exit
this.process.on('close', (code) => {
log(`Qwen CLI exited with code ${code}`);
this.process = null;
this.status = 'stopped';
this.sessionId = null;
// Reject all pending requests
for (const [id, { reject }] of this.pendingRequests) {
reject(new Error('Qwen CLI process exited'));
}
this.pendingRequests.clear();
sendMessageToExtension({
type: 'event',
data: { type: 'qwen_stopped', code }
});
});
this.process.on('error', (err) => {
logError(`Qwen CLI process error: ${err.message}`);
this.status = 'error';
});
// Initialize ACP connection
const initResult = await this.initialize();
if (!initResult.success) {
this.stop();
return initResult;
}
// Create a new session
const sessionResult = await this.createSession(cwd);
if (!sessionResult.success) {
this.stop();
return sessionResult;
}
this.status = 'running';
return {
success: true,
data: {
status: 'running',
pid: this.process.pid,
sessionId: this.sessionId,
agentInfo: initResult.data.agentInfo
}
};
} catch (error) {
logError(`Failed to start Qwen CLI: ${error.message}`);
return { success: false, error: error.message };
}
}
handleAcpData(data) {
this.inputBuffer += data;
const lines = this.inputBuffer.split('\n');
this.inputBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed);
this.handleAcpMessage(message);
} catch (err) {
logError(`Failed to parse ACP message: ${trimmed}`);
}
}
}
handleAcpMessage(message) {
logDebug(`ACP received: ${JSON.stringify(message)}`);
// Handle response to our request
if ('id' in message && !('method' in message)) {
const pending = this.pendingRequests.get(message.id);
if (pending) {
this.pendingRequests.delete(message.id);
if ('result' in message) {
pending.resolve(message.result);
} else if ('error' in message) {
pending.reject(new Error(message.error.message || 'ACP error'));
}
}
return;
}
// Handle notification from Qwen CLI
if ('method' in message && !('id' in message)) {
this.handleAcpNotification(message.method, message.params);
return;
}
// Handle request from Qwen CLI (e.g., permission requests)
if ('method' in message && 'id' in message) {
this.handleAcpRequest(message.id, message.method, message.params);
return;
}
}
handleAcpNotification(method, params) {
switch (method) {
case 'session/update':
// Forward session updates to the extension
sendMessageToExtension({
type: 'event',
data: {
type: 'session_update',
sessionId: params.sessionId,
update: params.update
}
});
break;
case 'authenticate/update':
sendMessageToExtension({
type: 'event',
data: {
type: 'auth_update',
authUri: params._meta?.authUri
}
});
break;
default:
log(`Unknown ACP notification: ${method}`);
}
}
handleAcpRequest(id, method, params) {
switch (method) {
case 'session/request_permission':
// Forward permission request to extension
sendMessageToExtension({
type: 'permission_request',
requestId: id,
sessionId: params.sessionId,
toolCall: params.toolCall,
options: params.options
});
break;
case 'fs/read_text_file':
// Handle file read request
this.handleFileReadRequest(id, params);
break;
case 'fs/write_text_file':
// Handle file write request
this.handleFileWriteRequest(id, params);
break;
default:
log(`Unknown ACP request: ${method}`);
this.sendAcpResponse(id, { error: { code: -32601, message: 'Method not found' } });
}
}
handleFileReadRequest(id, params) {
try {
const content = fs.readFileSync(params.path, 'utf-8');
this.sendAcpResponse(id, { result: { content } });
} catch (err) {
this.sendAcpResponse(id, {
error: { code: -32000, message: `Failed to read file: ${err.message}` }
});
}
}
handleFileWriteRequest(id, params) {
try {
fs.writeFileSync(params.path, params.content, 'utf-8');
this.sendAcpResponse(id, { result: null });
} catch (err) {
this.sendAcpResponse(id, {
error: { code: -32000, message: `Failed to write file: ${err.message}` }
});
}
}
sendAcpMessage(message) {
if (!this.process || !this.process.stdin.writable) {
throw new Error('Qwen CLI is not running');
}
const json = JSON.stringify(message) + '\n';
logDebug(`ACP send: ${json.trim()}`);
this.process.stdin.write(json);
}
sendAcpRequest(method, params) {
return new Promise((resolve, reject) => {
const id = this.nextRequestId++;
this.pendingRequests.set(id, { resolve, reject });
try {
this.sendAcpMessage({
jsonrpc: '2.0',
id,
method,
params
});
} catch (err) {
this.pendingRequests.delete(id);
reject(err);
}
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}
}, 30000);
});
}
sendAcpResponse(id, response) {
this.sendAcpMessage({
jsonrpc: '2.0',
id,
...response
});
}
sendAcpNotification(method, params) {
this.sendAcpMessage({
jsonrpc: '2.0',
method,
params
});
}
async initialize() {
try {
const result = await this.sendAcpRequest('initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true
}
}
});
log(`Qwen CLI initialized: ${JSON.stringify(result)}`);
return { success: true, data: result };
} catch (err) {
logError(`Failed to initialize Qwen CLI: ${err.message}`);
return { success: false, error: err.message };
}
}
async createSession(cwd) {
try {
const result = await this.sendAcpRequest('session/new', {
cwd,
mcpServers: []
});
this.sessionId = result.sessionId;
log(`Session created: ${this.sessionId}`);
return { success: true, data: result };
} catch (err) {
logError(`Failed to create session: ${err.message}`);
return { success: false, error: err.message };
}
}
async prompt(text) {
if (!this.sessionId) {
return { success: false, error: 'No active session' };
}
try {
const result = await this.sendAcpRequest('session/prompt', {
sessionId: this.sessionId,
prompt: [{ type: 'text', text }]
});
return { success: true, data: result };
} catch (err) {
logError(`Prompt failed: ${err.message}`);
return { success: false, error: err.message };
}
}
async cancel() {
if (!this.sessionId) {
return { success: false, error: 'No active session' };
}
try {
this.sendAcpNotification('session/cancel', {
sessionId: this.sessionId
});
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
respondToPermission(requestId, optionId) {
this.sendAcpResponse(requestId, {
result: {
outcome: optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }
}
});
}
stop() {
if (!this.process) {
return { success: false, error: 'Qwen CLI is not running' };
}
try {
this.process.kill('SIGTERM');
this.process = null;
this.status = 'stopped';
this.sessionId = null;
return { success: true, data: 'Qwen CLI stopped' };
} catch (error) {
return { success: false, error: error.message };
}
}
getStatus() {
return {
status: this.status,
sessionId: this.sessionId,
pid: this.process?.pid || null
};
}
}
// ============================================================================
// Global State
// ============================================================================
const acpConnection = new AcpConnection();
// Check if Qwen CLI is installed
function checkQwenInstallation() {
async function checkQwenInstallation() {
return new Promise((resolve) => {
try {
const checkProcess = spawn('qwen', ['--version'], {
@@ -77,255 +507,56 @@ function checkQwenInstallation() {
windowsHide: true
});
let output = '';
checkProcess.stdout.on('data', (data) => {
output += data.toString();
});
checkProcess.on('error', () => {
resolve(false);
resolve({ installed: false });
});
checkProcess.on('close', (code) => {
resolve(code === 0);
if (code === 0) {
resolve({ installed: true, version: output.trim() });
} else {
resolve({ installed: false });
}
});
// Timeout after 5 seconds
setTimeout(() => {
checkProcess.kill();
resolve(false);
resolve({ installed: false });
}, 5000);
} catch (error) {
resolve(false);
resolve({ installed: false });
}
});
}
// Start Qwen CLI process
async function startQwenCli(config = {}) {
if (qwenProcess) {
return { success: false, error: 'Qwen CLI is already running' };
}
// ============================================================================
// Message Handlers
// ============================================================================
try {
// Build command arguments
const args = [];
// Add MCP servers if specified
if (config.mcpServers && config.mcpServers.length > 0) {
for (const server of config.mcpServers) {
args.push('mcp', 'add', '--transport', 'http', server, `http://localhost:${config.httpPort || 8080}/mcp/${server}`);
args.push('&&');
}
}
// Start the CLI server
args.push('qwen', 'server');
if (config.httpPort) {
args.push('--port', String(config.httpPort));
}
// Spawn the process
qwenProcess = spawn(args.join(' '), {
shell: true,
windowsHide: true,
detached: false,
stdio: ['pipe', 'pipe', 'pipe']
});
// Check if process started successfully
if (!qwenProcess || !qwenProcess.pid) {
qwenProcess = null;
qwenStatus = 'stopped';
return {
success: false,
error: 'Failed to start Qwen CLI process'
};
}
qwenStatus = 'running';
// Handle process output
qwenProcess.stdout.on('data', (data) => {
const output = data.toString();
sendMessage({
type: 'event',
data: {
type: 'qwen_output',
content: output
}
});
});
qwenProcess.stderr.on('data', (data) => {
const error = data.toString();
sendMessage({
type: 'event',
data: {
type: 'qwen_error',
content: error
}
});
});
qwenProcess.on('close', (code) => {
qwenProcess = null;
qwenStatus = 'stopped';
sendMessage({
type: 'event',
data: {
type: 'qwen_stopped',
code: code
}
});
});
// Wait a bit for the process to start
await new Promise(resolve => setTimeout(resolve, 2000));
// Get capabilities
qwenCapabilities = await getQwenCapabilities();
return {
success: true,
data: {
status: 'running',
pid: qwenProcess && qwenProcess.pid ? qwenProcess.pid : null,
capabilities: qwenCapabilities
}
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
// Stop Qwen CLI process
function stopQwenCli() {
if (!qwenProcess) {
return { success: false, error: 'Qwen CLI is not running' };
}
try {
qwenProcess.kill('SIGTERM');
qwenProcess = null;
qwenStatus = 'stopped';
return {
success: true,
data: 'Qwen CLI stopped'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
// Get Qwen CLI capabilities (MCP servers, tools, etc.)
async function getQwenCapabilities() {
return new Promise((resolve) => {
const checkProcess = spawn('qwen', ['mcp', 'list', '--json'], {
shell: true,
windowsHide: true
});
let output = '';
checkProcess.stdout.on('data', (data) => {
output += data.toString();
});
checkProcess.on('close', () => {
try {
const capabilities = JSON.parse(output);
resolve(capabilities);
} catch {
resolve([]);
}
});
checkProcess.on('error', () => {
resolve([]);
});
// Timeout after 5 seconds
setTimeout(() => {
checkProcess.kill();
resolve([]);
}, 5000);
});
}
// Send request to Qwen CLI via HTTP
async function sendToQwenHttp(action, data, config = {}) {
const http = require('http');
const port = config.httpPort || 8080;
const hostname = 'localhost';
const postData = JSON.stringify({
action,
data
});
const options = {
hostname,
port,
path: '/api/process',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(responseData);
resolve(response);
} catch (error) {
reject(new Error('Invalid response from Qwen CLI'));
}
});
});
req.on('error', (error) => {
reject(error);
});
req.write(postData);
req.end();
});
}
// Handle messages from Chrome extension
async function handleMessage(message) {
async function handleExtensionMessage(message) {
log(`Received from extension: ${JSON.stringify(message)}`);
let response;
switch (message.type) {
case 'handshake':
// Initial handshake with extension
const isInstalled = await checkQwenInstallation();
const installInfo = await checkQwenInstallation();
response = {
type: 'handshake_response',
version: '1.0.0',
qwenInstalled: isInstalled,
qwenStatus: qwenStatus,
capabilities: qwenCapabilities
qwenInstalled: installInfo.installed,
qwenVersion: installInfo.version,
qwenStatus: acpConnection.getStatus().status
};
break;
case 'start_qwen':
// Start Qwen CLI
const startResult = await startQwenCli(message.config);
const cwd = message.cwd || process.cwd();
const startResult = await acpConnection.start(cwd);
response = {
type: 'response',
id: message.id,
@@ -334,8 +565,7 @@ async function handleMessage(message) {
break;
case 'stop_qwen':
// Stop Qwen CLI
const stopResult = stopQwenCli();
const stopResult = acpConnection.stop();
response = {
type: 'response',
id: message.id,
@@ -343,43 +573,43 @@ async function handleMessage(message) {
};
break;
case 'qwen_request':
// Send request to Qwen CLI
try {
if (qwenStatus !== 'running') {
throw new Error('Qwen CLI is not running');
}
case 'qwen_prompt':
const promptResult = await acpConnection.prompt(message.text);
response = {
type: 'response',
id: message.id,
...promptResult
};
break;
const qwenResponse = await sendToQwenHttp(
message.action,
message.data,
message.config
);
case 'qwen_cancel':
const cancelResult = await acpConnection.cancel();
response = {
type: 'response',
id: message.id,
...cancelResult
};
break;
response = {
type: 'response',
id: message.id,
data: qwenResponse
};
} catch (error) {
response = {
type: 'response',
id: message.id,
error: error.message
};
}
case 'permission_response':
acpConnection.respondToPermission(message.requestId, message.optionId);
response = {
type: 'response',
id: message.id,
success: true
};
break;
case 'get_status':
// Get current status
const status = acpConnection.getStatus();
const installStatus = await checkQwenInstallation();
response = {
type: 'response',
id: message.id,
data: {
qwenInstalled: await checkQwenInstallation(),
qwenStatus: qwenStatus,
qwenPid: qwenProcess ? qwenProcess.pid : null,
capabilities: qwenCapabilities
...status,
qwenInstalled: installStatus.installed,
qwenVersion: installStatus.version
}
};
break;
@@ -392,30 +622,31 @@ async function handleMessage(message) {
};
}
sendMessage(response);
sendMessageToExtension(response);
}
// ============================================================================
// Cleanup
// ============================================================================
function cleanup() {
log('Cleaning up...');
acpConnection.stop();
}
// Clean up on exit
process.on('SIGINT', () => {
if (qwenProcess) {
qwenProcess.kill();
}
cleanup();
process.exit();
});
process.on('SIGTERM', () => {
if (qwenProcess) {
qwenProcess.kill();
}
cleanup();
process.exit();
});
// Log function for debugging (writes to a file since stdout is used for messaging)
function log(message) {
const logFile = path.join(os.tmpdir(), 'qwen-bridge-host.log');
fs.appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`);
}
// ============================================================================
// Main
// ============================================================================
// Main execution
log('Native host started');
readMessages();
log('Native host started (ACP mode)');
readMessagesFromExtension();