feat: 浏览器扩展支持交互式页面操作 (browser_act)
在原有只读抓取 (browser_fetch_page) 基础上,新增 observe→act 循环, 让 Agent 能在用户的 Chrome 中实际操作网页:点击/输入/滚动/等待/页内跳转。 后端: - 新工具 browser_act,按 session 复用标签页,返回带编号的可交互元素清单+正文 - browser_context 任务队列扩展 kind/session_id/action 与 observation 结果 - /api/browser-context 路由下发 act 任务、接收 observation - BrowserContextConfig 增加 actions_enabled 等开关,tools.py 两级门控 - 接线 app.py 路由挂载、app_config.py 配置字段、config.yaml 工具注册 前端: - ChatPage 处理「发送当前页」回跳的 browser_context query 参数并自动提交 扩展 (browser-extension/deer-web-clipper): - shared.js 自包含注入函数 deerObservePage / deerPerformAction - background.js 按 kind 分发任务,session→tab 映射存 chrome.storage.session 测试:tests/test_browser_act.py(队列往返 + 工具校验 + 模拟扩展成功路径,8 passed) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0001f86826
commit
d4a71ce384
24 files changed
+4499
-162
No files matched your search
@@ -0,0 +1,148 @@
|
||||
const textFields = [
|
||||
"apiBaseUrl",
|
||||
"webBaseUrl",
|
||||
"accessToken",
|
||||
"defaultPrompt",
|
||||
"workerId",
|
||||
"taskPollIntervalSeconds",
|
||||
"taskPageLoadTimeoutSeconds",
|
||||
];
|
||||
const checkboxFields = [
|
||||
"autoDiscoverServiceUrl",
|
||||
"tokenSyncEnabled",
|
||||
"pageAssistantEnabled",
|
||||
"taskPollingEnabled",
|
||||
"closeTaskTabs",
|
||||
];
|
||||
|
||||
function formatServiceStatus(settings) {
|
||||
if (!settings.lastServiceSyncAt) {
|
||||
return `当前服务地址:${settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl};API:${settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl}`;
|
||||
}
|
||||
return `当前服务地址:${settings.webBaseUrl || ""};API:${settings.apiBaseUrl || ""}\n上次识别:${settings.lastServiceSyncAt}(${settings.lastServiceSyncSource || "unknown source"})`;
|
||||
}
|
||||
|
||||
function formatTokenStatus(settings) {
|
||||
if (!settings.lastTokenSyncAt) {
|
||||
return "Token 尚未自动同步。";
|
||||
}
|
||||
return `上次 token 同步:${settings.lastTokenSyncAt}(${settings.lastTokenSyncSource || "unknown source"})`;
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
if (!settings.workerId) {
|
||||
settings.workerId = deerMakeWorkerId();
|
||||
await chrome.storage.sync.set({ workerId: settings.workerId });
|
||||
}
|
||||
document.getElementById("apiBaseUrl").placeholder =
|
||||
DEER_CLIPPER_DEFAULTS.apiBaseUrl || "在 config.js 中配置默认 API 地址";
|
||||
document.getElementById("webBaseUrl").placeholder =
|
||||
DEER_CLIPPER_DEFAULTS.webBaseUrl || "在 config.js 中配置默认前端地址";
|
||||
for (const field of textFields) {
|
||||
document.getElementById(field).value = settings[field] ?? "";
|
||||
}
|
||||
for (const field of checkboxFields) {
|
||||
document.getElementById(field).checked = Boolean(settings[field]);
|
||||
}
|
||||
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
|
||||
document.getElementById("tokenStatus").textContent = formatTokenStatus(settings);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const next = {};
|
||||
for (const field of textFields) {
|
||||
next[field] = document.getElementById(field).value.trim();
|
||||
}
|
||||
for (const field of checkboxFields) {
|
||||
next[field] = document.getElementById(field).checked;
|
||||
}
|
||||
next.apiBaseUrl = deerNormalizeBaseUrl(next.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
next.webBaseUrl = deerNormalizeBaseUrl(next.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||
next.defaultPrompt = next.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
|
||||
next.workerId = next.workerId || deerMakeWorkerId();
|
||||
next.taskPollIntervalSeconds = Math.max(3, Number(next.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds));
|
||||
next.taskPageLoadTimeoutSeconds = Math.max(5, Number(next.taskPageLoadTimeoutSeconds || DEER_CLIPPER_DEFAULTS.taskPageLoadTimeoutSeconds));
|
||||
await chrome.storage.sync.set(next);
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
document.getElementById("status").textContent = "已保存。";
|
||||
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
|
||||
document.getElementById("tokenStatus").textContent = formatTokenStatus(settings);
|
||||
}
|
||||
|
||||
function sendDiscoverServiceMessage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ type: "zncm-discover-service" }, (response) => {
|
||||
const error = chrome.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response || { ok: false, message: "服务地址识别没有返回结果。" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendSyncTokenMessage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => {
|
||||
const error = chrome.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response || { ok: false, message: "Token 同步没有返回结果。" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverServiceNow() {
|
||||
const button = document.getElementById("discoverService");
|
||||
button.disabled = true;
|
||||
document.getElementById("serviceStatus").textContent = "正在识别服务地址...";
|
||||
try {
|
||||
await chrome.storage.sync.set({
|
||||
autoDiscoverServiceUrl: document.getElementById("autoDiscoverServiceUrl").checked,
|
||||
apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl),
|
||||
webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl),
|
||||
});
|
||||
const response = await sendDiscoverServiceMessage();
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || "";
|
||||
document.getElementById("webBaseUrl").value = settings.webBaseUrl || "";
|
||||
document.getElementById("serviceStatus").textContent = response.message || formatServiceStatus(settings);
|
||||
} catch (error) {
|
||||
document.getElementById("serviceStatus").textContent = error instanceof Error ? error.message : "服务地址识别失败。";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTokenNow() {
|
||||
const button = document.getElementById("syncToken");
|
||||
button.disabled = true;
|
||||
document.getElementById("tokenStatus").textContent = "正在同步 zncm token...";
|
||||
try {
|
||||
await chrome.storage.sync.set({
|
||||
tokenSyncEnabled: document.getElementById("tokenSyncEnabled").checked,
|
||||
apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl),
|
||||
webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl),
|
||||
});
|
||||
const response = await sendSyncTokenMessage();
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
document.getElementById("accessToken").value = settings.accessToken || "";
|
||||
document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || "";
|
||||
document.getElementById("webBaseUrl").value = settings.webBaseUrl || "";
|
||||
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
|
||||
document.getElementById("tokenStatus").textContent = response.message || formatTokenStatus(settings);
|
||||
} catch (error) {
|
||||
document.getElementById("tokenStatus").textContent = error instanceof Error ? error.message : "Token 同步失败。";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("save").addEventListener("click", saveSettings);
|
||||
document.getElementById("discoverService").addEventListener("click", discoverServiceNow);
|
||||
document.getElementById("syncToken").addEventListener("click", syncTokenNow);
|
||||
void loadSettings();
|
||||
Reference in new issue
Block a user