diff --git a/.gitignore b/.gitignore
index 4aebc103..de6dbc5f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
# Workspace/editor
.vscode/
frontend-web/.vscode/
-
+node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -84,6 +84,9 @@ offline-backend-20260512/backend/tests/*
deploy
.history
+# Cloned third-party repos (local only)
+git-clone/
+
# Repo-root offline dependency pack (local only)
/dist/
diff --git a/browser-extension/deer-web-clipper/README.md b/browser-extension/deer-web-clipper/README.md
new file mode 100644
index 00000000..608c3303
--- /dev/null
+++ b/browser-extension/deer-web-clipper/README.md
@@ -0,0 +1,136 @@
+# zncm Web Clipper
+
+这是给 zncm 使用的 Chrome 插件,安装后默认连接 `config.js` 中配置好的服务地址。
+
+默认地址配置在 `browser-extension/deer-web-clipper/config.js`:
+
+```js
+globalThis.ZNCM_WEB_CLIPPER_CONFIG = Object.freeze({
+ defaultWebBaseUrl: "http://47.94.209.59:2026",
+ defaultApiBaseUrl: "http://47.94.209.59:2026",
+});
+```
+
+如果部署地址变化,只需要修改 `config.js` 里的这两个字段,然后重新加载插件。
+
+正常情况下不需要手动配置 URL,也不需要手动复制 token。
+
+## 能做什么
+
+1. 把当前浏览器标签页发送到 zncm,作为 Markdown 附件进入聊天。
+2. 接收 zncm 聊天中由 `browser_fetch_page` 工具创建的网页抓取任务。
+3. 插件在 Chrome 中打开目标网页,读取可见文本和表格,再把结果回传给 zncm。
+4. 在普通网页右下角显示“zncm 页面问答”浮窗,直接把当前页面内容交给 zncm 问答。
+5. 接收 zncm 的 `browser_act` 交互式操作任务:在 Chrome 中打开页面并按 zncm 的指令**操作页面**——点击、输入、滚动、等待、页内跳转,每一步把页面上的可交互元素清单和正文回传给 zncm,由 zncm 决定下一步(observe→act 循环)。
+
+## 页面操作(browser_act)
+
+除了只读抓取,zncm 还能驱动插件**实际操作网页**,完成登录、填表、翻页找数据等多步任务:
+
+1. zncm 调用 `browser_act` 工具,先 `navigate` 打开页面。
+2. 插件打开标签页(同一会话会复用这个标签页),扫描页面上的可交互元素,给每个元素编号后连同正文回传给 zncm。
+3. zncm 根据元素编号决定下一步(点第 3 个按钮、在第 1 个输入框填字……),再次调用 `browser_act`。
+4. 插件执行动作、重新扫描页面、回传新状态,如此循环,直到任务完成。
+5. 任务结束时 zncm 用 `action="close"` 关闭该会话标签页。
+
+支持的动作:`navigate`(打开/跳转 URL)、`click`(点击元素)、`type`(输入文字,可选回车提交)、`scroll`(滚动到元素或下翻一屏)、`wait`(等待某 CSS 选择器出现)、`extract`(重新读取正文)、`observe`(仅重新扫描)、`close`(关闭会话标签页)。
+
+> 提示:页面操作能力较强(可点击任意按钮、提交表单),可在后端 `config.yaml` 用 `browser_context.actions_enabled: false` 关闭,只保留只读抓取。
+
+## 后端启用
+
+在 `offline-backend-20260512/backend/config.yaml` 中保持:
+
+```yaml
+browser_context:
+ enabled: true # 只读抓取 + 页面问答
+ actions_enabled: true # 交互式页面操作 browser_act(不需要可设为 false)
+```
+
+修改配置后需要重启后端。
+
+## 安装插件
+
+1. 打开 Chrome 的 `chrome://extensions`。
+2. 开启右上角的“开发者模式”。
+3. 点击“加载已解压的扩展程序”。
+4. 选择目录:`browser-extension/deer-web-clipper`。
+5. 安装后插件名会显示为 `zncm Web Clipper`。
+
+## 安装即用
+
+插件默认已经开启:
+
+- 自动识别已打开 zncm 页面的服务地址。
+- 自动同步已登录 zncm 的 token。
+- 自动轮询 zncm 聊天触发的浏览器抓取任务。
+- 页面问答框默认开启。
+
+如果你打开的是 `config.js` 里配置的默认服务器,通常无需进入设置页。
+
+如果部署地址变了,保持一个已登录的 zncm 页面打开,然后在插件设置里点击:
+
+- `重新识别服务地址`
+- `同步 token`
+
+## 自动同步 Token
+
+插件会自动从两个位置获取登录 token:
+
+1. zncm API 域名下的 `access_token` Cookie,包括 HttpOnly Cookie。
+2. 已登录 zncm 前端页面里的浏览器本地登录态。
+
+切换登录账号后,插件后台每次轮询任务前都会重新同步 token;点击插件里的“同步 token”也可以立即刷新。
+
+如果当前浏览器已经登出 zncm,且打开的 zncm 页面里没有 token,插件会清空旧 token,避免继续用上一个账号请求。
+
+## 页面问答框
+
+安装插件后,普通 `http` / `https` 页面右下角会出现 `AI` 按钮。点击后可以在当前网页上直接提问,例如:
+
+```text
+总结这页的核心结论
+提取页面里的价格和时间
+这张表里哪个指标最高?
+```
+
+插件会读取当前页面可见文本和表格,发送到 zncm,并把 zncm 的回答显示回浮窗里。多轮追问会复用同一个 zncm 会话;如果页面 URL 变化,会自动重新开始当前页会话。
+
+是否显示问答框可以自己控制:
+
+- 插件弹窗里勾选或取消“启用页面问答框”。
+- 插件设置页里勾选或取消“启用页面问答框”。
+
+关闭问答框只会隐藏页面浮窗,不会影响“发送当前页”和 zncm 聊天中的 `browser_fetch_page` 抓取任务。
+
+## 手动发送当前网页
+
+点击插件图标,选择“发送当前页”。
+
+插件会读取当前页面的可见文本和表格,发送到 zncm,并打开一个新的 zncm 聊天页面。zncm 会把网页内容作为上传文件注入到首条问题里。
+
+## 在 zncm 聊天中触发浏览器抓取
+
+在 zncm 中输入带有明确 URL 的问题,例如:
+
+```text
+使用 browser_fetch_page 打开 https://example.com/report,提取页面里的关键表格数据并总结。
+```
+
+执行流程:
+
+1. zncm 调用 `browser_fetch_page` 工具创建浏览器任务。
+2. 插件后台轮询到任务。
+3. 插件在 Chrome 中打开目标网页。
+4. 插件提取页面文本和表格。
+5. 插件把结果回传给 zncm。
+6. zncm 基于回传内容回答你的问题。
+
+这个方式适合读取需要浏览器登录态、内网权限或当前 Chrome 环境才能访问的页面。
+
+## 注意事项
+
+- 目标 URL 必须是明确的 `http` 或 `https` 地址。
+- 插件不能读取 `chrome://` 等浏览器内部页面。
+- 如果目标网站阻止扩展脚本注入,抓取可能失败。
+- 后台任务默认会打开新标签页;是否抓取完成后关闭标签页,可在插件设置里配置。
diff --git a/browser-extension/deer-web-clipper/background.js b/browser-extension/deer-web-clipper/background.js
new file mode 100644
index 00000000..104380f7
--- /dev/null
+++ b/browser-extension/deer-web-clipper/background.js
@@ -0,0 +1,905 @@
+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;
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function getSettings() {
+ const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
+ 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(currentApiBase);
+ add(webBase);
+ add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
+ add(LEGACY_LOCAL_API_BASE_URL);
+
+ 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.
+ }
+
+ return candidates;
+}
+
+async function browserContextStatus(apiBaseUrl) {
+ try {
+ const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
+ credentials: "include",
+ });
+ 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
+ ) {
+ await chrome.storage.sync.set({
+ apiBaseUrl: next.apiBaseUrl,
+ webBaseUrl: 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 startup() {
+ let settings = await getSettings();
+ settings = await discoverServiceBases(settings);
+ settings = await syncAuthToken(settings);
+ await refreshAlarm(settings);
+ await scheduleNextPoll(1000);
+}
+
+chrome.runtime.onInstalled.addListener(() => void startup());
+chrome.runtime.onStartup.addListener(() => void startup());
+chrome.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName === "sync") {
+ void startup();
+ }
+});
+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();
diff --git a/browser-extension/deer-web-clipper/config.js b/browser-extension/deer-web-clipper/config.js
new file mode 100644
index 00000000..5ab04b8d
--- /dev/null
+++ b/browser-extension/deer-web-clipper/config.js
@@ -0,0 +1,9 @@
+// Default deployment settings for zncm Web Clipper.
+//
+// Change these two values before loading or packaging the extension when the
+// zncm service address changes. Users can still override them in the extension
+// settings page after installation.
+globalThis.ZNCM_WEB_CLIPPER_CONFIG = Object.freeze({
+ defaultWebBaseUrl: "http://47.94.209.59:2026",
+ defaultApiBaseUrl: "http://47.94.209.59:2026",
+});
diff --git a/browser-extension/deer-web-clipper/content.js b/browser-extension/deer-web-clipper/content.js
new file mode 100644
index 00000000..1c387461
--- /dev/null
+++ b/browser-extension/deer-web-clipper/content.js
@@ -0,0 +1,737 @@
+(() => {
+ if (window.top !== window || window.__zncmPageAssistantLoaded) {
+ return;
+ }
+ window.__zncmPageAssistantLoaded = true;
+
+ const HOST_ID = "zncm-page-assistant-host";
+ const state = {
+ enabled: true,
+ open: false,
+ busy: false,
+ threadId: "",
+ pageUrl: location.href,
+ messages: [],
+ };
+
+ let host = null;
+ let shadow = null;
+ let ui = {};
+
+ function sendRuntimeMessage(message) {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(message, (response) => {
+ const error = chrome.runtime.lastError;
+ if (error) {
+ reject(new Error(error.message));
+ return;
+ }
+ resolve(response || {});
+ });
+ });
+ }
+
+ function makeElement(tag, className = "", text = "") {
+ const element = document.createElement(tag);
+ if (className) {
+ element.className = className;
+ }
+ if (text) {
+ element.textContent = text;
+ }
+ return element;
+ }
+
+ function setStatus(message) {
+ if (ui.status) {
+ ui.status.textContent = message || "";
+ }
+ }
+
+ function setBusy(busy) {
+ state.busy = busy;
+ if (ui.send) ui.send.disabled = busy;
+ if (ui.input) ui.input.disabled = busy;
+ if (ui.panel) ui.panel.classList.toggle("is-busy", busy);
+ }
+
+ function resetThreadIfPageChanged() {
+ if (state.pageUrl === location.href) {
+ return;
+ }
+ state.pageUrl = location.href;
+ state.threadId = "";
+ state.messages = [];
+ renderMessages();
+ setStatus("页面已变化,下一次提问会重新读取当前页面。");
+ }
+
+ function destroyWidget() {
+ if (host) {
+ host.remove();
+ }
+ host = null;
+ shadow = null;
+ ui = {};
+ }
+
+ function renderMessages() {
+ if (!ui.messages) {
+ return;
+ }
+ ui.messages.replaceChildren();
+ if (state.messages.length === 0) {
+ const empty = makeElement("div", "empty");
+ const emptyTitle = makeElement("strong", "", "问问当前页面");
+ const emptyText = makeElement("span", "", "可以提问页面里的数据、表格、段落和结论。");
+ empty.append(emptyTitle, emptyText);
+ ui.messages.appendChild(empty);
+ return;
+ }
+
+ for (const message of state.messages) {
+ const item = makeElement("div", `message ${message.role}`);
+ const avatar = makeElement("div", "avatar", message.role === "user" ? "你" : "zn");
+ const bubble = makeElement("div", "bubble");
+ const label = makeElement("div", "message-label", message.role === "user" ? "你" : "zncm");
+ const body = makeElement("div", "message-body", message.text);
+ if (message.pending) {
+ body.classList.add("pending");
+ }
+ bubble.append(label, body);
+ item.append(avatar, bubble);
+ ui.messages.appendChild(item);
+ }
+ ui.messages.scrollTop = ui.messages.scrollHeight;
+ }
+
+ function setOpen(open) {
+ state.open = open;
+ if (!ui.panel || !ui.launcher) {
+ return;
+ }
+ ui.panel.classList.toggle("open", open);
+ ui.launcher.classList.toggle("open", open);
+ ui.launcher.setAttribute("aria-expanded", String(open));
+ if (open) {
+ resetThreadIfPageChanged();
+ setTimeout(() => ui.input?.focus(), 50);
+ }
+ }
+
+ async function ask(question) {
+ const normalized = String(question || "").trim();
+ if (!normalized || state.busy) {
+ return;
+ }
+
+ resetThreadIfPageChanged();
+ setBusy(true);
+ setStatus("正在读取页面并发送给 zncm...");
+ state.messages.push({ role: "user", text: normalized });
+ const pending = { role: "assistant", text: "zncm 正在理解当前页面...", pending: true };
+ state.messages.push(pending);
+ renderMessages();
+ ui.input.value = "";
+
+ try {
+ if (typeof deerExtractCurrentPage !== "function") {
+ throw new Error("页面读取脚本未加载,请刷新当前页面后重试。");
+ }
+ const page = deerExtractCurrentPage();
+ const response = await sendRuntimeMessage({
+ type: "zncm-page-agent-ask",
+ page,
+ question: normalized,
+ threadId: state.threadId || "",
+ });
+ if (!response.ok) {
+ throw new Error(response.message || "zncm 没有返回答案。");
+ }
+ state.threadId = response.thread_id || state.threadId;
+ pending.text = response.answer || "zncm 没有返回答案。";
+ pending.pending = false;
+ setStatus(response.truncated ? "已回答。页面内容较长,本次按配置做了截断。" : "已回答。");
+ } catch (error) {
+ pending.text = error instanceof Error ? error.message : "页面问答失败。";
+ pending.pending = false;
+ setStatus("本次问答失败。");
+ } finally {
+ setBusy(false);
+ renderMessages();
+ ui.input.focus();
+ }
+ }
+
+ function buildWidget() {
+ host = document.createElement("div");
+ host.id = HOST_ID;
+ document.documentElement.appendChild(host);
+ shadow = host.attachShadow({ mode: "open" });
+
+ const style = document.createElement("style");
+ style.textContent = `
+ :host {
+ all: initial;
+ color-scheme: light;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ }
+
+ .launcher,
+ .panel {
+ position: fixed;
+ z-index: 2147483647;
+ font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ }
+
+ .launcher {
+ right: 20px;
+ bottom: 20px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 8px;
+ height: 44px;
+ min-width: 112px;
+ border: 1px solid rgba(15, 118, 110, 0.28);
+ border-radius: 8px;
+ padding: 0 12px 0 10px;
+ background: #0f766e;
+ color: #fff;
+ box-shadow: 0 12px 34px rgba(15, 118, 110, 0.28), 0 2px 8px rgba(17, 24, 39, 0.12);
+ cursor: pointer;
+ font-weight: 750;
+ transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
+ }
+
+ .launcher:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 16px 40px rgba(15, 118, 110, 0.32), 0 4px 12px rgba(17, 24, 39, 0.14);
+ }
+
+ .launcher-mark {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.18);
+ font-size: 12px;
+ letter-spacing: 0;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
+ }
+
+ .launcher.open {
+ background: #115e59;
+ }
+
+ .panel {
+ right: 20px;
+ bottom: 72px;
+ width: min(430px, calc(100vw - 24px));
+ max-height: min(640px, calc(100vh - 96px));
+ display: none;
+ grid-template-rows: auto auto minmax(180px, 1fr) auto;
+ overflow: hidden;
+ border: 1px solid #d6d8dc;
+ border-radius: 8px;
+ background: #ffffff;
+ color: #171717;
+ box-shadow: 0 24px 70px rgba(17, 24, 39, 0.2), 0 1px 2px rgba(17, 24, 39, 0.08);
+ }
+
+ .panel.open {
+ display: grid;
+ animation: zncm-panel-in 160ms ease-out;
+ }
+
+ @keyframes zncm-panel-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.985);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ }
+
+ .header {
+ display: grid;
+ gap: 10px;
+ padding: 14px;
+ background: linear-gradient(180deg, #f4fffc 0%, #ffffff 84%);
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .header-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ .brand {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: 10px;
+ }
+
+ .brand-mark {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ flex: 0 0 auto;
+ border-radius: 8px;
+ background: #0f766e;
+ color: #fff;
+ font-weight: 800;
+ box-shadow: 0 8px 20px rgba(15, 118, 110, 0.18);
+ }
+
+ .brand-copy {
+ display: grid;
+ min-width: 0;
+ gap: 1px;
+ }
+
+ .brand-copy strong {
+ color: #111827;
+ font-size: 15px;
+ line-height: 1.25;
+ }
+
+ .brand-copy span {
+ overflow: hidden;
+ color: #6b7280;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .header-actions {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 6px;
+ }
+
+ .icon-button {
+ display: grid;
+ place-items: center;
+ width: auto;
+ min-width: 34px;
+ height: 30px;
+ padding: 0 8px;
+ border: 1px solid #dfe3e8;
+ border-radius: 7px;
+ background: #fff;
+ color: #374151;
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+ white-space: nowrap;
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
+ }
+
+ .icon-button:hover {
+ border-color: #0f766e;
+ color: #0f766e;
+ background: #f0fdfa;
+ }
+
+ .page-chip {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ border: 1px solid #dbeafe;
+ border-radius: 8px;
+ padding: 8px 10px;
+ background: #f8fbff;
+ color: #334155;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
+ }
+
+ .page-chip-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: #0f766e;
+ }
+
+ .page-chip-text {
+ overflow: hidden;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .status {
+ min-height: 20px;
+ padding: 9px 14px;
+ border-bottom: 1px solid #f0f2f5;
+ color: #475569;
+ background: #fbfcfd;
+ font-size: 12px;
+ }
+
+ .messages {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+ overflow: auto;
+ padding: 14px;
+ background: linear-gradient(180deg, #f7f8fa 0%, #f3f6f8 100%);
+ }
+
+ .empty {
+ display: grid;
+ gap: 5px;
+ padding: 16px;
+ border: 1px solid #dfe7ed;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #ffffff 0%, #fbfefd 100%);
+ color: #4b5563;
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
+ }
+
+ .empty strong {
+ color: #111827;
+ font-size: 14px;
+ }
+
+ .empty span {
+ font-size: 13px;
+ }
+
+ .message {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+ }
+
+ .message.user {
+ grid-template-columns: minmax(0, 1fr) 28px;
+ }
+
+ .message.user .avatar {
+ grid-column: 2;
+ }
+
+ .message.user .bubble {
+ grid-column: 1;
+ justify-self: end;
+ }
+
+ .avatar {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: #e6fffb;
+ color: #0f766e;
+ font-size: 11px;
+ font-weight: 800;
+ }
+
+ .message.user .avatar {
+ background: #111827;
+ color: #fff;
+ }
+
+ .bubble {
+ display: grid;
+ gap: 4px;
+ max-width: 100%;
+ }
+
+ .message-label {
+ color: #6b7280;
+ font-size: 11px;
+ }
+
+ .message.user .message-label {
+ text-align: right;
+ }
+
+ .message-body {
+ max-width: 330px;
+ padding: 10px 12px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #fff;
+ color: #171717;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ }
+
+ .message.user .message-body {
+ border-color: #0f172a;
+ background: #0f172a;
+ color: #fff;
+ }
+
+ .message-body.pending {
+ color: #6b7280;
+ }
+
+ .message-body.pending::after {
+ content: "";
+ display: inline-block;
+ width: 18px;
+ height: 4px;
+ margin-left: 6px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #0f766e 0 33%, transparent 33% 100%);
+ background-size: 9px 4px;
+ animation: zncm-thinking 900ms linear infinite;
+ vertical-align: middle;
+ }
+
+ @keyframes zncm-thinking {
+ from {
+ background-position: 0 0;
+ }
+ to {
+ background-position: 18px 0;
+ }
+ }
+
+ .composer-wrap {
+ display: grid;
+ gap: 10px;
+ padding: 12px 14px 14px;
+ background: #fff;
+ border-top: 1px solid #e5e7eb;
+ }
+
+ .quick {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+ }
+
+ .quick button {
+ border: 1px solid #d6d8dc;
+ border-radius: 8px;
+ padding: 8px 10px;
+ background: #fbfcfd;
+ color: #374151;
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+ text-align: left;
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
+ }
+
+ .quick button:hover {
+ border-color: #0f766e;
+ color: #0f766e;
+ background: #f0fdfa;
+ transform: translateY(-1px);
+ }
+
+ .composer {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ align-items: end;
+ }
+
+ .composer textarea {
+ min-height: 46px;
+ max-height: 130px;
+ resize: vertical;
+ border: 1px solid #cfd6dd;
+ border-radius: 8px;
+ padding: 10px 11px;
+ color: #171717;
+ background: #fbfcfd;
+ font: inherit;
+ outline: none;
+ }
+
+ .composer textarea:focus {
+ border-color: #0f766e;
+ box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
+ }
+
+ .composer button {
+ height: 42px;
+ border: 0;
+ border-radius: 8px;
+ padding: 0 14px;
+ background: #0f766e;
+ color: #fff;
+ cursor: pointer;
+ font: inherit;
+ font-weight: 750;
+ box-shadow: 0 8px 18px rgba(15, 118, 110, 0.2);
+ transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease;
+ }
+
+ .composer button:hover {
+ background: #115e59;
+ transform: translateY(-1px);
+ box-shadow: 0 10px 22px rgba(15, 118, 110, 0.24);
+ }
+
+ .panel.is-busy .brand-mark,
+ .panel.is-busy .launcher-mark {
+ opacity: 0.82;
+ }
+
+ .composer button:disabled,
+ .quick button:disabled,
+ .icon-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+
+ @media (max-width: 480px) {
+ .launcher {
+ right: 12px;
+ bottom: 12px;
+ min-width: 96px;
+ }
+
+ .panel {
+ right: 12px;
+ bottom: 64px;
+ width: calc(100vw - 24px);
+ max-height: calc(100vh - 80px);
+ }
+
+ .message-body {
+ max-width: calc(100vw - 104px);
+ }
+
+ .quick {
+ grid-template-columns: 1fr;
+ }
+ }
+ `;
+
+ const launcher = makeElement("button", "launcher");
+ launcher.type = "button";
+ launcher.title = "打开 zncm 页面问答";
+ launcher.setAttribute("aria-expanded", "false");
+ launcher.append(makeElement("span", "launcher-mark", "zn"), makeElement("span", "", "页面问答"));
+
+ const panel = makeElement("section", "panel");
+ const header = makeElement("header", "header");
+ const headerTop = makeElement("div", "header-top");
+ const brand = makeElement("div", "brand");
+ const brandCopy = makeElement("div", "brand-copy");
+ const title = makeElement("strong", "", "zncm 页面问答");
+ const subtitle = makeElement("span", "", "读取当前页面并回答");
+ brandCopy.append(title, subtitle);
+ brand.append(makeElement("div", "brand-mark", "zn"), brandCopy);
+
+ const actions = makeElement("div", "header-actions");
+ const settings = makeElement("button", "icon-button", "设置");
+ settings.type = "button";
+ settings.title = "打开插件设置";
+ const minimize = makeElement("button", "icon-button", "-");
+ minimize.type = "button";
+ minimize.title = "收起";
+ actions.append(settings, minimize);
+ headerTop.append(brand, actions);
+
+ const pageChip = makeElement("div", "page-chip");
+ const pageTitle = makeElement("span", "page-chip-text", document.title || location.hostname);
+ pageChip.append(makeElement("span", "page-chip-dot"), pageTitle);
+ header.append(headerTop, pageChip);
+
+ const status = makeElement("div", "status", "已连接当前页面。");
+ const messages = makeElement("div", "messages");
+
+ const composerWrap = makeElement("div", "composer-wrap");
+ const quick = makeElement("div", "quick");
+ for (const text of ["总结此页", "提取关键数据", "列出待办", "解释表格"]) {
+ const chip = makeElement("button", "", text);
+ chip.type = "button";
+ chip.addEventListener("click", () => ask(text));
+ quick.appendChild(chip);
+ }
+
+ const composer = makeElement("form", "composer");
+ const input = makeElement("textarea");
+ input.placeholder = "问当前页面...";
+ input.rows = 2;
+ const send = makeElement("button", "", "发送");
+ send.type = "submit";
+ composer.append(input, send);
+ composerWrap.append(quick, composer);
+
+ panel.append(header, status, messages, composerWrap);
+ shadow.append(style, panel, launcher);
+
+ ui = { launcher, panel, subtitle, pageTitle, status, messages, input, send };
+ renderMessages();
+
+ launcher.addEventListener("click", () => setOpen(!state.open));
+ minimize.addEventListener("click", () => setOpen(false));
+ settings.addEventListener("click", () => {
+ sendRuntimeMessage({ type: "zncm-open-options" }).catch((error) => {
+ setStatus(error instanceof Error ? error.message : "无法打开插件设置。");
+ });
+ });
+ composer.addEventListener("submit", (event) => {
+ event.preventDefault();
+ ask(input.value);
+ });
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ ask(input.value);
+ }
+ });
+ }
+
+ function ensureWidget() {
+ if (!state.enabled) {
+ destroyWidget();
+ return;
+ }
+ if (document.getElementById(HOST_ID)) {
+ return;
+ }
+ buildWidget();
+ }
+
+ async function loadSettings() {
+ const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
+ state.enabled = Boolean(settings.pageAssistantEnabled);
+ ensureWidget();
+ }
+
+ chrome.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== "sync" || !changes.pageAssistantEnabled) {
+ return;
+ }
+ state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
+ ensureWidget();
+ if (state.enabled) {
+ setStatus("页面问答框已开启。");
+ }
+ });
+
+ setInterval(() => {
+ if (!state.enabled || !ui.pageTitle) {
+ return;
+ }
+ if (state.pageUrl !== location.href) {
+ resetThreadIfPageChanged();
+ }
+ ui.pageTitle.textContent = document.title || location.hostname;
+ }, 1200);
+
+ void loadSettings();
+})();
diff --git a/browser-extension/deer-web-clipper/manifest.json b/browser-extension/deer-web-clipper/manifest.json
new file mode 100644
index 00000000..637adddb
--- /dev/null
+++ b/browser-extension/deer-web-clipper/manifest.json
@@ -0,0 +1,24 @@
+{
+ "manifest_version": 3,
+ "name": "zncm Web Clipper",
+ "version": "0.2.0",
+ "description": "Send pages to zncm and execute browser-backed fetch tasks.",
+ "permissions": ["activeTab", "alarms", "cookies", "scripting", "storage", "tabs"],
+ "host_permissions": ["zncm Web Clipper Settings
+
+
+
+ zncm Web Clipper
+
+
+
技能加载中...
@@ -799,9 +877,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {当前没有可用技能
+ ) : filteredSkills.length === 0 ? ( +没有匹配的技能
) : (暂无画像信息,对话后系统会自动归纳。
++ 暂无画像信息,对话后系统会自动归纳。 +
) : ( -+ {desensitize(value)} +
+暂无历史摘要。
++ 暂无历史摘要。 +
) : ( -+ {desensitize(value)} +
+