mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 17:57:46 +00:00
chore(chrome-qwen-bridge): wip use chat ui
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const readline = require('readline');
|
|
||||||
|
|
||||||
const BRIDGE_URL = 'http://127.0.0.1:18765';
|
const BRIDGE_URL = 'http://127.0.0.1:18765';
|
||||||
|
|
||||||
@@ -18,40 +17,44 @@ const PROTOCOL_VERSION = '2024-11-05';
|
|||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{
|
{
|
||||||
name: 'browser_read_page',
|
name: 'browser_read_page',
|
||||||
description: 'Read the content of the current browser page. Returns URL, title, text content, links, and images.',
|
description:
|
||||||
|
'Read the content of the current browser page. Returns URL, title, text content, links, and images.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
required: []
|
required: [],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'browser_capture_screenshot',
|
name: 'browser_capture_screenshot',
|
||||||
description: 'Capture a screenshot of the current browser tab. Returns a base64-encoded PNG image.',
|
description:
|
||||||
|
'Capture a screenshot of the current browser tab. Returns a base64-encoded PNG image.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
required: []
|
required: [],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'browser_get_network_logs',
|
name: 'browser_get_network_logs',
|
||||||
description: 'Get network request logs from the current browser tab. Useful for debugging API calls.',
|
description:
|
||||||
|
'Get network request logs from the current browser tab. Useful for debugging API calls.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
required: []
|
required: [],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'browser_get_console_logs',
|
name: 'browser_get_console_logs',
|
||||||
description: 'Get console logs (log, error, warn, info) from the current browser tab.',
|
description:
|
||||||
|
'Get console logs (log, error, warn, info) from the current browser tab.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
required: []
|
required: [],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Send request to Native Host HTTP bridge
|
// Send request to Native Host HTTP bridge
|
||||||
@@ -59,15 +62,18 @@ async function callBridge(method, params = {}) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const data = JSON.stringify({ method, params });
|
const data = JSON.stringify({ method, params });
|
||||||
|
|
||||||
const req = http.request(BRIDGE_URL, {
|
const req = http.request(
|
||||||
|
BRIDGE_URL,
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(data)
|
'Content-Length': Buffer.byteLength(data),
|
||||||
}
|
},
|
||||||
}, (res) => {
|
},
|
||||||
|
(res) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
res.on('data', chunk => body += chunk);
|
res.on('data', (chunk) => (body += chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const result = JSON.parse(body);
|
const result = JSON.parse(body);
|
||||||
@@ -80,10 +86,15 @@ async function callBridge(method, params = {}) {
|
|||||||
reject(new Error(`Failed to parse response: ${err.message}`));
|
reject(new Error(`Failed to parse response: ${err.message}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
req.on('error', (err) => {
|
req.on('error', (err) => {
|
||||||
reject(new Error(`Bridge connection failed: ${err.message}. Make sure Chrome extension is running.`));
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Bridge connection failed: ${err.message}. Make sure Chrome extension is running.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(data);
|
req.write(data);
|
||||||
@@ -100,15 +111,19 @@ async function handleToolCall(name, args) {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify({
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
url: data.url,
|
url: data.url,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
content: data.content?.text || data.content?.markdown || '',
|
content: data.content?.text || data.content?.markdown || '',
|
||||||
linksCount: data.links?.length || 0,
|
linksCount: data.links?.length || 0,
|
||||||
imagesCount: data.images?.length || 0
|
imagesCount: data.images?.length || 0,
|
||||||
}, null, 2)
|
},
|
||||||
}
|
null,
|
||||||
]
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,44 +134,45 @@ async function handleToolCall(name, args) {
|
|||||||
{
|
{
|
||||||
type: 'image',
|
type: 'image',
|
||||||
data: data.dataUrl?.replace(/^data:image\/png;base64,/, '') || '',
|
data: data.dataUrl?.replace(/^data:image\/png;base64,/, '') || '',
|
||||||
mimeType: 'image/png'
|
mimeType: 'image/png',
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'browser_get_network_logs': {
|
case 'browser_get_network_logs': {
|
||||||
const data = await callBridge('get_network_logs');
|
const data = await callBridge('get_network_logs');
|
||||||
const logs = data.logs || [];
|
const logs = data.logs || [];
|
||||||
const summary = logs.slice(-20).map(log => ({
|
const summary = logs.slice(-50).map((log) => ({
|
||||||
method: log.method,
|
method: log.method,
|
||||||
url: log.params?.request?.url || log.params?.documentURL,
|
url: log.params?.request?.url || log.params?.documentURL,
|
||||||
status: log.params?.response?.status,
|
status: log.params?.response?.status,
|
||||||
timestamp: log.timestamp
|
timestamp: log.timestamp,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `Network logs (last ${summary.length} entries):\n${JSON.stringify(summary, null, 2)}`
|
text: `Network logs (last ${summary.length} entries):\n${JSON.stringify(summary, null, 2)}`,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'browser_get_console_logs': {
|
case 'browser_get_console_logs': {
|
||||||
const data = await callBridge('get_console_logs');
|
const data = await callBridge('get_console_logs');
|
||||||
const logs = data.logs || [];
|
const logs = data.logs || [];
|
||||||
const formatted = logs.slice(-50).map(log =>
|
const formatted = logs
|
||||||
`[${log.type}] ${log.message}`
|
.slice(-50)
|
||||||
).join('\n');
|
.map((log) => `[${log.type}] ${log.message}`)
|
||||||
|
.join('\n');
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `Console logs (last ${Math.min(logs.length, 50)} entries):\n${formatted || '(no logs captured)'}`
|
text: `Console logs (last ${Math.min(logs.length, 50)} entries):\n${formatted || '(no logs captured)'}`,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,24 +181,19 @@ async function handleToolCall(name, args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send JSON-RPC response
|
// JSON-RPC framing over stdio (Content-Length)
|
||||||
function sendResponse(id, result) {
|
let inputBuffer = Buffer.alloc(0);
|
||||||
const response = {
|
function writeMessage(obj) {
|
||||||
jsonrpc: '2.0',
|
const json = Buffer.from(JSON.stringify(obj), 'utf8');
|
||||||
id,
|
const header = Buffer.from(`Content-Length: ${json.length}\r\n\r\n`, 'utf8');
|
||||||
result
|
process.stdout.write(header);
|
||||||
};
|
process.stdout.write(json);
|
||||||
console.log(JSON.stringify(response));
|
}
|
||||||
|
function sendResponse(id, result) {
|
||||||
|
writeMessage({ jsonrpc: '2.0', id, result });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send JSON-RPC error
|
|
||||||
function sendError(id, code, message) {
|
function sendError(id, code, message) {
|
||||||
const response = {
|
writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
|
||||||
jsonrpc: '2.0',
|
|
||||||
id,
|
|
||||||
error: { code, message }
|
|
||||||
};
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle incoming JSON-RPC messages
|
// Handle incoming JSON-RPC messages
|
||||||
@@ -195,15 +206,26 @@ async function handleMessage(message) {
|
|||||||
sendResponse(id, {
|
sendResponse(id, {
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {}
|
tools: {},
|
||||||
},
|
},
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: 'browser-mcp-server',
|
name: 'chrome-browser',
|
||||||
version: '1.0.0'
|
version: '1.0.0',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'tool': {
|
||||||
|
// Return functionDeclarations compatible with Qwen's mcpToTool expectation
|
||||||
|
const functionDeclarations = TOOLS.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parametersJsonSchema: t.inputSchema || { type: 'object', properties: {} },
|
||||||
|
}));
|
||||||
|
sendResponse(id, { functionDeclarations });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'notifications/initialized':
|
case 'notifications/initialized':
|
||||||
// No response needed for notifications
|
// No response needed for notifications
|
||||||
break;
|
break;
|
||||||
@@ -214,17 +236,20 @@ async function handleMessage(message) {
|
|||||||
|
|
||||||
case 'tools/call':
|
case 'tools/call':
|
||||||
try {
|
try {
|
||||||
const result = await handleToolCall(params.name, params.arguments || {});
|
const result = await handleToolCall(
|
||||||
|
params.name,
|
||||||
|
params.arguments || {},
|
||||||
|
);
|
||||||
sendResponse(id, result);
|
sendResponse(id, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendResponse(id, {
|
sendResponse(id, {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `Error: ${err.message}`
|
text: `Error: ${err.message}`,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
isError: true
|
isError: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -245,24 +270,40 @@ async function handleMessage(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main: Read JSON-RPC messages from stdin
|
// Main: Read JSON-RPC messages from stdin (Content-Length framed)
|
||||||
const rl = readline.createInterface({
|
process.stdin.on('data', (chunk) => {
|
||||||
input: process.stdin,
|
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
||||||
output: process.stdout,
|
while (true) {
|
||||||
terminal: false
|
let headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
||||||
});
|
let sepLen = 4;
|
||||||
|
if (headerEnd === -1) {
|
||||||
rl.on('line', async (line) => {
|
headerEnd = inputBuffer.indexOf('\n\n');
|
||||||
try {
|
sepLen = 2;
|
||||||
const message = JSON.parse(line);
|
|
||||||
await handleMessage(message);
|
|
||||||
} catch (err) {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
}
|
||||||
});
|
if (headerEnd === -1) return; // wait for full header
|
||||||
|
|
||||||
rl.on('close', () => {
|
const headerStr = inputBuffer.slice(0, headerEnd).toString('utf8');
|
||||||
process.exit(0);
|
const match = headerStr.match(/Content-Length:\s*(\d+)/i);
|
||||||
|
if (!match) {
|
||||||
|
// drop until next header
|
||||||
|
inputBuffer = inputBuffer.slice(headerEnd + sepLen);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const length = parseInt(match[1], 10);
|
||||||
|
const totalLen = headerEnd + sepLen + length;
|
||||||
|
if (inputBuffer.length < totalLen) return; // wait for full body
|
||||||
|
const body = inputBuffer.slice(headerEnd + sepLen, totalLen);
|
||||||
|
inputBuffer = inputBuffer.slice(totalLen);
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(body.toString('utf8'));
|
||||||
|
// Debug to stderr (not stdout): show basic method flow
|
||||||
|
try { console.error('[MCP <-]', message.method || 'response', message.id ?? ''); } catch (_) {}
|
||||||
|
handleMessage(message);
|
||||||
|
} catch (e) {
|
||||||
|
try { console.error('[MCP] JSON parse error:', e.message); } catch (_) {}
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
|
|||||||
@@ -127,7 +127,51 @@ class AcpConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log(`Starting Qwen CLI with ACP mode in ${cwd}`);
|
// Normalize CWD: use a dedicated clean directory for Chrome extension
|
||||||
|
// to avoid slow QWEN.md scanning in directories with many files
|
||||||
|
let normalizedCwd = cwd;
|
||||||
|
try {
|
||||||
|
const home = os.homedir();
|
||||||
|
// Use a dedicated directory for Chrome bridge to minimize file scanning
|
||||||
|
const chromeBridgeDir = path.join(home, '.qwen', 'chrome-bridge');
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
if (!fs.existsSync(chromeBridgeDir)) {
|
||||||
|
fs.mkdirSync(chromeBridgeDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an empty QWEN.md to immediately satisfy memory discovery
|
||||||
|
// This prevents BfsFileSearch from scanning many directories
|
||||||
|
const qwenMdPath = path.join(chromeBridgeDir, 'QWEN.md');
|
||||||
|
if (!fs.existsSync(qwenMdPath)) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
qwenMdPath,
|
||||||
|
'# Chrome Browser Bridge\n\nThis is the Qwen CLI Chrome extension workspace.\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use the dedicated chrome-bridge directory unless a specific CWD is requested
|
||||||
|
if (
|
||||||
|
!normalizedCwd ||
|
||||||
|
normalizedCwd === '/' ||
|
||||||
|
normalizedCwd === '\\' ||
|
||||||
|
!fs.existsSync(normalizedCwd)
|
||||||
|
) {
|
||||||
|
normalizedCwd = chromeBridgeDir;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
const fallback = path.join(os.homedir(), '.qwen', 'chrome-bridge');
|
||||||
|
if (!fs.existsSync(fallback))
|
||||||
|
fs.mkdirSync(fallback, { recursive: true });
|
||||||
|
normalizedCwd = fallback;
|
||||||
|
} catch (_) {
|
||||||
|
normalizedCwd = os.homedir();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Starting Qwen CLI with ACP mode in ${normalizedCwd}`);
|
||||||
|
|
||||||
// Chrome 环境没有用户 PATH,需要手动设置
|
// Chrome 环境没有用户 PATH,需要手动设置
|
||||||
const env = {
|
const env = {
|
||||||
@@ -137,22 +181,59 @@ class AcpConnection {
|
|||||||
(process.env.PATH || ''),
|
(process.env.PATH || ''),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.process = spawn(
|
// Resolve qwen CLI path more robustly
|
||||||
'/Users/yiliang/.npm-global/bin/qwen',
|
const qwenPath = (() => {
|
||||||
[
|
try {
|
||||||
|
// Prefer local monorepo build: packages/cli/dist/index.js
|
||||||
|
const localCli = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'cli',
|
||||||
|
'dist',
|
||||||
|
'index.js',
|
||||||
|
);
|
||||||
|
if (fs.existsSync(localCli)) {
|
||||||
|
return localCli;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
// Prefer explicit env override
|
||||||
|
if (
|
||||||
|
process.env.QWEN_CLI_PATH &&
|
||||||
|
fs.existsSync(process.env.QWEN_CLI_PATH)
|
||||||
|
) {
|
||||||
|
return process.env.QWEN_CLI_PATH;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
// Fallback to previously used absolute path if it exists
|
||||||
|
if (fs.existsSync('/Users/yiliang/.npm-global/bin/qwen')) {
|
||||||
|
return '/Users/yiliang/.npm-global/bin/qwen';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Last resort: rely on PATH
|
||||||
|
return 'qwen';
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Support both executable CLI (e.g., 'qwen') and Node script paths (e.g., '/.../dist/index.js')
|
||||||
|
const isNodeScript = /\.(mjs|cjs|js)$/i.test(qwenPath);
|
||||||
|
const spawnCommand = isNodeScript ? process.execPath || 'node' : qwenPath;
|
||||||
|
const spawnArgs = [
|
||||||
|
...(isNodeScript ? [qwenPath] : []),
|
||||||
'--experimental-acp',
|
'--experimental-acp',
|
||||||
'--allowed-mcp-server-names',
|
'--allowed-mcp-server-names',
|
||||||
'chrome-browser',
|
'chrome-browser,chrome-devtools',
|
||||||
'--debug',
|
'--debug',
|
||||||
],
|
];
|
||||||
{
|
|
||||||
cwd,
|
this.process = spawn(spawnCommand, spawnArgs, {
|
||||||
|
cwd: normalizedCwd,
|
||||||
env,
|
env,
|
||||||
shell: true,
|
shell: true,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.process || !this.process.pid) {
|
if (!this.process || !this.process.pid) {
|
||||||
this.process = null;
|
this.process = null;
|
||||||
@@ -172,6 +253,12 @@ class AcpConnection {
|
|||||||
const message = data.toString().trim();
|
const message = data.toString().trim();
|
||||||
if (message) {
|
if (message) {
|
||||||
log(`Qwen stderr: ${message}`);
|
log(`Qwen stderr: ${message}`);
|
||||||
|
try {
|
||||||
|
sendMessageToExtension({
|
||||||
|
type: 'event',
|
||||||
|
data: { type: 'cli_stderr', line: message },
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,7 +294,7 @@ class AcpConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a new session
|
// Create a new session
|
||||||
const sessionResult = await this.createSession(cwd);
|
const sessionResult = await this.createSession(normalizedCwd);
|
||||||
if (!sessionResult.success) {
|
if (!sessionResult.success) {
|
||||||
this.stop();
|
this.stop();
|
||||||
return sessionResult;
|
return sessionResult;
|
||||||
@@ -301,6 +388,17 @@ class AcpConnection {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'notifications/tools/list_changed':
|
||||||
|
// Forward MCP tool list change notifications to the extension
|
||||||
|
sendMessageToExtension({
|
||||||
|
type: 'event',
|
||||||
|
data: {
|
||||||
|
type: 'tools_list_changed',
|
||||||
|
tools: params?.tools || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log(`Unknown ACP notification: ${method}`);
|
log(`Unknown ACP notification: ${method}`);
|
||||||
}
|
}
|
||||||
@@ -468,7 +566,7 @@ class AcpConnection {
|
|||||||
this.process.stdin.write(json);
|
this.process.stdin.write(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAcpRequest(method, params) {
|
sendAcpRequest(method, params, timeoutMs = 30000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = this.nextRequestId++;
|
const id = this.nextRequestId++;
|
||||||
this.pendingRequests.set(id, { resolve, reject });
|
this.pendingRequests.set(id, { resolve, reject });
|
||||||
@@ -485,13 +583,13 @@ class AcpConnection {
|
|||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout after 30 seconds
|
// Timeout after specified duration
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (this.pendingRequests.has(id)) {
|
if (this.pendingRequests.has(id)) {
|
||||||
this.pendingRequests.delete(id);
|
this.pendingRequests.delete(id);
|
||||||
reject(new Error(`Request ${method} timed out`));
|
reject(new Error(`Request ${method} timed out`));
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, timeoutMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,21 +611,20 @@ class AcpConnection {
|
|||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendAcpRequest('initialize', {
|
const result = await this.sendAcpRequest(
|
||||||
|
'initialize',
|
||||||
|
{
|
||||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||||
clientCapabilities: {
|
clientCapabilities: {
|
||||||
fs: {
|
fs: {
|
||||||
|
// Only advertise filesystem capabilities; CLI schema accepts only 'fs' here.
|
||||||
readTextFile: true,
|
readTextFile: true,
|
||||||
writeTextFile: true,
|
writeTextFile: true,
|
||||||
},
|
},
|
||||||
browser: {
|
|
||||||
readPage: true,
|
|
||||||
captureScreenshot: true,
|
|
||||||
getNetworkLogs: true,
|
|
||||||
getConsoleLogs: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
log(`Qwen CLI initialized: ${JSON.stringify(result)}`);
|
log(`Qwen CLI initialized: ${JSON.stringify(result)}`);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
@@ -539,6 +636,39 @@ class AcpConnection {
|
|||||||
|
|
||||||
async createSession(cwd) {
|
async createSession(cwd) {
|
||||||
try {
|
try {
|
||||||
|
// Helper to discover Chrome DevTools WS URL from env or default port
|
||||||
|
const http = require('http');
|
||||||
|
async function fetchJson(url) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const req = http.get(url, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (c) => (body += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body));
|
||||||
|
} catch (_) {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(null));
|
||||||
|
req.end();
|
||||||
|
} catch (_) {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function discoverDevToolsWsUrl() {
|
||||||
|
// 1) Explicit env
|
||||||
|
if (process.env.DEVTOOLS_WS_URL) return process.env.DEVTOOLS_WS_URL;
|
||||||
|
// 2) Provided port
|
||||||
|
const port = process.env.CHROME_REMOTE_DEBUG_PORT || '9222';
|
||||||
|
const json = await fetchJson(`http://127.0.0.1:${port}/json/version`);
|
||||||
|
if (json && json.webSocketDebuggerUrl) return json.webSocketDebuggerUrl;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Get the path to browser-mcp-server.js
|
// Get the path to browser-mcp-server.js
|
||||||
const browserMcpServerPath = path.join(
|
const browserMcpServerPath = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -547,24 +677,65 @@ class AcpConnection {
|
|||||||
|
|
||||||
log(`Creating session with MCP server: ${browserMcpServerPath}`);
|
log(`Creating session with MCP server: ${browserMcpServerPath}`);
|
||||||
|
|
||||||
|
// Use the same Node runtime that's running this host process to launch the MCP server.
|
||||||
|
// This avoids hard-coded paths like /usr/local/bin/node which may not exist on all systems
|
||||||
|
// (e.g., Homebrew on Apple Silicon uses /opt/homebrew/bin/node, or users may use nvm).
|
||||||
|
const nodeCommand = process.execPath || 'node';
|
||||||
|
|
||||||
const mcpServersConfig = [
|
const mcpServersConfig = [
|
||||||
{
|
{
|
||||||
name: 'chrome-browser',
|
name: 'chrome-browser',
|
||||||
command: '/usr/local/bin/node',
|
command: nodeCommand,
|
||||||
args: [browserMcpServerPath],
|
args: [browserMcpServerPath],
|
||||||
env: [],
|
env: [],
|
||||||
|
timeout: 180000, // 3 minutes timeout for MCP operations
|
||||||
|
trust: true, // Auto-approve browser tools
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Optionally add open-source DevTools MCP if a WS URL is available
|
||||||
|
try {
|
||||||
|
const wsUrl = await discoverDevToolsWsUrl();
|
||||||
|
if (wsUrl) {
|
||||||
|
mcpServersConfig.push({
|
||||||
|
name: 'chrome-devtools',
|
||||||
|
command: 'chrome-devtools-mcp',
|
||||||
|
args: ['--ws-url', wsUrl],
|
||||||
|
env: [{ name: 'DEVTOOLS_WS_URL', value: wsUrl }],
|
||||||
|
timeout: 180000,
|
||||||
|
trust: true,
|
||||||
|
});
|
||||||
|
log(`Adding DevTools MCP with wsUrl: ${wsUrl}`);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
'DevTools WS URL not found (is Chrome running with --remote-debugging-port=9222?). Skipping chrome-devtools MCP.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`Failed to prepare DevTools MCP: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
log(`MCP servers config: ${JSON.stringify(mcpServersConfig)}`);
|
log(`MCP servers config: ${JSON.stringify(mcpServersConfig)}`);
|
||||||
|
|
||||||
const result = await this.sendAcpRequest('session/new', {
|
// Skip MCP server configuration to avoid slow tool discovery.
|
||||||
|
// Browser tools are handled via fallback mechanism in service-worker.js.
|
||||||
|
// To enable MCP (slower startup), set useMcp = true
|
||||||
|
const useMcp = false;
|
||||||
|
|
||||||
|
const result = await this.sendAcpRequest(
|
||||||
|
'session/new',
|
||||||
|
{
|
||||||
cwd,
|
cwd,
|
||||||
mcpServers: mcpServersConfig,
|
mcpServers: useMcp ? mcpServersConfig : [],
|
||||||
});
|
},
|
||||||
|
useMcp ? 180000 : 30000, // Fast startup without MCP
|
||||||
|
);
|
||||||
|
|
||||||
this.sessionId = result.sessionId;
|
this.sessionId = result.sessionId;
|
||||||
log(`Session created: ${this.sessionId}`);
|
log(`Session created: ${this.sessionId}`);
|
||||||
|
try {
|
||||||
|
log(`Session/new result: ${JSON.stringify(result)}`);
|
||||||
|
} catch (_) {}
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`Failed to create session: ${err.message}`);
|
logError(`Failed to create session: ${err.message}`);
|
||||||
@@ -699,14 +870,14 @@ const acpConnection = new AcpConnection();
|
|||||||
async function checkQwenInstallation() {
|
async function checkQwenInstallation() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
const checkProcess = spawn(
|
const qwenPath =
|
||||||
'/Users/yiliang/.npm-global/bin/qwen',
|
process.env.QWEN_CLI_PATH ||
|
||||||
['--version'],
|
'/Users/yiliang/.npm-global/bin/qwen' ||
|
||||||
{
|
'qwen';
|
||||||
|
const checkProcess = spawn(qwenPath, ['--version'], {
|
||||||
shell: true,
|
shell: true,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
checkProcess.stdout.on('data', (data) => {
|
checkProcess.stdout.on('data', (data) => {
|
||||||
@@ -740,28 +911,33 @@ async function checkQwenInstallation() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a prompt string from action and data
|
* Build a prompt string from action, data, and optional user prompt
|
||||||
*/
|
*/
|
||||||
function buildPromptFromAction(action, data) {
|
function buildPromptFromAction(action, data, userPrompt) {
|
||||||
|
// If user provided additional instructions, append them
|
||||||
|
const userInstructions = userPrompt
|
||||||
|
? `\n\nUser's request: ${userPrompt}`
|
||||||
|
: '';
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'analyze_page':
|
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.`;
|
return `Here is the webpage content:\n\nURL: ${data.url}\nTitle: ${data.title}\n\nContent:\n${data.content?.text || data.content?.markdown || 'No content available'}${userInstructions || '\n\nPlease provide a summary and any notable observations.'}`;
|
||||||
|
|
||||||
case 'analyze_screenshot':
|
case 'analyze_screenshot':
|
||||||
return `Please analyze the screenshot from this URL: ${data.url}\n\n[Screenshot data provided as base64 image]`;
|
return `Here is a screenshot from URL: ${data.url}\n\n[Screenshot data provided as base64 image]${userInstructions || '\n\nPlease analyze this screenshot.'}`;
|
||||||
|
|
||||||
case 'ai_analyze':
|
case 'ai_analyze':
|
||||||
return (
|
return (
|
||||||
data.prompt ||
|
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'}`
|
`Here is the webpage:\n\nURL: ${data.pageData?.url}\nTitle: ${data.pageData?.title}\n\nContent:\n${data.pageData?.content?.text || 'No content available'}${userInstructions}`
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'process_text':
|
case 'process_text':
|
||||||
return `Please process the following ${data.context || 'text'}:\n\n${data.text}`;
|
return `Here is the ${data.context || 'text'}:\n\n${data.text}${userInstructions || '\n\nPlease process this information.'}`;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// For unknown actions, just stringify the data
|
// For unknown actions, just stringify the data
|
||||||
return `Action: ${action}\nData: ${JSON.stringify(data, null, 2)}`;
|
return `Action: ${action}\nData: ${JSON.stringify(data, null, 2)}${userInstructions}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +962,20 @@ async function handleExtensionMessage(message) {
|
|||||||
qwenVersion: 'checking...',
|
qwenVersion: 'checking...',
|
||||||
qwenStatus: acpConnection.getStatus().status,
|
qwenStatus: acpConnection.getStatus().status,
|
||||||
};
|
};
|
||||||
|
// Send host info event (log path, runtime) to help debugging
|
||||||
|
try {
|
||||||
|
sendMessageToExtension({
|
||||||
|
type: 'event',
|
||||||
|
data: {
|
||||||
|
type: 'host_info',
|
||||||
|
logFile: LOG_FILE,
|
||||||
|
node: process.execPath,
|
||||||
|
pid: process.pid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logError(`Failed to send host_info: ${e.message}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'start_qwen':
|
case 'start_qwen':
|
||||||
@@ -836,8 +1026,12 @@ async function handleExtensionMessage(message) {
|
|||||||
|
|
||||||
case 'qwen_request':
|
case 'qwen_request':
|
||||||
// Handle generic requests from extension (analyze_page, analyze_screenshot, etc.)
|
// Handle generic requests from extension (analyze_page, analyze_screenshot, etc.)
|
||||||
// Convert action + data to a prompt for Qwen CLI
|
// Convert action + data to a prompt for Qwen CLI, including user's original request
|
||||||
const promptText = buildPromptFromAction(message.action, message.data);
|
const promptText = buildPromptFromAction(
|
||||||
|
message.action,
|
||||||
|
message.data,
|
||||||
|
message.userPrompt,
|
||||||
|
);
|
||||||
if (acpConnection.status !== 'running') {
|
if (acpConnection.status !== 'running') {
|
||||||
response = {
|
response = {
|
||||||
type: 'response',
|
type: 'response',
|
||||||
|
|||||||
@@ -14,4 +14,13 @@ if [ ! -f "$NODE_PATH" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 执行 Native Host
|
# 执行 Native Host
|
||||||
|
|
||||||
|
# Prefer local CLI build if available and QWEN_CLI_PATH is not set
|
||||||
|
if [ -z "$QWEN_CLI_PATH" ]; then
|
||||||
|
LOCAL_CLI="$DIR/../../cli/dist/index.js"
|
||||||
|
if [ -f "$LOCAL_CLI" ]; then
|
||||||
|
export QWEN_CLI_PATH="$LOCAL_CLI"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$NODE_PATH" "$DIR/host.js"
|
exec "$NODE_PATH" "$DIR/host.js"
|
||||||
@@ -32,6 +32,13 @@ export const App: React.FC = () => {
|
|||||||
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
||||||
const [loadingMessage, setLoadingMessage] = useState<string | null>(null);
|
const [loadingMessage, setLoadingMessage] = useState<string | null>(null);
|
||||||
const [streamingContent, setStreamingContent] = useState('');
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
|
// Debug: cache slash-commands (available_commands_update) & MCP tools list
|
||||||
|
const [availableCommands, setAvailableCommands] = useState<any[]>([]);
|
||||||
|
const [mcpTools, setMcpTools] = useState<any[]>([]);
|
||||||
|
const [internalTools, setInternalTools] = useState<any[]>([]);
|
||||||
|
const [showToolsPanel, setShowToolsPanel] = useState(false);
|
||||||
|
const [authUri, setAuthUri] = useState<string | null>(null);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const [permissionRequest, setPermissionRequest] = useState<{
|
const [permissionRequest, setPermissionRequest] = useState<{
|
||||||
requestId: number;
|
requestId: number;
|
||||||
options: PermissionOption[];
|
options: PermissionOption[];
|
||||||
@@ -56,6 +63,61 @@ export const App: React.FC = () => {
|
|||||||
case 'STATUS_UPDATE':
|
case 'STATUS_UPDATE':
|
||||||
setIsConnected((message as { status: string }).status !== 'disconnected');
|
setIsConnected((message as { status: string }).status !== 'disconnected');
|
||||||
break;
|
break;
|
||||||
|
case 'hostInfo': {
|
||||||
|
console.log('[HostInfo]', (message as any).data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hostLog': {
|
||||||
|
const line = (message as { data?: { line?: string } }).data?.line;
|
||||||
|
if (line) console.log('[HostLog]', line);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'authUpdate': {
|
||||||
|
const uri = (message as { data?: { authUri?: string } }).data?.authUri;
|
||||||
|
if (uri) setAuthUri(uri);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'availableCommands': {
|
||||||
|
const cmds = (message as { data?: { availableCommands?: any[] } }).data?.availableCommands || [];
|
||||||
|
setAvailableCommands(cmds);
|
||||||
|
console.log('[App] Available commands:', cmds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'mcpTools': {
|
||||||
|
const tools = (message as { data?: { tools?: any[] } }).data?.tools || [];
|
||||||
|
setMcpTools(tools);
|
||||||
|
console.log('[App] MCP tools:', tools);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'internalMcpTools': {
|
||||||
|
const tools = (message as { data?: { tools?: any[] } }).data?.tools || [];
|
||||||
|
setInternalTools(tools);
|
||||||
|
console.log('[App] Internal MCP tools:', tools);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toolProgress': {
|
||||||
|
const payload = (message as { data?: { name?: string; stage?: string; ok?: boolean; error?: string } }).data || ({} as any);
|
||||||
|
const name = payload.name || '';
|
||||||
|
const stage = payload.stage || '';
|
||||||
|
const ok = payload.ok;
|
||||||
|
const pretty = (n: string) => {
|
||||||
|
switch (n) {
|
||||||
|
case 'read_page': return 'Read Page';
|
||||||
|
case 'capture_screenshot': return 'Capture Screenshot';
|
||||||
|
case 'get_network_logs': return 'Get Network Logs';
|
||||||
|
case 'get_console_logs': return 'Get Console Logs';
|
||||||
|
default: return n;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (stage === 'start') {
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Running tool: ${pretty(name)}…`, timestamp: Date.now() }]);
|
||||||
|
} else if (stage === 'end') {
|
||||||
|
const endText = ok === false ? `Tool failed: ${pretty(name)}${payload.error ? ` — ${payload.error}` : ''}` : `Tool finished: ${pretty(name)}`;
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: endText, timestamp: Date.now() }]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'streamStart':
|
case 'streamStart':
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
@@ -123,9 +185,18 @@ export const App: React.FC = () => {
|
|||||||
// Check connection status on mount
|
// Check connection status on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
const response = await vscode.postMessage({ type: 'GET_STATUS' }) as { connected?: boolean; status?: string } | null;
|
const response = await vscode.postMessage({ type: 'GET_STATUS' }) as { connected?: boolean; status?: string; availableCommands?: any[]; mcpTools?: any[]; internalTools?: any[] } | null;
|
||||||
if (response) {
|
if (response) {
|
||||||
setIsConnected(response.connected || false);
|
setIsConnected(response.connected || false);
|
||||||
|
if (Array.isArray(response.availableCommands)) {
|
||||||
|
setAvailableCommands(response.availableCommands);
|
||||||
|
}
|
||||||
|
if (Array.isArray(response.mcpTools)) {
|
||||||
|
setMcpTools(response.mcpTools);
|
||||||
|
}
|
||||||
|
if (Array.isArray(response.internalTools)) {
|
||||||
|
setInternalTools(response.internalTools);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkStatus();
|
checkStatus();
|
||||||
@@ -182,6 +253,53 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [vscode]);
|
}, [vscode]);
|
||||||
|
|
||||||
|
// Read current page and ask Qwen to analyze (bypasses MCP; uses content-script extractor)
|
||||||
|
const handleReadPage = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsWaitingForResponse(true);
|
||||||
|
setLoadingMessage('Reading page...');
|
||||||
|
const extract = (await vscode.postMessage({ type: 'EXTRACT_PAGE_DATA' })) as any;
|
||||||
|
if (!extract || !extract.success) {
|
||||||
|
setIsWaitingForResponse(false);
|
||||||
|
setLoadingMessage(null);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Read Page failed: ${extract?.error || 'unknown error'}`, timestamp: Date.now() }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await vscode.postMessage({ type: 'SEND_TO_QWEN', action: 'analyze_page', data: extract.data });
|
||||||
|
// streamStart will arrive from service worker; keep waiting state until it starts streaming
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsWaitingForResponse(false);
|
||||||
|
setLoadingMessage(null);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Read Page error: ${err?.message || String(err)}`, timestamp: Date.now() }]);
|
||||||
|
}
|
||||||
|
}, [vscode]);
|
||||||
|
|
||||||
|
// Get network logs and send to Qwen to analyze (bypasses MCP; uses debugger API)
|
||||||
|
const handleGetNetworkLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsWaitingForResponse(true);
|
||||||
|
setLoadingMessage('Collecting network logs...');
|
||||||
|
const resp = (await vscode.postMessage({ type: 'GET_NETWORK_LOGS' })) as any;
|
||||||
|
if (!resp || !resp.success) {
|
||||||
|
setIsWaitingForResponse(false);
|
||||||
|
setLoadingMessage(null);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Get Network Logs failed: ${resp?.error || 'unknown error'}`, timestamp: Date.now() }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logs = resp.data || resp.logs || [];
|
||||||
|
const summary = Array.isArray(logs) ? logs.slice(-50) : [];
|
||||||
|
const text = `Network logs (last ${summary.length} entries):\n` + JSON.stringify(summary.map((l:any)=>({method:l.method,url:l.params?.request?.url||l.params?.documentURL,status:l.params?.response?.status,timestamp:l.timestamp})), null, 2);
|
||||||
|
// Show a short message to user
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: 'Running tool: Get Network Logs…', timestamp: Date.now() }]);
|
||||||
|
// Ask Qwen to analyze
|
||||||
|
await vscode.postMessage({ type: 'SEND_TO_QWEN', action: 'ai_analyze', data: { pageData: { content: { text } }, prompt: 'Please analyze these network logs, list failed or slow requests and possible causes.' } });
|
||||||
|
} catch (err:any) {
|
||||||
|
setIsWaitingForResponse(false);
|
||||||
|
setLoadingMessage(null);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Get Network Logs error: ${err?.message || String(err)}`, timestamp: Date.now() }]);
|
||||||
|
}
|
||||||
|
}, [vscode]);
|
||||||
|
|
||||||
// Handle permission response
|
// Handle permission response
|
||||||
const handlePermissionResponse = useCallback((optionId: string) => {
|
const handlePermissionResponse = useCallback((optionId: string) => {
|
||||||
if (!permissionRequest) return;
|
if (!permissionRequest) return;
|
||||||
@@ -207,8 +325,44 @@ export const App: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-500'}`} />
|
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-500'}`} />
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
{isConnected ? `Connected (${mcpTools.length + internalTools.length} tools)` : 'Disconnected'}
|
||||||
</span>
|
</span>
|
||||||
|
{isConnected && (
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
|
||||||
|
onClick={handleReadPage}
|
||||||
|
title="Read current page"
|
||||||
|
>
|
||||||
|
Read Page
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isConnected && (
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
|
||||||
|
onClick={handleGetNetworkLogs}
|
||||||
|
title="Get network logs"
|
||||||
|
>
|
||||||
|
Network Logs
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isConnected && (
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
|
||||||
|
onClick={handleGetConsoleLogs}
|
||||||
|
title="Get console logs"
|
||||||
|
>
|
||||||
|
Console Logs
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isConnected && (mcpTools.length + internalTools.length) > 0 && (
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
|
||||||
|
onClick={() => setShowToolsPanel(v => !v)}
|
||||||
|
title="Show available tools"
|
||||||
|
>
|
||||||
|
Tools
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -252,9 +406,13 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Waiting indicator */}
|
{/* Waiting indicator */}
|
||||||
{isWaitingForResponse && loadingMessage && (
|
{(isWaitingForResponse && loadingMessage) && (
|
||||||
<WaitingMessage loadingMessage={loadingMessage} />
|
<WaitingMessage loadingMessage={loadingMessage} />
|
||||||
)}
|
)}
|
||||||
|
{/* If streaming started but no chunks yet, show thinking indicator */}
|
||||||
|
{(isStreaming && !streamingContent) && (
|
||||||
|
<WaitingMessage loadingMessage={loadingMessage || 'Thinking...'} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</>
|
</>
|
||||||
@@ -268,15 +426,15 @@ export const App: React.FC = () => {
|
|||||||
inputFieldRef={inputFieldRef}
|
inputFieldRef={inputFieldRef}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isWaitingForResponse={isWaitingForResponse}
|
isWaitingForResponse={isWaitingForResponse}
|
||||||
isComposing={false}
|
isComposing={isComposing}
|
||||||
editMode="default"
|
editMode="default"
|
||||||
thinkingEnabled={false}
|
thinkingEnabled={false}
|
||||||
activeFileName={null}
|
activeFileName={null}
|
||||||
activeSelection={null}
|
activeSelection={null}
|
||||||
skipAutoActiveContext={true}
|
skipAutoActiveContext={true}
|
||||||
onInputChange={setInputText}
|
onInputChange={setInputText}
|
||||||
onCompositionStart={() => {}}
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
onCompositionEnd={() => {}}
|
onCompositionEnd={() => setIsComposing(false)}
|
||||||
onKeyDown={() => {}}
|
onKeyDown={() => {}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
@@ -312,6 +470,85 @@ export const App: React.FC = () => {
|
|||||||
onClose={() => setPermissionRequest(null)}
|
onClose={() => setPermissionRequest(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auth Required banner */}
|
||||||
|
{authUri && (
|
||||||
|
<div className="absolute left-3 right-3 top-10 z-50 bg-[#2a2d2e] border border-yellow-600 text-yellow-200 rounded p-2 text-[12px] flex items-center justify-between gap-2">
|
||||||
|
<div>Authentication required. Click to sign in.</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="px-2 py-0.5 rounded bg-yellow-700 hover:bg-yellow-600 text-white"
|
||||||
|
onClick={() => {
|
||||||
|
try { chrome.tabs.create({ url: authUri }); } catch (_) {}
|
||||||
|
}}
|
||||||
|
>Open Link</button>
|
||||||
|
<button className="px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600" onClick={() => setAuthUri(null)}>Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debug: Tools panel */}
|
||||||
|
{showToolsPanel && (mcpTools.length + internalTools.length) > 0 && (
|
||||||
|
<div className="absolute right-3 top-10 z-50 max-w-[80%] w-[360px] max-h-[50vh] overflow-auto bg-[#2a2d2e] text-[13px] text-gray-200 border border-gray-700 rounded shadow-lg p-2">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-semibold">Available Tools ({mcpTools.length + internalTools.length})</div>
|
||||||
|
<button className="text-gray-400 hover:text-gray-200" onClick={() => setShowToolsPanel(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-400 mb-1">Internal (chrome-browser)</div>
|
||||||
|
<ul className="space-y-1 mb-2">
|
||||||
|
{internalTools.map((t: any, i: number) => {
|
||||||
|
const name = (t && (t.name || t.tool?.name)) || String(t);
|
||||||
|
const desc = (t && (t.description || t.tool?.description)) || '';
|
||||||
|
return (
|
||||||
|
<li key={`internal-${i}`} className="px-2 py-1 rounded hover:bg-[#3a3d3e]">
|
||||||
|
<div className="font-mono text-xs text-[#a6e22e] break-all">{name}</div>
|
||||||
|
{desc && <div className="text-[11px] text-gray-400 break-words">{desc}</div>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="text-[11px] text-gray-400 mb-1">Discovered (MCP)</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{mcpTools.map((t: any, i: number) => {
|
||||||
|
const name = (t && (t.name || t.tool?.name)) || String(t);
|
||||||
|
const desc = (t && (t.description || t.tool?.description)) || '';
|
||||||
|
return (
|
||||||
|
<li key={`discovered-${i}`} className="px-2 py-1 rounded hover:bg-[#3a3d3e]">
|
||||||
|
<div className="font-mono text-xs text-[#a6e22e] break-all">{name}</div>
|
||||||
|
{desc && <div className="text-[11px] text-gray-400 break-words">{desc}</div>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Get console logs and send to Qwen to analyze (bypasses MCP; uses content-script capture)
|
||||||
|
const handleGetConsoleLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsWaitingForResponse(true);
|
||||||
|
setLoadingMessage('Collecting console logs...');
|
||||||
|
const resp = (await vscode.postMessage({ type: 'GET_CONSOLE_LOGS' })) as any;
|
||||||
|
if (!resp || !resp.success) {
|
||||||
|
setIsWaitingForResponse(false);
|
||||||
|
setLoadingMessage(null);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Get Console Logs failed: ${resp?.error || 'unknown error'}`, timestamp: Date.now() }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logs = resp.data || [];
|
||||||
|
const formatted = logs.slice(-50).map((l:any)=>`[${l.type}] ${l.message}`).join('
|
||||||
|
');
|
||||||
|
const text = `Console logs (last ${Math.min(logs.length,50)} entries):
|
||||||
|
${formatted || '(no logs captured)'}`;
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: 'Running tool: Get Console Logs…', timestamp: Date.now() }]);
|
||||||
|
await vscode.postMessage({ type: 'SEND_TO_QWEN', action: 'ai_analyze', data: { pageData: { content: { text } }, prompt: 'Please analyze these console logs and summarize errors/warnings.' } });
|
||||||
|
} catch (err:any) {
|
||||||
|
setIsWaitingForResponse(false);
|
||||||
|
setLoadingMessage(null);
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: `Get Console Logs error: ${err?.message || String(err)}`, timestamp: Date.now() }]);
|
||||||
|
}
|
||||||
|
}, [vscode]);
|
||||||
|
|||||||
@@ -2,8 +2,26 @@
|
|||||||
|
|
||||||
echo "🔧 更新 Native Host 配置..."
|
echo "🔧 更新 Native Host 配置..."
|
||||||
|
|
||||||
CONFIG_FILE="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json"
|
CONFIG_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||||
RUN_SCRIPT="$PWD/native-host/run.sh"
|
CONFIG_FILE="$CONFIG_DIR/com.qwen.cli.bridge.json"
|
||||||
|
# 解析绝对路径,避免写入相对路径
|
||||||
|
RUN_SCRIPT="$(cd "$(pwd -P)" && printf "%s/native-host/run.sh" "$(pwd -P)")"
|
||||||
|
|
||||||
|
# 读取扩展 ID(来自 .extension-id 文件),若不存在则提示手动填写
|
||||||
|
EXT_ID_FILE=".extension-id"
|
||||||
|
if [ -f "$EXT_ID_FILE" ]; then
|
||||||
|
EXT_ID="$(cat "$EXT_ID_FILE" | tr -d '\n' | tr -d '\r')"
|
||||||
|
else
|
||||||
|
echo "⚠️ 未找到 .extension-id 文件,请手动填写扩展 ID。"
|
||||||
|
read -p "请输入扩展 ID: " EXT_ID
|
||||||
|
fi
|
||||||
|
if [ -z "$EXT_ID" ]; then
|
||||||
|
echo "❌ 扩展 ID 为空,退出。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
chmod +x "$RUN_SCRIPT" 2>/dev/null || true
|
||||||
|
|
||||||
# 创建新的配置
|
# 创建新的配置
|
||||||
cat > "$CONFIG_FILE" <<EOF
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
@@ -12,7 +30,7 @@ cat > "$CONFIG_FILE" <<EOF
|
|||||||
"description": "Native messaging host for Qwen CLI Bridge",
|
"description": "Native messaging host for Qwen CLI Bridge",
|
||||||
"path": "$RUN_SCRIPT",
|
"path": "$RUN_SCRIPT",
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"allowed_origins": ["chrome-extension://*/"]
|
"allowed_origins": ["chrome-extension://$EXT_ID/"]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -23,4 +41,4 @@ cat "$CONFIG_FILE"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "现在请:"
|
echo "现在请:"
|
||||||
echo "1. 重新加载 Chrome 扩展 (chrome://extensions/)"
|
echo "1. 重新加载 Chrome 扩展 (chrome://extensions/)"
|
||||||
echo "2. 点击扩展图标测试连接"
|
echo "2. 在扩展侧边栏点击 Connect 测试连接"
|
||||||
|
|||||||
Reference in New Issue
Block a user