Merge branch 'gzy_browser' into 'master'
fix See merge request knowledge-base-bj/deerflow-code!37
This commit is contained in:
commit
7dbac9cc56
3 files changed
+241
-19
No files matched your search
@@ -5,6 +5,9 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
|
|||||||
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let isPolling = false;
|
let isPolling = false;
|
||||||
|
let startupPromise = null;
|
||||||
|
|
||||||
|
const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]);
|
||||||
|
|
||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -12,6 +15,14 @@ function delay(ms) {
|
|||||||
|
|
||||||
async function getSettings() {
|
async function getSettings() {
|
||||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||||
|
const discovered = await chrome.storage.local.get({
|
||||||
|
discoveredApiBaseUrl: "",
|
||||||
|
discoveredWebBaseUrl: "",
|
||||||
|
});
|
||||||
|
if (settings.autoDiscoverServiceUrl) {
|
||||||
|
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
|
||||||
|
settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl;
|
||||||
|
}
|
||||||
const updates = {};
|
const updates = {};
|
||||||
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
|
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
|
||||||
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
|
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
|
||||||
@@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
add(currentApiBase);
|
|
||||||
add(webBase);
|
add(webBase);
|
||||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
|
||||||
add(LEGACY_LOCAL_API_BASE_URL);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(normalizeServiceBase(webBase));
|
const url = new URL(normalizeServiceBase(webBase));
|
||||||
@@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
|||||||
// Ignore invalid candidates.
|
// Ignore invalid candidates.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add(currentApiBase);
|
||||||
|
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||||
|
add(LEGACY_LOCAL_API_BASE_URL);
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) {
|
|||||||
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
// The gateway may enforce authentication globally before this otherwise
|
||||||
|
// public discovery endpoint is reached. A 401/403 still proves that this
|
||||||
|
// candidate is the zncm API paired with the open frontend tab.
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return { enabled: true, authenticationRequired: true };
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) {
|
|||||||
settings.webBaseUrl !== next.webBaseUrl ||
|
settings.webBaseUrl !== next.webBaseUrl ||
|
||||||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||||||
) {
|
) {
|
||||||
await chrome.storage.sync.set({
|
// Discovered endpoints are runtime state, not user-authored preferences.
|
||||||
apiBaseUrl: next.apiBaseUrl,
|
// Keep them in storage.local so Chrome sync write quotas cannot break task
|
||||||
webBaseUrl: next.webBaseUrl,
|
// polling. The options page may still persist explicit user choices.
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
discoveredApiBaseUrl: next.apiBaseUrl,
|
||||||
|
discoveredWebBaseUrl: next.webBaseUrl,
|
||||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||||
});
|
});
|
||||||
@@ -826,19 +847,50 @@ async function pollOnce() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startup() {
|
async function initializePolling() {
|
||||||
let settings = await getSettings();
|
let settings = await getSettings();
|
||||||
settings = await discoverServiceBases(settings);
|
try {
|
||||||
settings = await syncAuthToken(settings);
|
settings = await discoverServiceBases(settings);
|
||||||
|
} catch (error) {
|
||||||
|
// Service discovery persists diagnostic metadata in storage.sync. Chrome can
|
||||||
|
// temporarily reject those writes after its quota is reached; polling must
|
||||||
|
// still start with the last known settings.
|
||||||
|
console.warn("zncm service discovery failed during startup", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
settings = await syncAuthToken(settings);
|
||||||
|
} catch (error) {
|
||||||
|
// A failed status/token write must not prevent alarms from being installed.
|
||||||
|
// pollOnce() will retry token synchronization before claiming each task.
|
||||||
|
console.warn("zncm token sync failed during startup", error);
|
||||||
|
}
|
||||||
await refreshAlarm(settings);
|
await refreshAlarm(settings);
|
||||||
await scheduleNextPoll(1000);
|
await scheduleNextPoll(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.runtime.onInstalled.addListener(() => void startup());
|
function startup() {
|
||||||
chrome.runtime.onStartup.addListener(() => void startup());
|
if (!startupPromise) {
|
||||||
|
startupPromise = initializePolling().finally(() => {
|
||||||
|
startupPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return startupPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartPollingSchedule() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
await refreshAlarm(settings);
|
||||||
|
await scheduleNextPoll(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
|
||||||
|
chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
|
||||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||||
if (areaName === "sync") {
|
if (
|
||||||
void startup();
|
areaName === "sync" &&
|
||||||
|
Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key))
|
||||||
|
) {
|
||||||
|
void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
@@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
void startup();
|
void startup().catch((error) => console.warn("zncm startup failed", error));
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const test = require("node:test");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
|
||||||
|
const BACKGROUND_PATH = path.join(__dirname, "background.js");
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
let storageChangedListener;
|
||||||
|
let alarmCreateCount = 0;
|
||||||
|
const settings = {
|
||||||
|
apiBaseUrl: "http://localhost:8001",
|
||||||
|
webBaseUrl: "http://localhost:5174",
|
||||||
|
autoDiscoverServiceUrl: false,
|
||||||
|
accessToken: "",
|
||||||
|
tokenSyncEnabled: false,
|
||||||
|
taskPollingEnabled: true,
|
||||||
|
workerId: "test-worker",
|
||||||
|
taskPollIntervalSeconds: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chrome = {
|
||||||
|
alarms: {
|
||||||
|
clear: async () => {},
|
||||||
|
create: async () => {
|
||||||
|
alarmCreateCount += 1;
|
||||||
|
},
|
||||||
|
onAlarm: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
cookies: { get: async () => null },
|
||||||
|
runtime: {
|
||||||
|
onInstalled: { addListener: () => {} },
|
||||||
|
onMessage: { addListener: () => {} },
|
||||||
|
onStartup: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: async (defaults) => ({ ...defaults }),
|
||||||
|
set: async () => {},
|
||||||
|
},
|
||||||
|
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
|
||||||
|
sync: {
|
||||||
|
get: async () => ({ ...settings }),
|
||||||
|
set: async (updates) => Object.assign(settings, updates),
|
||||||
|
},
|
||||||
|
onChanged: {
|
||||||
|
addListener: (listener) => {
|
||||||
|
storageChangedListener = listener;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tabs: { query: async () => [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
chrome,
|
||||||
|
console,
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error("offline test");
|
||||||
|
},
|
||||||
|
importScripts: () => {},
|
||||||
|
setTimeout: () => 1,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
URL,
|
||||||
|
DEER_CLIPPER_DEFAULTS: settings,
|
||||||
|
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
|
||||||
|
deerMakeWorkerId: () => "test-worker",
|
||||||
|
deerAuthHeaders: () => ({}),
|
||||||
|
deerExtractAuthTokenFromPage: () => ({ token: "" }),
|
||||||
|
};
|
||||||
|
context.globalThis = context;
|
||||||
|
vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, {
|
||||||
|
filename: BACKGROUND_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emitStorageChange(changes) {
|
||||||
|
storageChangedListener(changes, "sync");
|
||||||
|
},
|
||||||
|
getAlarmCreateCount: () => alarmCreateCount,
|
||||||
|
resetAlarmCreateCount: () => {
|
||||||
|
alarmCreateCount = 0;
|
||||||
|
},
|
||||||
|
getApiCandidates: (webBase, currentApiBase) =>
|
||||||
|
vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
test("self-written sync metadata does not restart browser task polling", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
await settle();
|
||||||
|
harness.resetAlarmCreateCount();
|
||||||
|
|
||||||
|
harness.emitStorageChange({
|
||||||
|
lastTokenSyncAt: { oldValue: "before", newValue: "after" },
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(harness.getAlarmCreateCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("polling configuration changes restart browser task polling", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
await settle();
|
||||||
|
harness.resetAlarmCreateCount();
|
||||||
|
|
||||||
|
harness.emitStorageChange({
|
||||||
|
taskPollingEnabled: { oldValue: false, newValue: true },
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(harness.getAlarmCreateCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service discovery prefers the API paired with the open zncm tab", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")),
|
||||||
|
[
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://localhost:8001",
|
||||||
|
"http://47.94.209.59:2026",
|
||||||
|
"http://localhost:8001",
|
||||||
|
].filter((value, index, values) => values.indexOf(value) === index),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
const HOST_ID = "zncm-page-assistant-host";
|
const HOST_ID = "zncm-page-assistant-host";
|
||||||
const state = {
|
const state = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
taskPollingEnabled: true,
|
||||||
|
webBaseUrl: "",
|
||||||
open: false,
|
open: false,
|
||||||
busy: false,
|
busy: false,
|
||||||
threadId: "",
|
threadId: "",
|
||||||
@@ -709,20 +711,58 @@
|
|||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||||
state.enabled = Boolean(settings.pageAssistantEnabled);
|
state.enabled = Boolean(settings.pageAssistantEnabled);
|
||||||
|
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
|
||||||
|
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||||
ensureWidget();
|
ensureWidget();
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||||
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
|
if (areaName !== "sync") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
if (changes.pageAssistantEnabled) {
|
||||||
ensureWidget();
|
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||||
if (state.enabled) {
|
ensureWidget();
|
||||||
setStatus("页面问答框已开启。");
|
if (state.enabled) {
|
||||||
|
setStatus("页面问答框已开启。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.taskPollingEnabled) {
|
||||||
|
state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue);
|
||||||
|
}
|
||||||
|
if (changes.webBaseUrl) {
|
||||||
|
state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isZncmPage() {
|
||||||
|
const appPath = `${location.pathname}${location.hash}`.toLowerCase();
|
||||||
|
if (
|
||||||
|
appPath.includes("/page/workspace") ||
|
||||||
|
appPath.includes("/chats/") ||
|
||||||
|
appPath.includes("#/login")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const configured = new URL(state.webBaseUrl);
|
||||||
|
return configured.origin === location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MV3 service workers may be suspended between their short polling timers,
|
||||||
|
// while chrome.alarms cannot reliably run more often than every 30 seconds.
|
||||||
|
// A visible zncm tab provides a cheap wake-up signal so browser_act requests
|
||||||
|
// are normally claimed within the backend/model timeout window.
|
||||||
|
setInterval(() => {
|
||||||
|
if (!state.taskPollingEnabled || !isZncmPage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!state.enabled || !ui.pageTitle) {
|
if (!state.enabled || !ui.pageTitle) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in new issue
Block a user