chore(chrome-qwen-bridge): wip use chat ui

This commit is contained in:
yiliang114
2025-12-22 08:22:19 +08:00
parent 84e190fd9d
commit 43c703a79d
8 changed files with 1570 additions and 305 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,6 @@
*/
const http = require('http');
const readline = require('readline');
const BRIDGE_URL = 'http://127.0.0.1:18765';
@@ -18,40 +17,44 @@ const PROTOCOL_VERSION = '2024-11-05';
const TOOLS = [
{
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: {
type: 'object',
properties: {},
required: []
}
required: [],
},
},
{
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: {
type: 'object',
properties: {},
required: []
}
required: [],
},
},
{
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: {
type: 'object',
properties: {},
required: []
}
required: [],
},
},
{
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: {
type: 'object',
properties: {},
required: []
}
}
required: [],
},
},
];
// Send request to Native Host HTTP bridge
@@ -59,31 +62,39 @@ async function callBridge(method, params = {}) {
return new Promise((resolve, reject) => {
const data = JSON.stringify({ method, params });
const req = http.request(BRIDGE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
const result = JSON.parse(body);
if (result.success) {
resolve(result.data);
} else {
reject(new Error(result.error || 'Unknown error'));
const req = http.request(
BRIDGE_URL,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
},
},
(res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
try {
const result = JSON.parse(body);
if (result.success) {
resolve(result.data);
} else {
reject(new Error(result.error || 'Unknown error'));
}
} catch (err) {
reject(new Error(`Failed to parse response: ${err.message}`));
}
} catch (err) {
reject(new Error(`Failed to parse response: ${err.message}`));
}
});
});
});
},
);
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);
@@ -100,15 +111,19 @@ async function handleToolCall(name, args) {
content: [
{
type: 'text',
text: JSON.stringify({
url: data.url,
title: data.title,
content: data.content?.text || data.content?.markdown || '',
linksCount: data.links?.length || 0,
imagesCount: data.images?.length || 0
}, null, 2)
}
]
text: JSON.stringify(
{
url: data.url,
title: data.title,
content: data.content?.text || data.content?.markdown || '',
linksCount: data.links?.length || 0,
imagesCount: data.images?.length || 0,
},
null,
2,
),
},
],
};
}
@@ -119,44 +134,45 @@ async function handleToolCall(name, args) {
{
type: 'image',
data: data.dataUrl?.replace(/^data:image\/png;base64,/, '') || '',
mimeType: 'image/png'
}
]
mimeType: 'image/png',
},
],
};
}
case 'browser_get_network_logs': {
const data = await callBridge('get_network_logs');
const logs = data.logs || [];
const summary = logs.slice(-20).map(log => ({
const summary = logs.slice(-50).map((log) => ({
method: log.method,
url: log.params?.request?.url || log.params?.documentURL,
status: log.params?.response?.status,
timestamp: log.timestamp
timestamp: log.timestamp,
}));
return {
content: [
{
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': {
const data = await callBridge('get_console_logs');
const logs = data.logs || [];
const formatted = logs.slice(-50).map(log =>
`[${log.type}] ${log.message}`
).join('\n');
const formatted = logs
.slice(-50)
.map((log) => `[${log.type}] ${log.message}`)
.join('\n');
return {
content: [
{
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
function sendResponse(id, result) {
const response = {
jsonrpc: '2.0',
id,
result
};
console.log(JSON.stringify(response));
// JSON-RPC framing over stdio (Content-Length)
let inputBuffer = Buffer.alloc(0);
function writeMessage(obj) {
const json = Buffer.from(JSON.stringify(obj), 'utf8');
const header = Buffer.from(`Content-Length: ${json.length}\r\n\r\n`, 'utf8');
process.stdout.write(header);
process.stdout.write(json);
}
function sendResponse(id, result) {
writeMessage({ jsonrpc: '2.0', id, result });
}
// Send JSON-RPC error
function sendError(id, code, message) {
const response = {
jsonrpc: '2.0',
id,
error: { code, message }
};
console.log(JSON.stringify(response));
writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
}
// Handle incoming JSON-RPC messages
@@ -195,15 +206,26 @@ async function handleMessage(message) {
sendResponse(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: {}
tools: {},
},
serverInfo: {
name: 'browser-mcp-server',
version: '1.0.0'
}
name: 'chrome-browser',
version: '1.0.0',
},
});
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':
// No response needed for notifications
break;
@@ -214,17 +236,20 @@ async function handleMessage(message) {
case 'tools/call':
try {
const result = await handleToolCall(params.name, params.arguments || {});
const result = await handleToolCall(
params.name,
params.arguments || {},
);
sendResponse(id, result);
} catch (err) {
sendResponse(id, {
content: [
{
type: 'text',
text: `Error: ${err.message}`
}
text: `Error: ${err.message}`,
},
],
isError: true
isError: true,
});
}
break;
@@ -245,26 +270,42 @@ async function handleMessage(message) {
}
}
// Main: Read JSON-RPC messages from stdin
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
// Main: Read JSON-RPC messages from stdin (Content-Length framed)
process.stdin.on('data', (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, chunk]);
while (true) {
let headerEnd = inputBuffer.indexOf('\r\n\r\n');
let sepLen = 4;
if (headerEnd === -1) {
headerEnd = inputBuffer.indexOf('\n\n');
sepLen = 2;
}
if (headerEnd === -1) return; // wait for full header
rl.on('line', async (line) => {
try {
const message = JSON.parse(line);
await handleMessage(message);
} catch (err) {
// Ignore parse errors
const headerStr = inputBuffer.slice(0, headerEnd).toString('utf8');
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
}
}
});
rl.on('close', () => {
process.exit(0);
});
// Handle errors
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);

View File

@@ -127,7 +127,51 @@ class AcpConnection {
}
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需要手动设置
const env = {
@@ -137,22 +181,59 @@ class AcpConnection {
(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'],
},
);
// Resolve qwen CLI path more robustly
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',
'--allowed-mcp-server-names',
'chrome-browser,chrome-devtools',
'--debug',
];
this.process = spawn(spawnCommand, spawnArgs, {
cwd: normalizedCwd,
env,
shell: true,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (!this.process || !this.process.pid) {
this.process = null;
@@ -172,6 +253,12 @@ class AcpConnection {
const message = data.toString().trim();
if (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
const sessionResult = await this.createSession(cwd);
const sessionResult = await this.createSession(normalizedCwd);
if (!sessionResult.success) {
this.stop();
return sessionResult;
@@ -301,6 +388,17 @@ class AcpConnection {
});
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:
log(`Unknown ACP notification: ${method}`);
}
@@ -468,7 +566,7 @@ class AcpConnection {
this.process.stdin.write(json);
}
sendAcpRequest(method, params) {
sendAcpRequest(method, params, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const id = this.nextRequestId++;
this.pendingRequests.set(id, { resolve, reject });
@@ -485,13 +583,13 @@ class AcpConnection {
reject(err);
}
// Timeout after 30 seconds
setTimeout(() => {
// Timeout after specified duration
const timer = setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}
}, 30000);
}, timeoutMs);
});
}
@@ -513,21 +611,20 @@ class AcpConnection {
async initialize() {
try {
const result = await this.sendAcpRequest('initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
browser: {
readPage: true,
captureScreenshot: true,
getNetworkLogs: true,
getConsoleLogs: true,
const result = await this.sendAcpRequest(
'initialize',
{
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: {
fs: {
// Only advertise filesystem capabilities; CLI schema accepts only 'fs' here.
readTextFile: true,
writeTextFile: true,
},
},
},
});
30000,
);
log(`Qwen CLI initialized: ${JSON.stringify(result)}`);
return { success: true, data: result };
@@ -539,6 +636,39 @@ class AcpConnection {
async createSession(cwd) {
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
const browserMcpServerPath = path.join(
__dirname,
@@ -547,24 +677,65 @@ class AcpConnection {
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 = [
{
name: 'chrome-browser',
command: '/usr/local/bin/node',
command: nodeCommand,
args: [browserMcpServerPath],
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)}`);
const result = await this.sendAcpRequest('session/new', {
cwd,
mcpServers: mcpServersConfig,
});
// 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,
mcpServers: useMcp ? mcpServersConfig : [],
},
useMcp ? 180000 : 30000, // Fast startup without MCP
);
this.sessionId = result.sessionId;
log(`Session created: ${this.sessionId}`);
try {
log(`Session/new result: ${JSON.stringify(result)}`);
} catch (_) {}
return { success: true, data: result };
} catch (err) {
logError(`Failed to create session: ${err.message}`);
@@ -699,14 +870,14 @@ const acpConnection = new AcpConnection();
async function checkQwenInstallation() {
return new Promise((resolve) => {
try {
const checkProcess = spawn(
'/Users/yiliang/.npm-global/bin/qwen',
['--version'],
{
shell: true,
windowsHide: true,
},
);
const qwenPath =
process.env.QWEN_CLI_PATH ||
'/Users/yiliang/.npm-global/bin/qwen' ||
'qwen';
const checkProcess = spawn(qwenPath, ['--version'], {
shell: true,
windowsHide: true,
});
let output = '';
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) {
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':
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':
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'}`
`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':
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:
// 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...',
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;
case 'start_qwen':
@@ -836,8 +1026,12 @@ async function handleExtensionMessage(message) {
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);
// Convert action + data to a prompt for Qwen CLI, including user's original request
const promptText = buildPromptFromAction(
message.action,
message.data,
message.userPrompt,
);
if (acpConnection.status !== 'running') {
response = {
type: 'response',

View File

@@ -14,4 +14,13 @@ if [ ! -f "$NODE_PATH" ]; then
fi
# 执行 Native Host
exec "$NODE_PATH" "$DIR/host.js"
# 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"

View File

@@ -32,6 +32,13 @@ export const App: React.FC = () => {
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState<string | null>(null);
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<{
requestId: number;
options: PermissionOption[];
@@ -56,6 +63,61 @@ export const App: React.FC = () => {
case 'STATUS_UPDATE':
setIsConnected((message as { status: string }).status !== 'disconnected');
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':
setIsStreaming(true);
@@ -123,9 +185,18 @@ export const App: React.FC = () => {
// Check connection status on mount
useEffect(() => {
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) {
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();
@@ -182,6 +253,53 @@ export const App: React.FC = () => {
}
}, [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
const handlePermissionResponse = useCallback((optionId: string) => {
if (!permissionRequest) return;
@@ -207,8 +325,44 @@ export const App: React.FC = () => {
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-500'}`} />
<span className="text-xs text-gray-400">
{isConnected ? 'Connected' : 'Disconnected'}
{isConnected ? `Connected (${mcpTools.length + internalTools.length} tools)` : 'Disconnected'}
</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>
@@ -252,9 +406,13 @@ export const App: React.FC = () => {
)}
{/* Waiting indicator */}
{isWaitingForResponse && loadingMessage && (
{(isWaitingForResponse && loadingMessage) && (
<WaitingMessage loadingMessage={loadingMessage} />
)}
{/* If streaming started but no chunks yet, show thinking indicator */}
{(isStreaming && !streamingContent) && (
<WaitingMessage loadingMessage={loadingMessage || 'Thinking...'} />
)}
<div ref={messagesEndRef} />
</>
@@ -268,15 +426,15 @@ export const App: React.FC = () => {
inputFieldRef={inputFieldRef}
isStreaming={isStreaming}
isWaitingForResponse={isWaitingForResponse}
isComposing={false}
isComposing={isComposing}
editMode="default"
thinkingEnabled={false}
activeFileName={null}
activeSelection={null}
skipAutoActiveContext={true}
onInputChange={setInputText}
onCompositionStart={() => {}}
onCompositionEnd={() => {}}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmit}
onCancel={handleCancel}
@@ -312,6 +470,85 @@ export const App: React.FC = () => {
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>
);
};
// 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]);

View File

@@ -2,8 +2,26 @@
echo "🔧 更新 Native Host 配置..."
CONFIG_FILE="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json"
RUN_SCRIPT="$PWD/native-host/run.sh"
CONFIG_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
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
@@ -12,7 +30,7 @@ cat > "$CONFIG_FILE" <<EOF
"description": "Native messaging host for Qwen CLI Bridge",
"path": "$RUN_SCRIPT",
"type": "stdio",
"allowed_origins": ["chrome-extension://*/"]
"allowed_origins": ["chrome-extension://$EXT_ID/"]
}
EOF
@@ -23,4 +41,4 @@ cat "$CONFIG_FILE"
echo ""
echo "现在请:"
echo "1. 重新加载 Chrome 扩展 (chrome://extensions/)"
echo "2. 点击扩展图标测试连接"
echo "2. 在扩展侧边栏点击 Connect 测试连接"