feat: release v0.74.0 - Chrome Bridge 浏览器自动化集成

🎉 重大功能
- Chrome Bridge: 完整的浏览器自动化控制系统
- 零配置自动连接:扩展自动加载和配置
- AI 原生集成:作为 MCP 工具支持自然语言控制
- 支持导航、页面读取、元素交互、表单操作等

🐛 Bug 修复
- WebSocket 路由修复 (Axum 路径参数语法)
- Chrome 扩展存储清理
- 扩展重复注入防护
- 剪贴板权限添加

🔧 代码质量
- 修复 33+ Clippy 警告
- 所有 259 个测试通过
- ESLint 无警告

📝 文档
- Chrome Bridge 使用指南
- API 技术文档
- 快速参考卡片
This commit is contained in:
coso
2026-02-28 16:05:36 +08:00
parent 0686737331
commit cd95cac72f
108 changed files with 18916 additions and 1921 deletions
+71
View File
@@ -0,0 +1,71 @@
# Proxycast Chrome Bridge 扩展
用于把 Chrome 页面能力接入 Proxycast 的浏览器桥接通道,供各业务 AI Agent 通过统一 `browser_execute_action` / MCP 浏览器工具调用。
## 功能
- Observer 通道自动连接:`/proxycast-chrome-observer/Proxycast_Key=...`
- 页面信息上报:标题、URL、Markdown
- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `go_back` 等
- 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取
## 安装
1. 打开 Chrome `chrome://extensions`
2. 打开右上角「开发者模式」
3. 点击「加载已解压的扩展程序」
4. 选择目录:`extensions/proxycast-chrome`
## 配置
点击扩展图标打开弹窗,配置:
- `Server URL`:Proxycast 服务地址,例如 `ws://127.0.0.1:8999`
- `Bridge Key`:Proxycast 服务 API Key(与后端 `Proxycast_Key` 一致)
- `Profile Key`:浏览器会话隔离键(建议与业务场景对应,如 `research_a`)
点击「保存并重连」后,扩展会建立 observer WebSocket 连接。
## 验证
1. 在 Proxycast 设置中查看 `get_chrome_bridge_status`,`observer_count` 应大于 0
2. 调用 `browser_execute_action`:
```json
{
"profile_key": "default",
"action": "navigate",
"args": { "url": "https://example.com" }
}
```
3. 再调用 `browser_execute_action`:
```json
{
"action": "read_page"
}
```
如果返回 `success=true` 且 `data.markdown` 有内容,说明链路可用。
## 自动化联调脚本
仓库提供了桥接链路的端到端联调脚本(模拟 observer/control 双端):
```bash
npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default
```
脚本会验证:
- observer/control 握手
- 双向心跳 ack
- `wait_for_page_info=true` 命令链路(`command_result` + `page_info_update`)
- 普通命令链路(`command_result`)
## 兼容说明
- 扩展只负责浏览器侧采集与动作执行。
- Agent 侧通过 `aster_agent_cmd` 与 `unified_chat_cmd` 注册的浏览器 MCP 兼容工具访问。
- 若你同时使用独立 Chrome Profile(Tauri `open_chrome_profile_window`),请在对应 Profile 内安装该扩展,并使用不同 `profileKey` 做隔离。
+667
View File
@@ -0,0 +1,667 @@
const HEARTBEAT_INTERVAL_MS = 30000;
const RECONNECT_MIN_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 30000;
const PAGE_CAPTURE_RETRY_LIMIT = 3;
const DEFAULT_SETTINGS = {
serverUrl: "ws://127.0.0.1:8999",
bridgeKey: "",
profileKey: "default",
monitoringEnabled: true,
};
let ws = null;
let isConnected = false;
let reconnectAttempts = 0;
let reconnectTimer = null;
let heartbeatTimer = null;
let activeTabId = null;
let monitoringEnabled = true;
let latestPageInfo = null;
let lastSettings = { ...DEFAULT_SETTINGS };
function logInfo(message, payload) {
if (payload === undefined) {
console.log(`[ProxycastBridge] ${message}`);
} else {
console.log(`[ProxycastBridge] ${message}`, payload);
}
}
function logWarn(message, payload) {
if (payload === undefined) {
console.warn(`[ProxycastBridge] ${message}`);
} else {
console.warn(`[ProxycastBridge] ${message}`, payload);
}
}
function readSettings() {
return new Promise((resolve) => {
chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => {
resolve({ ...DEFAULT_SETTINGS, ...stored });
});
});
}
function writeSettings(partial) {
return new Promise((resolve) => {
chrome.storage.local.set(partial, () => resolve());
});
}
function buildObserverUrl(settings) {
const serverUrl = String(settings.serverUrl || "").trim();
const bridgeKey = String(settings.bridgeKey || "").trim();
if (!serverUrl || !bridgeKey) {
return null;
}
const normalized = serverUrl.replace(/\/$/, "");
const profileKey = encodeURIComponent(settings.profileKey || "default");
return `${normalized}/proxycast-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${profileKey}`;
}
async function connectObserver(forceReconnect = false) {
if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) {
return;
}
clearReconnectTimer();
clearHeartbeatTimer();
const settings = await readSettings();
lastSettings = settings;
monitoringEnabled = Boolean(settings.monitoringEnabled);
const url = buildObserverUrl(settings);
if (!url) {
logWarn("缺少 serverUrl 或 bridgeKey,无法建立连接");
setConnectionState(false);
broadcastStatus();
return;
}
if (forceReconnect && ws) {
try {
ws.close();
} catch (_) {}
}
logInfo(`连接 observer: ${url}`);
ws = new WebSocket(url);
ws.onopen = () => {
reconnectAttempts = 0;
setConnectionState(true);
startHeartbeat();
broadcastStatus();
triggerPageCapture("ws_open");
};
ws.onmessage = async (event) => {
try {
const payload = JSON.parse(event.data);
await handleObserverMessage(payload);
} catch (error) {
logWarn("解析消息失败", error?.message || String(error));
}
};
ws.onclose = () => {
setConnectionState(false);
clearHeartbeatTimer();
scheduleReconnect();
broadcastStatus();
};
ws.onerror = (error) => {
logWarn("WebSocket 错误", error?.message || error);
};
}
function disconnectObserver(manual = true) {
clearReconnectTimer();
clearHeartbeatTimer();
if (ws) {
try {
ws.close();
} catch (_) {}
}
if (manual) {
setConnectionState(false);
broadcastStatus();
}
}
function setConnectionState(connected) {
isConnected = connected;
chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" });
chrome.action.setBadgeBackgroundColor({ color: connected ? "#16a34a" : "#dc2626" });
}
function startHeartbeat() {
clearHeartbeatTimer();
heartbeatTimer = setInterval(() => {
sendObserverMessage({ type: "heartbeat", timestamp: Date.now() });
}, HEARTBEAT_INTERVAL_MS);
}
function clearHeartbeatTimer() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectAttempts += 1;
const delay = Math.min(
RECONNECT_MAX_DELAY_MS,
RECONNECT_MIN_DELAY_MS * Math.pow(2, reconnectAttempts - 1),
);
reconnectTimer = setTimeout(async () => {
reconnectTimer = null;
await connectObserver();
}, delay);
logInfo(`连接断开,${delay}ms 后重连(第 ${reconnectAttempts} 次)`);
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function sendObserverMessage(payload) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return false;
}
ws.send(JSON.stringify(payload));
return true;
}
function broadcastStatus(extra) {
chrome.runtime
.sendMessage({
type: "STATUS_UPDATE",
data: {
isConnected,
monitoringEnabled,
activeTabId,
latestPageInfo,
settings: {
...lastSettings,
bridgeKey: lastSettings.bridgeKey ? "***" : "",
},
...extra,
},
})
.catch(() => {});
}
async function handleObserverMessage(payload) {
const type = payload?.type;
if (type === "heartbeat_ack" || type === "connection_ack") {
return;
}
if (type !== "command" || !payload.data) {
return;
}
await executeRemoteCommand(payload.data);
}
async function executeRemoteCommand(commandData) {
const command = String(commandData.command || "").trim();
if (!command) {
return;
}
const requestId = commandData.requestId;
const sourceClientId = commandData.sourceClientId;
const waitForPageInfo = commandData.wait_for_page_info === true;
if (command === "open_url") {
await handleOpenUrl(commandData, waitForPageInfo);
return;
}
if (command === "switch_tab") {
await handleSwitchTab(commandData, waitForPageInfo);
return;
}
const tabId = await resolveTargetTabId();
if (!tabId) {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: "没有可用的活动标签页",
});
return;
}
try {
const response = await sendCommandToTab(tabId, {
type: "EXECUTE_COMMAND",
data: commandData,
});
if (response?.status === "error") {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: response.error || "命令执行失败",
});
return;
}
sendCommandResult({
requestId,
sourceClientId,
status: "success",
message: response?.message || `${command} 执行成功`,
});
if (waitForPageInfo || command === "get_page_info") {
await triggerPageCapture("command_result");
}
} catch (error) {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: error?.message || String(error),
});
}
}
async function handleOpenUrl(commandData, waitForPageInfo) {
const requestId = commandData.requestId;
const sourceClientId = commandData.sourceClientId;
let targetUrl = String(commandData.url || "").trim();
if (!targetUrl) {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: "open_url 缺少 url 参数",
});
return;
}
if (!/^https?:\/\//i.test(targetUrl)) {
targetUrl = `https://${targetUrl}`;
}
try {
const tab = await new Promise((resolve, reject) => {
chrome.tabs.create({ url: targetUrl, active: true }, (created) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(created);
});
});
activeTabId = tab.id;
sendCommandResult({
requestId,
sourceClientId,
status: "success",
message: `已打开 ${targetUrl}`,
});
if (waitForPageInfo) {
await waitTabLoadComplete(tab.id, 30000);
await triggerPageCapture("open_url_complete");
}
} catch (error) {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: error?.message || String(error),
});
}
}
async function handleSwitchTab(commandData, waitForPageInfo) {
const requestId = commandData.requestId;
const sourceClientId = commandData.sourceClientId;
const raw = String(commandData.target || "").trim();
if (!raw) {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: "switch_tab 缺少 target 参数",
});
return;
}
let targetTab = null;
const byId = Number(raw);
if (!Number.isNaN(byId) && byId > 0) {
try {
targetTab = await chrome.tabs.get(byId);
} catch (_) {
targetTab = null;
}
}
if (!targetTab) {
const tabs = await chrome.tabs.query({ currentWindow: true });
const idx = Number(raw);
if (!Number.isNaN(idx) && idx >= 0 && idx < tabs.length) {
targetTab = tabs[idx];
}
}
if (!targetTab || !targetTab.id) {
sendCommandResult({
requestId,
sourceClientId,
status: "error",
error: `未找到标签页: ${raw}`,
});
return;
}
await chrome.tabs.update(targetTab.id, { active: true });
activeTabId = targetTab.id;
sendCommandResult({
requestId,
sourceClientId,
status: "success",
message: `已切换到标签页 ${targetTab.id}`,
});
if (waitForPageInfo) {
await triggerPageCapture("switch_tab");
}
}
function sendCommandResult(data) {
sendObserverMessage({
type: "command_result",
data,
});
}
async function resolveTargetTabId() {
if (activeTabId) {
try {
const tab = await chrome.tabs.get(activeTabId);
if (tab && !tab.discarded) {
return tab.id;
}
} catch (_) {}
}
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id) {
activeTabId = tabs[0].id;
return tabs[0].id;
}
return null;
}
async function sendCommandToTab(tabId, payload) {
try {
return await chrome.tabs.sendMessage(tabId, payload);
} catch (error) {
await injectContentScript(tabId);
return await chrome.tabs.sendMessage(tabId, payload);
}
}
async function injectContentScript(tabId) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["content_script.js"],
});
}
async function triggerPageCapture(reason, retry = 0) {
if (!monitoringEnabled && reason !== "manual") {
return;
}
const tabId = await resolveTargetTabId();
if (!tabId) {
return;
}
try {
await sendCommandToTab(tabId, {
type: "REQUEST_PAGE_CAPTURE",
data: { reason },
});
} catch (error) {
if (retry < PAGE_CAPTURE_RETRY_LIMIT) {
setTimeout(() => {
triggerPageCapture(reason, retry + 1);
}, 250 * (retry + 1));
} else {
logWarn("页面抓取请求失败", error?.message || String(error));
}
}
}
function waitTabLoadComplete(tabId, timeoutMs) {
return new Promise((resolve) => {
let resolved = false;
const timer = setTimeout(() => {
if (resolved) {
return;
}
resolved = true;
chrome.tabs.onUpdated.removeListener(onUpdated);
resolve();
}, timeoutMs);
const onUpdated = (updatedTabId, changeInfo) => {
if (updatedTabId !== tabId || changeInfo.status !== "complete") {
return;
}
if (resolved) {
return;
}
resolved = true;
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(onUpdated);
resolve();
};
chrome.tabs.onUpdated.addListener(onUpdated);
});
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
const type = request?.type;
if (type === "GET_STATUS") {
sendResponse({
isConnected,
monitoringEnabled,
activeTabId,
latestPageInfo,
settings: {
...lastSettings,
bridgeKey: lastSettings.bridgeKey ? "***" : "",
},
});
return true;
}
if (type === "UPDATE_SETTINGS") {
const patch = request?.data || {};
const next = {
serverUrl: typeof patch.serverUrl === "string" ? patch.serverUrl : lastSettings.serverUrl,
bridgeKey: typeof patch.bridgeKey === "string" ? patch.bridgeKey : lastSettings.bridgeKey,
profileKey: typeof patch.profileKey === "string" ? patch.profileKey : lastSettings.profileKey,
monitoringEnabled:
typeof patch.monitoringEnabled === "boolean"
? patch.monitoringEnabled
: monitoringEnabled,
};
writeSettings(next).then(async () => {
lastSettings = { ...lastSettings, ...next };
monitoringEnabled = Boolean(next.monitoringEnabled);
if (request?.data?.reconnect === true) {
await connectObserver(true);
}
broadcastStatus();
sendResponse({ success: true });
});
return true;
}
if (type === "TOGGLE_CONNECTION") {
if (isConnected) {
disconnectObserver(true);
sendResponse({ success: true, isConnected: false });
} else {
connectObserver().then(() => {
sendResponse({ success: true, isConnected: isConnected });
});
}
return true;
}
if (type === "TOGGLE_MONITORING") {
monitoringEnabled = !monitoringEnabled;
writeSettings({ monitoringEnabled }).then(() => {
if (monitoringEnabled) {
triggerPageCapture("manual");
}
broadcastStatus();
sendResponse({ success: true, monitoringEnabled });
});
return true;
}
if (type === "REQUEST_PAGE_CAPTURE") {
triggerPageCapture("manual").then(() => {
sendResponse({ success: true });
});
return true;
}
if (type === "PAGE_INFO_UPDATE") {
const senderTabId = sender?.tab?.id;
if (senderTabId && activeTabId && senderTabId !== activeTabId) {
return true;
}
const markdown = request?.data?.markdown;
if (typeof markdown !== "string" || !markdown.trim()) {
return true;
}
latestPageInfo = {
title: request?.data?.title || "",
url: request?.data?.url || "",
timestamp: Date.now(),
markdown,
};
chrome.storage.local.set({ latestPageInfo });
sendObserverMessage({
type: "pageInfoUpdate",
data: { markdown },
});
broadcastStatus({ latestPageInfo });
return true;
}
if (type === "COMMAND_RESULT") {
if (request?.data) {
sendCommandResult(request.data);
}
return true;
}
return true;
});
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
activeTabId = tabId;
await triggerPageCapture("tab_activated");
broadcastStatus();
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (tab.active) {
activeTabId = tabId;
}
if (tab.active && changeInfo.status === "complete") {
await triggerPageCapture("tab_updated");
}
});
async function loadAutoConfig() {
try {
const configUrl = chrome.runtime.getURL("auto_config.json");
logInfo(`尝试加载自动配置: ${configUrl}`);
const response = await fetch(configUrl);
logInfo(`fetch 响应状态: ${response.status}`);
if (!response.ok) {
logWarn(`自动配置文件不存在或无法访问: ${response.status}`);
return;
}
const config = await response.json();
logInfo("成功读取自动配置", config);
if (config.serverUrl && config.bridgeKey) {
logInfo("检测到自动配置,正在应用...", config);
await writeSettings({
serverUrl: config.serverUrl,
bridgeKey: config.bridgeKey,
profileKey: config.profileKey || "default",
monitoringEnabled: config.monitoringEnabled !== false,
});
logInfo("自动配置已应用");
} else {
logWarn("自动配置缺少必要字段", config);
}
} catch (error) {
// 文件不存在或解析失败时记录错误
logWarn("加载自动配置失败", error?.message || String(error));
}
}
async function init() {
await loadAutoConfig();
const settings = await readSettings();
lastSettings = settings;
monitoringEnabled = Boolean(settings.monitoringEnabled);
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
activeTabId = tabs[0]?.id || null;
if (settings.serverUrl && settings.bridgeKey) {
await connectObserver();
} else {
setConnectionState(false);
broadcastStatus();
}
chrome.storage.local.get(["latestPageInfo"], (stored) => {
if (stored.latestPageInfo) {
latestPageInfo = stored.latestPageInfo;
broadcastStatus();
}
});
}
init();
@@ -0,0 +1,300 @@
// 使用 IIFE 避免重复注入时的变量冲突
(function () {
// 检查是否已经注入过
if (window.__PROXYCAST_CONTENT_SCRIPT_LOADED__) {
return;
}
window.__PROXYCAST_CONTENT_SCRIPT_LOADED__ = true;
let refCounter = 0;
const REF_ATTR = "proxycast-id";
function nextRefId() {
refCounter += 1;
return `proxycast-${refCounter}`;
}
function resetRefs() {
refCounter = 0;
document.querySelectorAll(`[${REF_ATTR}]`).forEach((el) => {
el.removeAttribute(REF_ATTR);
});
}
function normalizeText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function isElementVisible(element) {
if (!element) {
return false;
}
const style = window.getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.opacity === "0"
) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isInteractiveElement(element) {
if (!element || !isElementVisible(element)) {
return false;
}
const tag = element.tagName.toLowerCase();
const role = element.getAttribute("role");
if (["a", "button", "input", "textarea", "select", "option"].includes(tag)) {
return true;
}
if (
role &&
[
"button",
"link",
"checkbox",
"radio",
"menuitem",
"tab",
"switch",
"option",
"searchbox",
"textbox",
"combobox",
].includes(role)
) {
return true;
}
if (element.hasAttribute("onclick")) {
return true;
}
const style = window.getComputedStyle(element);
return style.cursor === "pointer";
}
function interactiveLabel(element) {
const tag = element.tagName.toLowerCase();
const role = element.getAttribute("role");
const text = normalizeText(
element.innerText ||
element.value ||
element.placeholder ||
element.getAttribute("aria-label") ||
element.title ||
element.name ||
element.id,
);
if (tag === "a") {
return `链接: ${text || "无标题链接"}`;
}
if (tag === "button" || role === "button") {
return `按钮: ${text || "无标题按钮"}`;
}
if (tag === "input" || tag === "textarea") {
return `输入框: ${text || "未命名输入框"}`;
}
if (tag === "select") {
return `下拉框: ${text || "未命名下拉框"}`;
}
return `可交互元素: ${text || tag}`;
}
function buildMarkdown() {
resetRefs();
const lines = [];
lines.push(`# ${document.title || "Untitled"}`);
lines.push(`URL: ${window.location.href}`);
lines.push("");
const bodyText = normalizeText(document.body ? document.body.innerText : "");
if (bodyText) {
lines.push("## 页面文本");
lines.push(bodyText.slice(0, 6000));
lines.push("");
}
lines.push("## 可交互元素");
const allElements = Array.from(document.querySelectorAll("*")).filter(isInteractiveElement);
for (const element of allElements.slice(0, 300)) {
const refId = nextRefId();
element.setAttribute(REF_ATTR, refId);
lines.push(`- [${interactiveLabel(element)}](${refId})`);
}
return lines.join("\n").trim();
}
function findElement(target) {
if (!target || typeof target !== "string") {
return null;
}
const trimmed = target.trim();
if (!trimmed) {
return null;
}
let element = document.querySelector(`[${REF_ATTR}="${CSS.escape(trimmed)}"]`);
if (element) {
return element;
}
try {
element = document.querySelector(trimmed);
if (element) {
return element;
}
} catch (_) {}
element = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button']")).find(
(candidate) => normalizeText(candidate.innerText || candidate.value || candidate.placeholder || "") === trimmed,
);
if (element) {
return element;
}
element = Array.from(document.querySelectorAll("[aria-label]")).find(
(candidate) => normalizeText(candidate.getAttribute("aria-label")) === trimmed,
);
return element || null;
}
function scrollPage(text) {
const input = normalizeText(text);
let direction = "down";
let amount = 500;
if (input.includes(":")) {
const parts = input.split(":");
direction = normalizeText(parts[0]) || "down";
const parsed = Number(parts[1]);
if (!Number.isNaN(parsed) && parsed > 0) {
amount = parsed;
}
}
if (direction === "up") {
window.scrollBy(0, -amount);
} else if (direction === "left") {
window.scrollBy(-amount, 0);
} else if (direction === "right") {
window.scrollBy(amount, 0);
} else {
window.scrollBy(0, amount);
}
}
async function executeCommand(commandData) {
const command = String(commandData.command || "").trim();
const target = commandData.target;
const text = commandData.text;
switch (command) {
case "click": {
const element = findElement(target);
if (!element) {
return { status: "error", error: `未找到点击目标: ${target}` };
}
element.click();
return { status: "success", message: "click 执行成功" };
}
case "type": {
const element = findElement(target);
if (!element) {
return { status: "error", error: `未找到输入目标: ${target}` };
}
const value = text == null ? "" : String(text);
if ("value" in element) {
element.focus();
element.value = value;
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
} else {
element.textContent = value;
}
return { status: "success", message: "type 执行成功" };
}
case "scroll":
case "scroll_page": {
scrollPage(text);
return { status: "success", message: "scroll 执行成功" };
}
case "get_page_info": {
await sendPageInfo("get_page_info");
return { status: "success", message: "页面信息已回传" };
}
case "refresh_page": {
window.location.reload();
return { status: "success", message: "页面刷新中" };
}
case "go_back": {
window.history.back();
return { status: "success", message: "执行后退" };
}
case "go_forward": {
window.history.forward();
return { status: "success", message: "执行前进" };
}
default:
return { status: "error", error: `不支持的命令: ${command}` };
}
}
async function sendPageInfo(reason) {
const markdown = buildMarkdown();
const payload = {
type: "PAGE_INFO_UPDATE",
data: {
reason,
title: document.title || "",
url: window.location.href,
markdown,
},
};
await chrome.runtime.sendMessage(payload);
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
const type = request?.type;
if (type === "REQUEST_PAGE_CAPTURE") {
sendPageInfo(request?.data?.reason || "manual")
.then(() => sendResponse({ status: "success" }))
.catch((error) =>
sendResponse({ status: "error", error: error?.message || String(error) }),
);
return true;
}
if (type === "EXECUTE_COMMAND") {
executeCommand(request?.data || {})
.then(async (result) => {
if (request?.data?.wait_for_page_info === true) {
setTimeout(() => {
sendPageInfo("wait_for_page_info");
}, 400);
}
sendResponse(result);
})
.catch((error) =>
sendResponse({ status: "error", error: error?.message || String(error) }),
);
return true;
}
return true;
});
setTimeout(() => {
sendPageInfo("content_script_ready").catch(() => {});
}, 800);
})(); // 结束 IIFE
+22
View File
@@ -0,0 +1,22 @@
{
"manifest_version": 3,
"name": "Proxycast Browser Bridge",
"description": "Connect Chrome tabs with Proxycast browser runtime for AI agents.",
"version": "0.1.0",
"permissions": ["storage", "tabs", "activeTab", "scripting", "clipboardRead"],
"host_permissions": ["http://*/*", "https://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Proxycast Browser Bridge",
"default_popup": "popup.html"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content_script.js"],
"run_at": "document_idle"
}
]
}
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proxycast Browser Bridge</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 420px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: #333;
background: #f9fafb;
padding: 16px;
}
.header {
margin-bottom: 16px;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: #111;
margin-bottom: 4px;
}
.header p {
font-size: 12px;
color: #6b7280;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.status-label {
font-size: 12px;
color: #6b7280;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.badge-on {
background: #d1fae5;
color: #065f46;
}
.badge-off {
background: #fee2e2;
color: #991b1b;
}
.quick-config {
margin-bottom: 16px;
padding: 12px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
}
.quick-config-title {
font-size: 12px;
font-weight: 500;
color: #1e40af;
margin-bottom: 8px;
}
.quick-config-hint {
font-size: 11px;
color: #3b82f6;
margin-bottom: 8px;
}
.quick-config-buttons {
display: flex;
gap: 6px;
}
.form-group {
margin-bottom: 12px;
}
.form-label {
display: block;
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.form-input {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.form-input:focus {
border-color: #3b82f6;
}
.endpoint-preview {
font-size: 11px;
color: #6b7280;
word-break: break-all;
margin-top: 8px;
padding: 8px;
background: #f3f4f6;
border-radius: 4px;
}
.button-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
background: white;
color: #374151;
}
.btn:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
}
.btn-small {
padding: 6px 10px;
font-size: 11px;
}
.page-info {
margin-top: 12px;
padding: 10px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
}
.page-info-title {
font-size: 12px;
font-weight: 500;
color: #111;
margin-bottom: 4px;
}
.page-info-url {
font-size: 11px;
color: #6b7280;
word-break: break-all;
}
</style>
</head>
<body>
<div class="header">
<h1>Proxycast Browser Bridge</h1>
<p>连接 Chrome 与 Proxycast 服务</p>
</div>
<div class="status-row">
<span class="status-label">Observer 连接:</span>
<span id="bridgeStatus" class="badge badge-off">未连接</span>
</div>
<div class="status-row">
<span class="status-label">页面监控:</span>
<span id="monitorStatus" class="badge badge-off">关闭</span>
</div>
<div class="quick-config">
<div class="quick-config-title">🚀 一键配置</div>
<div class="quick-config-hint">从剪贴板粘贴配置(在 ProxyCast 页面点击"复制配置")</div>
<div class="quick-config-buttons">
<button id="pasteConfigBtn" class="btn btn-small">粘贴配置</button>
<button id="clearConfigBtn" class="btn btn-small">清除配置</button>
</div>
</div>
<div class="form-group">
<label class="form-label" for="serverUrl">Server URL</label>
<input
type="text"
id="serverUrl"
class="form-input"
placeholder="ws://127.0.0.1:8999"
/>
</div>
<div class="form-group">
<label class="form-label" for="bridgeKey">Bridge Key</label>
<input
type="text"
id="bridgeKey"
class="form-input"
placeholder="输入 Proxycast API Key"
/>
</div>
<div class="form-group">
<label class="form-label" for="profileKey">Profile Key</label>
<input
type="text"
id="profileKey"
class="form-input"
placeholder="default"
/>
</div>
<div class="endpoint-preview" id="endpointPreview">
Observer URL: 未配置
</div>
<div class="button-group">
<button id="saveBtn" class="btn btn-primary">保存并重连</button>
<button id="toggleConnBtn" class="btn">断开</button>
</div>
<div class="button-group">
<button id="toggleMonitorBtn" class="btn">切换监控</button>
<button id="captureBtn" class="btn">立即抓取</button>
</div>
<div class="page-info">
<div class="page-info-title" id="pageTitle">无页面信息</div>
<div class="page-info-url" id="pageUrl"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
const DEFAULT_SETTINGS = {
serverUrl: "ws://127.0.0.1:8999",
bridgeKey: "",
profileKey: "default",
monitoringEnabled: true,
};
const bridgeStatusEl = document.getElementById("bridgeStatus");
const monitorStatusEl = document.getElementById("monitorStatus");
const endpointPreviewEl = document.getElementById("endpointPreview");
const serverUrlEl = document.getElementById("serverUrl");
const bridgeKeyEl = document.getElementById("bridgeKey");
const profileKeyEl = document.getElementById("profileKey");
const saveBtnEl = document.getElementById("saveBtn");
const toggleConnBtnEl = document.getElementById("toggleConnBtn");
const toggleMonitorBtnEl = document.getElementById("toggleMonitorBtn");
const captureBtnEl = document.getElementById("captureBtn");
const pageTitleEl = document.getElementById("pageTitle");
const pageUrlEl = document.getElementById("pageUrl");
function setBadge(el, isOn, onText, offText) {
el.textContent = isOn ? onText : offText;
el.className = `badge ${isOn ? "badge-on" : "badge-off"}`;
}
function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) {
const base = String(serverUrl || "").trim().replace(/\/$/, "");
const key = String(bridgeKey || "").trim();
const profile = encodeURIComponent(String(profileKey || "default").trim() || "default");
if (!base || !key) {
return "Observer URL: 未配置";
}
return `Observer URL: ${base}/proxycast-chrome-observer/Proxycast_Key=${encodeURIComponent(key)}?profileKey=${profile}`;
}
function applyStatus(status) {
const connected = Boolean(status?.isConnected);
const monitoring = Boolean(status?.monitoringEnabled);
setBadge(bridgeStatusEl, connected, "已连接", "未连接");
setBadge(monitorStatusEl, monitoring, "开启", "关闭");
const latestPageInfo = status?.latestPageInfo;
if (latestPageInfo?.title || latestPageInfo?.url) {
pageTitleEl.textContent = latestPageInfo.title || "无标题";
pageUrlEl.textContent = latestPageInfo.url || "";
}
const settings = status?.settings;
if (settings) {
if (typeof settings.serverUrl === "string" && settings.serverUrl) {
serverUrlEl.value = settings.serverUrl;
}
if (typeof settings.profileKey === "string" && settings.profileKey) {
profileKeyEl.value = settings.profileKey;
}
endpointPreviewEl.textContent = buildObserverEndpoint(
settings.serverUrl,
bridgeKeyEl.value,
settings.profileKey,
);
}
}
function sendMessage(message) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response);
});
});
}
function readStoredSettings() {
return new Promise((resolve) => {
chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => {
resolve({ ...DEFAULT_SETTINGS, ...stored });
});
});
}
async function loadInitialState() {
const settings = await readStoredSettings();
serverUrlEl.value = settings.serverUrl;
bridgeKeyEl.value = settings.bridgeKey;
profileKeyEl.value = settings.profileKey;
endpointPreviewEl.textContent = buildObserverEndpoint(
settings.serverUrl,
settings.bridgeKey,
settings.profileKey,
);
try {
const status = await sendMessage({ type: "GET_STATUS" });
applyStatus(status || {});
} catch (error) {
console.warn("[ProxycastBridgePopup] 获取状态失败", error?.message || String(error));
}
}
async function saveAndReconnect() {
const payload = {
serverUrl: serverUrlEl.value.trim(),
bridgeKey: bridgeKeyEl.value.trim(),
profileKey: profileKeyEl.value.trim() || "default",
reconnect: true,
};
endpointPreviewEl.textContent = buildObserverEndpoint(
payload.serverUrl,
payload.bridgeKey,
payload.profileKey,
);
saveBtnEl.disabled = true;
const originalText = saveBtnEl.textContent;
saveBtnEl.textContent = "保存中...";
try {
await sendMessage({ type: "UPDATE_SETTINGS", data: payload });
saveBtnEl.textContent = "已保存";
setTimeout(() => {
saveBtnEl.textContent = originalText;
saveBtnEl.disabled = false;
}, 900);
} catch (error) {
saveBtnEl.textContent = "保存失败";
setTimeout(() => {
saveBtnEl.textContent = originalText;
saveBtnEl.disabled = false;
}, 1200);
console.warn("[ProxycastBridgePopup] 保存设置失败", error?.message || String(error));
}
}
async function toggleConnection() {
try {
await sendMessage({ type: "TOGGLE_CONNECTION" });
} catch (error) {
console.warn("[ProxycastBridgePopup] 切换连接失败", error?.message || String(error));
}
}
async function toggleMonitoring() {
try {
await sendMessage({ type: "TOGGLE_MONITORING" });
} catch (error) {
console.warn("[ProxycastBridgePopup] 切换监控失败", error?.message || String(error));
}
}
async function capturePageNow() {
captureBtnEl.disabled = true;
const originalText = captureBtnEl.textContent;
captureBtnEl.textContent = "抓取中...";
try {
await sendMessage({ type: "REQUEST_PAGE_CAPTURE" });
} catch (error) {
console.warn("[ProxycastBridgePopup] 请求抓取失败", error?.message || String(error));
} finally {
setTimeout(() => {
captureBtnEl.textContent = originalText;
captureBtnEl.disabled = false;
}, 800);
}
}
async function pasteConfigFromClipboard() {
const pasteBtn = document.getElementById("pasteConfigBtn");
pasteBtn.disabled = true;
const originalText = pasteBtn.textContent;
pasteBtn.textContent = "粘贴中...";
try {
const text = await navigator.clipboard.readText();
const config = JSON.parse(text);
if (config.serverUrl) {
serverUrlEl.value = config.serverUrl;
}
if (config.bridgeKey) {
bridgeKeyEl.value = config.bridgeKey;
}
if (config.profileKey) {
profileKeyEl.value = config.profileKey;
}
endpointPreviewEl.textContent = buildObserverEndpoint(
serverUrlEl.value,
bridgeKeyEl.value,
profileKeyEl.value,
);
pasteBtn.textContent = "已粘贴";
setTimeout(() => {
pasteBtn.textContent = originalText;
pasteBtn.disabled = false;
}, 1000);
} catch (error) {
pasteBtn.textContent = "粘贴失败";
setTimeout(() => {
pasteBtn.textContent = originalText;
pasteBtn.disabled = false;
}, 1500);
console.warn("[ProxycastBridgePopup] 粘贴配置失败", error?.message || String(error));
}
}
function clearConfig() {
serverUrlEl.value = DEFAULT_SETTINGS.serverUrl;
bridgeKeyEl.value = "";
profileKeyEl.value = DEFAULT_SETTINGS.profileKey;
endpointPreviewEl.textContent = buildObserverEndpoint(
serverUrlEl.value,
bridgeKeyEl.value,
profileKeyEl.value,
);
}
saveBtnEl.addEventListener("click", saveAndReconnect);
toggleConnBtnEl.addEventListener("click", toggleConnection);
toggleMonitorBtnEl.addEventListener("click", toggleMonitoring);
captureBtnEl.addEventListener("click", capturePageNow);
document.getElementById("pasteConfigBtn").addEventListener("click", pasteConfigFromClipboard);
document.getElementById("clearConfigBtn").addEventListener("click", clearConfig);
for (const input of [serverUrlEl, bridgeKeyEl, profileKeyEl]) {
input.addEventListener("input", () => {
endpointPreviewEl.textContent = buildObserverEndpoint(
serverUrlEl.value,
bridgeKeyEl.value,
profileKeyEl.value,
);
});
}
chrome.runtime.onMessage.addListener((request) => {
if (request?.type === "STATUS_UPDATE") {
applyStatus(request?.data || {});
}
});
loadInitialState();