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";
|
||||
let pollTimer = null;
|
||||
let isPolling = false;
|
||||
let startupPromise = null;
|
||||
|
||||
const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]);
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -12,6 +15,14 @@ function delay(ms) {
|
||||
|
||||
async function getSettings() {
|
||||
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 = {};
|
||||
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
|
||||
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
|
||||
@@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
}
|
||||
};
|
||||
|
||||
add(currentApiBase);
|
||||
add(webBase);
|
||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
add(LEGACY_LOCAL_API_BASE_URL);
|
||||
|
||||
try {
|
||||
const url = new URL(normalizeServiceBase(webBase));
|
||||
@@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
// Ignore invalid candidates.
|
||||
}
|
||||
|
||||
add(currentApiBase);
|
||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
add(LEGACY_LOCAL_API_BASE_URL);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) {
|
||||
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) {
|
||||
settings.webBaseUrl !== next.webBaseUrl ||
|
||||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||||
) {
|
||||
await chrome.storage.sync.set({
|
||||
apiBaseUrl: next.apiBaseUrl,
|
||||
webBaseUrl: next.webBaseUrl,
|
||||
// Discovered endpoints are runtime state, not user-authored preferences.
|
||||
// Keep them in storage.local so Chrome sync write quotas cannot break task
|
||||
// polling. The options page may still persist explicit user choices.
|
||||
await chrome.storage.local.set({
|
||||
discoveredApiBaseUrl: next.apiBaseUrl,
|
||||
discoveredWebBaseUrl: next.webBaseUrl,
|
||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||
});
|
||||
@@ -826,19 +847,50 @@ async function pollOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startup() {
|
||||
async function initializePolling() {
|
||||
let settings = await getSettings();
|
||||
settings = await discoverServiceBases(settings);
|
||||
settings = await syncAuthToken(settings);
|
||||
try {
|
||||
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 scheduleNextPoll(1000);
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => void startup());
|
||||
chrome.runtime.onStartup.addListener(() => void startup());
|
||||
function 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) => {
|
||||
if (areaName === "sync") {
|
||||
void startup();
|
||||
if (
|
||||
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) => {
|
||||
@@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
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 state = {
|
||||
enabled: true,
|
||||
taskPollingEnabled: true,
|
||||
webBaseUrl: "",
|
||||
open: false,
|
||||
busy: false,
|
||||
threadId: "",
|
||||
@@ -709,20 +711,58 @@
|
||||
async function loadSettings() {
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
state.enabled = Boolean(settings.pageAssistantEnabled);
|
||||
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
|
||||
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||
ensureWidget();
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
|
||||
if (areaName !== "sync") {
|
||||
return;
|
||||
}
|
||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||
ensureWidget();
|
||||
if (state.enabled) {
|
||||
setStatus("页面问答框已开启。");
|
||||
if (changes.pageAssistantEnabled) {
|
||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||
ensureWidget();
|
||||
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(() => {
|
||||
if (!state.enabled || !ui.pageTitle) {
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user