958 lines
30 KiB
JavaScript
958 lines
30 KiB
JavaScript
importScripts("config.js", "shared.js");
|
||
|
||
const ALARM_NAME = "deer-browser-task-poll";
|
||
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));
|
||
}
|
||
|
||
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;
|
||
}
|
||
if (!settings.webBaseUrl || settings.webBaseUrl === LEGACY_LOCAL_WEB_BASE_URL) {
|
||
updates.webBaseUrl = DEER_CLIPPER_DEFAULTS.webBaseUrl;
|
||
}
|
||
if (!settings.workerId) {
|
||
updates.workerId = deerMakeWorkerId();
|
||
}
|
||
if (Object.keys(updates).length > 0) {
|
||
await chrome.storage.sync.set(updates);
|
||
Object.assign(settings, updates);
|
||
}
|
||
return settings;
|
||
}
|
||
|
||
function pollIntervalMs(settings) {
|
||
const seconds = Number(settings.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds);
|
||
return Math.max(3, seconds) * 1000;
|
||
}
|
||
|
||
function originMatchPattern(value) {
|
||
try {
|
||
const url = new URL(deerNormalizeBaseUrl(value));
|
||
return `${url.protocol}//${url.host}/*`;
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function normalizeServiceBase(value) {
|
||
return deerNormalizeBaseUrl(value || "");
|
||
}
|
||
|
||
function serviceBaseFromTabUrl(value) {
|
||
try {
|
||
const url = new URL(value);
|
||
const path = url.pathname.replace(/\/+$/, "");
|
||
if (path === "/deerflow" || path.startsWith("/deerflow/")) {
|
||
return `${url.origin}/deerflow`;
|
||
}
|
||
return url.origin;
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||
const candidates = [];
|
||
const add = (value) => {
|
||
const normalized = normalizeServiceBase(value);
|
||
if (normalized && !candidates.includes(normalized)) {
|
||
candidates.push(normalized);
|
||
}
|
||
};
|
||
|
||
add(webBase);
|
||
|
||
try {
|
||
const url = new URL(normalizeServiceBase(webBase));
|
||
if (["5173", "5174", "3000"].includes(url.port)) {
|
||
const apiUrl = new URL(url.href);
|
||
apiUrl.port = "8001";
|
||
add(apiUrl.href);
|
||
}
|
||
} catch {
|
||
// Ignore invalid candidates.
|
||
}
|
||
|
||
add(currentApiBase);
|
||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
add(LEGACY_LOCAL_API_BASE_URL);
|
||
|
||
return candidates;
|
||
}
|
||
|
||
async function browserContextStatus(apiBaseUrl) {
|
||
try {
|
||
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;
|
||
}
|
||
const data = await response.json().catch(() => null);
|
||
if (data && typeof data.enabled === "boolean") {
|
||
return data;
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function tabLooksLikeZncm(tab) {
|
||
const value = `${tab.url || ""} ${tab.title || ""}`.toLowerCase();
|
||
return (
|
||
value.includes("deerflow") ||
|
||
value.includes("deer") ||
|
||
value.includes("zncm") ||
|
||
value.includes("/page/workspace") ||
|
||
value.includes("/chats/") ||
|
||
value.includes("#/login")
|
||
);
|
||
}
|
||
|
||
async function findOpenZncmTabs(settings) {
|
||
const tabs = [];
|
||
const seen = new Set();
|
||
const addTabs = (items) => {
|
||
for (const tab of items || []) {
|
||
if (tab?.id && !seen.has(tab.id)) {
|
||
seen.add(tab.id);
|
||
tabs.push(tab);
|
||
}
|
||
}
|
||
};
|
||
|
||
for (const pattern of [
|
||
originMatchPattern(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl),
|
||
originMatchPattern(DEER_CLIPPER_DEFAULTS.webBaseUrl),
|
||
]) {
|
||
if (!pattern) {
|
||
continue;
|
||
}
|
||
try {
|
||
addTabs(await chrome.tabs.query({ url: pattern }));
|
||
} catch {
|
||
// Continue with broader discovery below.
|
||
}
|
||
}
|
||
|
||
try {
|
||
const allTabs = await chrome.tabs.query({ url: ["http://*/*", "https://*/*"] });
|
||
addTabs(allTabs.filter(tabLooksLikeZncm));
|
||
} catch {
|
||
// Ignore tab discovery failures.
|
||
}
|
||
|
||
return tabs;
|
||
}
|
||
|
||
async function discoverServiceBases(settings, force = false) {
|
||
if (!settings.autoDiscoverServiceUrl && !force) {
|
||
return settings;
|
||
}
|
||
|
||
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||
let discoveredWebBaseUrl = webBaseUrl;
|
||
let source = "configured";
|
||
const tabs = await findOpenZncmTabs(settings);
|
||
if (tabs.length > 0) {
|
||
const tabBase = serviceBaseFromTabUrl(tabs[0].url);
|
||
if (tabBase) {
|
||
discoveredWebBaseUrl = tabBase;
|
||
source = `tab:${tabs[0].url || tabBase}`;
|
||
}
|
||
}
|
||
|
||
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
let verified = false;
|
||
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
|
||
const status = await browserContextStatus(candidate);
|
||
if (status) {
|
||
apiBaseUrl = candidate;
|
||
webBaseUrl = discoveredWebBaseUrl;
|
||
verified = true;
|
||
source = status.enabled ? `${source}:browser_context_enabled` : `${source}:browser_context_disabled`;
|
||
break;
|
||
}
|
||
}
|
||
if (!verified && force) {
|
||
source = `${source}:api_not_verified`;
|
||
}
|
||
|
||
const next = {
|
||
...settings,
|
||
apiBaseUrl,
|
||
webBaseUrl,
|
||
lastServiceSyncAt: new Date().toISOString(),
|
||
lastServiceSyncSource: source,
|
||
};
|
||
|
||
if (
|
||
force ||
|
||
settings.apiBaseUrl !== next.apiBaseUrl ||
|
||
settings.webBaseUrl !== next.webBaseUrl ||
|
||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||
) {
|
||
// 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,
|
||
});
|
||
}
|
||
return next;
|
||
}
|
||
|
||
async function getCookieToken(settings) {
|
||
if (!chrome.cookies?.get) {
|
||
return null;
|
||
}
|
||
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
try {
|
||
const cookie = await chrome.cookies.get({ url: apiBaseUrl, name: "access_token" });
|
||
if (cookie?.value) {
|
||
return { token: cookie.value, source: `cookie:${new URL(apiBaseUrl).host}:access_token` };
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to read zncm access_token cookie", error);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function getTokenFromOpenZncmTab(settings) {
|
||
const tabs = await findOpenZncmTabs(settings);
|
||
let sawZncmTab = false;
|
||
|
||
for (const tab of tabs) {
|
||
if (!tab.id) {
|
||
continue;
|
||
}
|
||
sawZncmTab = true;
|
||
try {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId: tab.id },
|
||
func: deerExtractAuthTokenFromPage,
|
||
});
|
||
const tokenInfo = result?.result;
|
||
if (tokenInfo?.token) {
|
||
const tabBase = serviceBaseFromTabUrl(tab.url);
|
||
if (tabBase && settings.autoDiscoverServiceUrl && settings.webBaseUrl !== tabBase) {
|
||
await chrome.storage.sync.set({
|
||
webBaseUrl: tabBase,
|
||
lastServiceSyncAt: new Date().toISOString(),
|
||
lastServiceSyncSource: `token-tab:${tab.url || tabBase}`,
|
||
});
|
||
}
|
||
return {
|
||
token: tokenInfo.token,
|
||
source: tokenInfo.source || `tab:${tab.url || "open-deer-tab"}`,
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to read zncm token from tab", tab.id, error);
|
||
}
|
||
}
|
||
|
||
if (sawZncmTab) {
|
||
return { token: "", source: "open-deer-tab", clearStale: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function syncAuthToken(settings = null, forceStatus = false) {
|
||
let current = settings || (await getSettings());
|
||
current = await discoverServiceBases(current);
|
||
if (!current.tokenSyncEnabled && !forceStatus) {
|
||
return current;
|
||
}
|
||
|
||
const tokenInfo = (await getTokenFromOpenZncmTab(current)) || (await getCookieToken(current));
|
||
if (!tokenInfo?.token) {
|
||
if (tokenInfo?.clearStale && current.accessToken) {
|
||
const next = {
|
||
...current,
|
||
accessToken: "",
|
||
lastTokenSyncSource: "cleared:open-deer-tab-no-token",
|
||
lastTokenSyncAt: new Date().toISOString(),
|
||
};
|
||
await chrome.storage.sync.set({
|
||
accessToken: "",
|
||
lastTokenSyncSource: next.lastTokenSyncSource,
|
||
lastTokenSyncAt: next.lastTokenSyncAt,
|
||
});
|
||
return next;
|
||
}
|
||
return current;
|
||
}
|
||
|
||
const next = {
|
||
...current,
|
||
accessToken: tokenInfo.token,
|
||
lastTokenSyncSource: tokenInfo.source,
|
||
lastTokenSyncAt: new Date().toISOString(),
|
||
};
|
||
if (
|
||
forceStatus ||
|
||
current.accessToken !== next.accessToken ||
|
||
current.lastTokenSyncSource !== next.lastTokenSyncSource
|
||
) {
|
||
await chrome.storage.sync.set({
|
||
accessToken: next.accessToken,
|
||
lastTokenSyncSource: next.lastTokenSyncSource,
|
||
lastTokenSyncAt: next.lastTokenSyncAt,
|
||
});
|
||
}
|
||
return next;
|
||
}
|
||
|
||
async function refreshAlarm(settings) {
|
||
await chrome.alarms.clear(ALARM_NAME);
|
||
if (!settings.taskPollingEnabled) {
|
||
return;
|
||
}
|
||
const minutes = Math.max(0.5, pollIntervalMs(settings) / 60000);
|
||
await chrome.alarms.create(ALARM_NAME, { delayInMinutes: minutes, periodInMinutes: minutes });
|
||
}
|
||
|
||
async function scheduleNextPoll(delayMs = null) {
|
||
if (pollTimer) {
|
||
clearTimeout(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
const settings = await getSettings();
|
||
if (!settings.taskPollingEnabled) {
|
||
return;
|
||
}
|
||
const ms = delayMs ?? pollIntervalMs(settings);
|
||
pollTimer = setTimeout(() => {
|
||
void pollOnce().catch((error) => console.warn("zncm task poll failed", error));
|
||
}, ms);
|
||
}
|
||
|
||
async function waitForTabLoad(tabId, timeoutSeconds) {
|
||
const timeoutMs = Math.max(5, Number(timeoutSeconds || 30)) * 1000;
|
||
try {
|
||
const tab = await chrome.tabs.get(tabId);
|
||
if (tab.status === "complete") {
|
||
return true;
|
||
}
|
||
} catch {
|
||
return false;
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const timer = setTimeout(() => {
|
||
chrome.tabs.onUpdated.removeListener(listener);
|
||
resolve(false);
|
||
}, timeoutMs);
|
||
|
||
function listener(updatedTabId, changeInfo) {
|
||
if (updatedTabId === tabId && changeInfo.status === "complete") {
|
||
clearTimeout(timer);
|
||
chrome.tabs.onUpdated.removeListener(listener);
|
||
resolve(true);
|
||
}
|
||
}
|
||
|
||
chrome.tabs.onUpdated.addListener(listener);
|
||
});
|
||
}
|
||
|
||
async function captureFromTab(tabId, maxContentChars) {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: deerExtractCurrentPage,
|
||
});
|
||
const page = result?.result;
|
||
if (!page?.content) {
|
||
throw new Error("当前页面没有可读取的文本。");
|
||
}
|
||
const limit = Math.max(1000, Number(maxContentChars || 200000));
|
||
if (page.content.length > limit) {
|
||
page.content = `${page.content.slice(0, limit)}\n\n[Truncated by zncm browser task limit]`;
|
||
}
|
||
return page;
|
||
}
|
||
|
||
async function postTaskResult(taskId, settings, payload) {
|
||
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
const response = await fetch(`${apiBaseUrl}/api/browser-context/tasks/${encodeURIComponent(taskId)}/result`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...deerAuthHeaders(settings),
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
|
||
throw new Error(detail || `HTTP ${response.status}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function zncmApiBaseCandidates(settings) {
|
||
const candidates = [];
|
||
const add = (value) => {
|
||
const normalized = deerNormalizeBaseUrl(value || "");
|
||
if (normalized && !candidates.includes(normalized)) {
|
||
candidates.push(normalized);
|
||
}
|
||
};
|
||
|
||
add(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
try {
|
||
const url = new URL(deerNormalizeBaseUrl(settings.apiBaseUrl || ""));
|
||
if (url.pathname && url.pathname !== "/") {
|
||
add(url.origin);
|
||
}
|
||
} catch {
|
||
// Ignore invalid configured API URL.
|
||
}
|
||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
return candidates;
|
||
}
|
||
|
||
async function zncmFetchJson(settings, path, options) {
|
||
let lastError = null;
|
||
for (const apiBaseUrl of zncmApiBaseCandidates(settings)) {
|
||
try {
|
||
const response = await fetch(`${apiBaseUrl}${path}`, options);
|
||
const contentType = response.headers.get("content-type") || "";
|
||
const data = contentType.includes("application/json")
|
||
? await response.json().catch(() => ({}))
|
||
: { detail: await response.text().catch(() => "") };
|
||
if (response.ok) {
|
||
return { data, apiBaseUrl, status: response.status };
|
||
}
|
||
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
|
||
lastError = new Error(detail || `HTTP ${response.status}`);
|
||
lastError.status = response.status;
|
||
lastError.detail = detail;
|
||
lastError.apiBaseUrl = apiBaseUrl;
|
||
if (response.status !== 404) {
|
||
throw lastError;
|
||
}
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (error?.status && error.status !== 404) {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
throw lastError || new Error("zncm 服务请求失败。");
|
||
}
|
||
|
||
function zncmMakeThreadId() {
|
||
if (globalThis.crypto?.randomUUID) {
|
||
return globalThis.crypto.randomUUID();
|
||
}
|
||
return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
function zncmBuildPagePrompt(page, content, truncated, followUp) {
|
||
const title = page.title || "未知标题";
|
||
const url = page.url || "未知 URL";
|
||
const capturedAt = new Date().toISOString();
|
||
if (followUp) {
|
||
return [
|
||
"你是 zncm 页面问答助手。请继续基于本会话里已经读取的网页内容回答用户问题。",
|
||
"如果已有网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
|
||
`当前页面标题:${title}`,
|
||
`当前页面 URL:${url}`,
|
||
`本轮提问时间:${capturedAt}`,
|
||
].join("\n\n");
|
||
}
|
||
|
||
return [
|
||
"你是 zncm 页面问答助手。请直接基于当前网页内容回答用户问题。",
|
||
"如果网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
|
||
`页面标题:${title}`,
|
||
`页面 URL:${url}`,
|
||
`读取时间:${capturedAt}`,
|
||
truncated ? "说明:页面内容较长,本次已按插件限制截断。" : "说明:页面内容如下。",
|
||
"当前网页内容:",
|
||
page.contentType === "text" ? `\`\`\`text\n${content}\n\`\`\`` : content,
|
||
].join("\n\n");
|
||
}
|
||
|
||
function zncmExtractLatestAnswer(values) {
|
||
const messages = Array.isArray(values?.messages) ? values.messages : [];
|
||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||
const message = messages[index] || {};
|
||
const type = String(message.type || message.role || "").toLowerCase();
|
||
if (type !== "ai" && type !== "assistant") {
|
||
continue;
|
||
}
|
||
const content = message.content;
|
||
if (typeof content === "string" && content.trim()) {
|
||
return content.trim();
|
||
}
|
||
if (Array.isArray(content)) {
|
||
const text = content
|
||
.map((part) => {
|
||
if (typeof part === "string") return part;
|
||
if (part && typeof part.text === "string") return part.text;
|
||
return "";
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n")
|
||
.trim();
|
||
if (text) {
|
||
return text;
|
||
}
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function askZncmViaRunsWait(settings, payload, page, question) {
|
||
const contentLimit = 60000;
|
||
const rawContent = String(page.content || "");
|
||
const truncated = rawContent.length > contentLimit;
|
||
const content = truncated ? `${rawContent.slice(0, contentLimit)}\n\n[Truncated locally by zncm Web Clipper]` : rawContent;
|
||
const threadId = payload.threadId || zncmMakeThreadId();
|
||
const promptPrefix = zncmBuildPagePrompt(page, content, truncated, Boolean(payload.threadId));
|
||
const { data } = await zncmFetchJson(settings, "/api/runs/wait", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...deerAuthHeaders(settings),
|
||
},
|
||
body: JSON.stringify({
|
||
assistant_id: "lead_agent",
|
||
input: {
|
||
messages: [
|
||
{
|
||
type: "human",
|
||
content: question,
|
||
additional_kwargs: {
|
||
prompt_prefix: promptPrefix,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
config: {
|
||
configurable: {
|
||
thread_id: threadId,
|
||
},
|
||
},
|
||
metadata: {
|
||
source: "zncm_page_assistant",
|
||
browser_context_url: page.url || "",
|
||
browser_context_title: page.title || "",
|
||
browser_context_fallback: true,
|
||
},
|
||
stream_mode: ["values"],
|
||
on_disconnect: "continue",
|
||
multitask_strategy: "reject",
|
||
}),
|
||
});
|
||
return {
|
||
success: true,
|
||
thread_id: threadId,
|
||
run_id: "",
|
||
answer: zncmExtractLatestAnswer(data) || "zncm 没有返回可显示的答案。",
|
||
truncated,
|
||
message: "zncm 页面问答已完成。",
|
||
};
|
||
}
|
||
|
||
async function askZncmAboutPage(payload) {
|
||
let settings = await getSettings();
|
||
settings = await discoverServiceBases(settings);
|
||
settings = await syncAuthToken(settings);
|
||
if (!settings.pageAssistantEnabled) {
|
||
throw new Error("页面问答框已关闭。");
|
||
}
|
||
|
||
const page = payload?.page || {};
|
||
const question = String(payload?.question || "").trim();
|
||
if (!question) {
|
||
throw new Error("请输入要询问的问题。");
|
||
}
|
||
if (!page.content) {
|
||
throw new Error("当前页面没有可读取的正文。");
|
||
}
|
||
|
||
try {
|
||
const { data } = await zncmFetchJson(settings, "/api/browser-context/ask", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...deerAuthHeaders(settings),
|
||
},
|
||
body: JSON.stringify({
|
||
thread_id: payload.threadId || null,
|
||
title: page.title || "",
|
||
url: page.url || "",
|
||
content: page.content || "",
|
||
content_type: page.contentType || "markdown",
|
||
question,
|
||
}),
|
||
});
|
||
return data;
|
||
} catch (error) {
|
||
const detail = String(error?.detail || error?.message || "");
|
||
if (error?.status === 404 && !/browser context integration is disabled/i.test(detail)) {
|
||
return askZncmViaRunsWait(settings, payload, page, question);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function getActSessionMap() {
|
||
try {
|
||
const stored = await chrome.storage.session.get({ actSessions: {} });
|
||
return stored.actSessions && typeof stored.actSessions === "object" ? stored.actSessions : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
async function setActSessionTab(sessionId, tabId) {
|
||
if (!sessionId) return;
|
||
const map = await getActSessionMap();
|
||
map[sessionId] = tabId;
|
||
await chrome.storage.session.set({ actSessions: map }).catch(() => {});
|
||
}
|
||
|
||
async function removeActSession(sessionId) {
|
||
if (!sessionId) return;
|
||
const map = await getActSessionMap();
|
||
if (sessionId in map) {
|
||
delete map[sessionId];
|
||
await chrome.storage.session.set({ actSessions: map }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function resolveSessionTabId(sessionId) {
|
||
const map = await getActSessionMap();
|
||
const tabId = map[sessionId];
|
||
if (!tabId) return null;
|
||
try {
|
||
await chrome.tabs.get(tabId);
|
||
return tabId;
|
||
} catch {
|
||
await removeActSession(sessionId);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function performActionInTab(tabId, action) {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: deerPerformAction,
|
||
args: [action],
|
||
});
|
||
return result?.result || { status: "ok", message: "" };
|
||
}
|
||
|
||
async function observeTab(tabId, maxElements) {
|
||
const [result] = await chrome.scripting.executeScript({
|
||
target: { tabId },
|
||
func: deerObservePage,
|
||
args: [maxElements],
|
||
});
|
||
const observation = result?.result;
|
||
if (!observation) {
|
||
throw new Error("当前页面无法读取交互元素。");
|
||
}
|
||
return observation;
|
||
}
|
||
|
||
async function runActTask(task, settings) {
|
||
const sessionId = task.session_id || "";
|
||
const action = task.action || {};
|
||
const actionType = String(action.type || "").toLowerCase();
|
||
const maxElements = 150;
|
||
|
||
try {
|
||
if (!sessionId) {
|
||
throw new Error("act 任务缺少 session_id。");
|
||
}
|
||
|
||
if (actionType === "close") {
|
||
const tabId = await resolveSessionTabId(sessionId);
|
||
if (tabId) {
|
||
await chrome.tabs.remove(tabId).catch(() => {});
|
||
}
|
||
await removeActSession(sessionId);
|
||
await postTaskResult(task.id, settings, {
|
||
kind: "act",
|
||
observation: { action_status: "ok", message: "Tab closed.", url: "", title: "", elements: [], text: "" },
|
||
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
|
||
});
|
||
return;
|
||
}
|
||
|
||
let tabId = await resolveSessionTabId(sessionId);
|
||
|
||
if (actionType === "navigate") {
|
||
const url = String(action.url || task.url || "");
|
||
if (!url) {
|
||
throw new Error("navigate 动作缺少 url。");
|
||
}
|
||
if (tabId) {
|
||
await chrome.tabs.update(tabId, { url });
|
||
} else {
|
||
const tab = await chrome.tabs.create({ url, active: false });
|
||
tabId = tab.id;
|
||
await setActSessionTab(sessionId, tabId);
|
||
}
|
||
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
|
||
await delay(800);
|
||
} else {
|
||
if (!tabId) {
|
||
throw new Error("会话标签页不存在,请先用 navigate 打开页面。");
|
||
}
|
||
const outcome = await performActionInTab(tabId, action);
|
||
// Give the page a moment to react / navigate, then settle.
|
||
await delay(400);
|
||
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
|
||
await delay(500);
|
||
if (outcome.status === "error") {
|
||
const observation = await observeTab(tabId, maxElements).catch(() => ({ elements: [], text: "", url: "", title: "" }));
|
||
await postTaskResult(task.id, settings, {
|
||
kind: "act",
|
||
observation: { ...observation, action_status: outcome.status, message: outcome.message },
|
||
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
const observation = await observeTab(tabId, maxElements);
|
||
await postTaskResult(task.id, settings, {
|
||
kind: "act",
|
||
title: observation.title || "",
|
||
url: observation.url || "",
|
||
observation: { ...observation, action_status: "ok", message: "" },
|
||
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
|
||
});
|
||
} catch (error) {
|
||
await postTaskResult(task.id, settings, {
|
||
kind: "act",
|
||
error: error instanceof Error ? error.message : String(error),
|
||
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
|
||
}).catch((submitError) => console.warn("Failed to submit zncm act task error", submitError));
|
||
}
|
||
}
|
||
|
||
async function runFetchTask(task, settings) {
|
||
let tabId = null;
|
||
try {
|
||
const tab = await chrome.tabs.create({ url: task.url, active: false });
|
||
tabId = tab.id;
|
||
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
|
||
await delay(750);
|
||
const page = await captureFromTab(tabId, task.max_content_chars);
|
||
await postTaskResult(task.id, settings, {
|
||
kind: "fetch",
|
||
title: page.title,
|
||
url: page.url,
|
||
content: page.content,
|
||
content_type: page.contentType || "markdown",
|
||
metadata: {
|
||
source: "zncm-web-clipper",
|
||
worker_id: settings.workerId,
|
||
instruction: task.instruction || "",
|
||
},
|
||
});
|
||
} catch (error) {
|
||
await postTaskResult(task.id, settings, {
|
||
kind: "fetch",
|
||
error: error instanceof Error ? error.message : String(error),
|
||
metadata: {
|
||
source: "zncm-web-clipper",
|
||
worker_id: settings.workerId,
|
||
},
|
||
}).catch((submitError) => console.warn("Failed to submit zncm task error", submitError));
|
||
} finally {
|
||
if (tabId && settings.closeTaskTabs) {
|
||
await chrome.tabs.remove(tabId).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function runTask(task, settings) {
|
||
if ((task.kind || "fetch") === "act") {
|
||
await runActTask(task, settings);
|
||
return;
|
||
}
|
||
await runFetchTask(task, settings);
|
||
}
|
||
|
||
async function pollOnce() {
|
||
if (isPolling) {
|
||
return { ok: true, message: "任务轮询正在运行。" };
|
||
}
|
||
|
||
isPolling = true;
|
||
try {
|
||
let settings = await getSettings();
|
||
settings = await discoverServiceBases(settings);
|
||
settings = await syncAuthToken(settings);
|
||
if (!settings.taskPollingEnabled) {
|
||
return { ok: false, message: "任务轮询已关闭。" };
|
||
}
|
||
|
||
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||
const url = `${apiBaseUrl}/api/browser-context/tasks/next?worker_id=${encodeURIComponent(settings.workerId)}`;
|
||
const response = await fetch(url, { headers: deerAuthHeaders(settings) });
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
|
||
throw new Error(detail || `HTTP ${response.status}`);
|
||
}
|
||
if (!data.task) {
|
||
return { ok: true, message: "暂无待处理的 zncm 浏览器任务。" };
|
||
}
|
||
|
||
await runTask(data.task, settings);
|
||
return { ok: true, message: `已完成 zncm 浏览器任务 ${data.task.id}。` };
|
||
} finally {
|
||
isPolling = false;
|
||
await scheduleNextPoll();
|
||
}
|
||
}
|
||
|
||
async function initializePolling() {
|
||
let settings = await getSettings();
|
||
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);
|
||
}
|
||
|
||
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" &&
|
||
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) => {
|
||
if (alarm.name === ALARM_NAME) {
|
||
void pollOnce().catch((error) => console.warn("zncm alarm poll failed", error));
|
||
}
|
||
});
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
if (message?.type === "zncm-discover-service") {
|
||
getSettings()
|
||
.then((settings) => discoverServiceBases(settings, true))
|
||
.then((settings) =>
|
||
sendResponse({
|
||
ok: Boolean(settings.apiBaseUrl && settings.webBaseUrl),
|
||
message: `服务地址:${settings.webBaseUrl};API:${settings.apiBaseUrl}`,
|
||
apiBaseUrl: settings.apiBaseUrl,
|
||
webBaseUrl: settings.webBaseUrl,
|
||
source: settings.lastServiceSyncSource || "",
|
||
}),
|
||
)
|
||
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
|
||
return true;
|
||
}
|
||
|
||
if (message?.type === "zncm-sync-token") {
|
||
syncAuthToken(null, true)
|
||
.then((settings) => {
|
||
const hasToken = Boolean(settings.accessToken);
|
||
sendResponse({
|
||
ok: hasToken,
|
||
message: hasToken
|
||
? `Token 已从 ${settings.lastTokenSyncSource || "zncm"} 同步。`
|
||
: "没有找到 zncm token。请打开已登录的 zncm 页面后重试。",
|
||
source: settings.lastTokenSyncSource || "",
|
||
});
|
||
})
|
||
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
|
||
return true;
|
||
}
|
||
|
||
if (message?.type === "zncm-page-agent-ask") {
|
||
askZncmAboutPage(message)
|
||
.then((result) => sendResponse({ ok: true, ...result }))
|
||
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
|
||
return true;
|
||
}
|
||
|
||
if (message?.type === "zncm-open-options") {
|
||
chrome.runtime.openOptionsPage()
|
||
.then(() => sendResponse({ ok: true }))
|
||
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
|
||
return true;
|
||
}
|
||
|
||
if (message?.type !== "zncm-poll-now") {
|
||
return false;
|
||
}
|
||
pollOnce()
|
||
.then((result) => sendResponse(result))
|
||
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
|
||
return true;
|
||
});
|
||
|
||
void startup().catch((error) => console.warn("zncm startup failed", error));
|