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": [""], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_title": "zncm Web Clipper", + "default_popup": "popup.html" + }, + "options_page": "options.html", + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["config.js", "shared.js", "content.js"], + "run_at": "document_idle", + "all_frames": false + } + ] +} diff --git a/browser-extension/deer-web-clipper/options.html b/browser-extension/deer-web-clipper/options.html new file mode 100644 index 00000000..107964e1 --- /dev/null +++ b/browser-extension/deer-web-clipper/options.html @@ -0,0 +1,74 @@ + + + + + zncm Web Clipper Settings + + + +
+

zncm Web Clipper Settings

+ + + +
+ +
+

+ + +
+ +
+

+ + + + + + + + +

+
+ + + + + diff --git a/browser-extension/deer-web-clipper/options.js b/browser-extension/deer-web-clipper/options.js new file mode 100644 index 00000000..855a6589 --- /dev/null +++ b/browser-extension/deer-web-clipper/options.js @@ -0,0 +1,148 @@ +const textFields = [ + "apiBaseUrl", + "webBaseUrl", + "accessToken", + "defaultPrompt", + "workerId", + "taskPollIntervalSeconds", + "taskPageLoadTimeoutSeconds", +]; +const checkboxFields = [ + "autoDiscoverServiceUrl", + "tokenSyncEnabled", + "pageAssistantEnabled", + "taskPollingEnabled", + "closeTaskTabs", +]; + +function formatServiceStatus(settings) { + if (!settings.lastServiceSyncAt) { + return `当前服务地址:${settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl};API:${settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl}`; + } + return `当前服务地址:${settings.webBaseUrl || ""};API:${settings.apiBaseUrl || ""}\n上次识别:${settings.lastServiceSyncAt}(${settings.lastServiceSyncSource || "unknown source"})`; +} + +function formatTokenStatus(settings) { + if (!settings.lastTokenSyncAt) { + return "Token 尚未自动同步。"; + } + return `上次 token 同步:${settings.lastTokenSyncAt}(${settings.lastTokenSyncSource || "unknown source"})`; +} + +async function loadSettings() { + const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + if (!settings.workerId) { + settings.workerId = deerMakeWorkerId(); + await chrome.storage.sync.set({ workerId: settings.workerId }); + } + document.getElementById("apiBaseUrl").placeholder = + DEER_CLIPPER_DEFAULTS.apiBaseUrl || "在 config.js 中配置默认 API 地址"; + document.getElementById("webBaseUrl").placeholder = + DEER_CLIPPER_DEFAULTS.webBaseUrl || "在 config.js 中配置默认前端地址"; + for (const field of textFields) { + document.getElementById(field).value = settings[field] ?? ""; + } + for (const field of checkboxFields) { + document.getElementById(field).checked = Boolean(settings[field]); + } + document.getElementById("serviceStatus").textContent = formatServiceStatus(settings); + document.getElementById("tokenStatus").textContent = formatTokenStatus(settings); +} + +async function saveSettings() { + const next = {}; + for (const field of textFields) { + next[field] = document.getElementById(field).value.trim(); + } + for (const field of checkboxFields) { + next[field] = document.getElementById(field).checked; + } + next.apiBaseUrl = deerNormalizeBaseUrl(next.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); + next.webBaseUrl = deerNormalizeBaseUrl(next.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); + next.defaultPrompt = next.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT; + next.workerId = next.workerId || deerMakeWorkerId(); + next.taskPollIntervalSeconds = Math.max(3, Number(next.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds)); + next.taskPageLoadTimeoutSeconds = Math.max(5, Number(next.taskPageLoadTimeoutSeconds || DEER_CLIPPER_DEFAULTS.taskPageLoadTimeoutSeconds)); + await chrome.storage.sync.set(next); + const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + document.getElementById("status").textContent = "已保存。"; + document.getElementById("serviceStatus").textContent = formatServiceStatus(settings); + document.getElementById("tokenStatus").textContent = formatTokenStatus(settings); +} + +function sendDiscoverServiceMessage() { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: "zncm-discover-service" }, (response) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + return; + } + resolve(response || { ok: false, message: "服务地址识别没有返回结果。" }); + }); + }); +} + +function sendSyncTokenMessage() { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + return; + } + resolve(response || { ok: false, message: "Token 同步没有返回结果。" }); + }); + }); +} + +async function discoverServiceNow() { + const button = document.getElementById("discoverService"); + button.disabled = true; + document.getElementById("serviceStatus").textContent = "正在识别服务地址..."; + try { + await chrome.storage.sync.set({ + autoDiscoverServiceUrl: document.getElementById("autoDiscoverServiceUrl").checked, + apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl), + webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl), + }); + const response = await sendDiscoverServiceMessage(); + const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || ""; + document.getElementById("webBaseUrl").value = settings.webBaseUrl || ""; + document.getElementById("serviceStatus").textContent = response.message || formatServiceStatus(settings); + } catch (error) { + document.getElementById("serviceStatus").textContent = error instanceof Error ? error.message : "服务地址识别失败。"; + } finally { + button.disabled = false; + } +} + +async function syncTokenNow() { + const button = document.getElementById("syncToken"); + button.disabled = true; + document.getElementById("tokenStatus").textContent = "正在同步 zncm token..."; + try { + await chrome.storage.sync.set({ + tokenSyncEnabled: document.getElementById("tokenSyncEnabled").checked, + apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl), + webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl), + }); + const response = await sendSyncTokenMessage(); + const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + document.getElementById("accessToken").value = settings.accessToken || ""; + document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || ""; + document.getElementById("webBaseUrl").value = settings.webBaseUrl || ""; + document.getElementById("serviceStatus").textContent = formatServiceStatus(settings); + document.getElementById("tokenStatus").textContent = response.message || formatTokenStatus(settings); + } catch (error) { + document.getElementById("tokenStatus").textContent = error instanceof Error ? error.message : "Token 同步失败。"; + } finally { + button.disabled = false; + } +} + +document.getElementById("save").addEventListener("click", saveSettings); +document.getElementById("discoverService").addEventListener("click", discoverServiceNow); +document.getElementById("syncToken").addEventListener("click", syncTokenNow); +void loadSettings(); diff --git a/browser-extension/deer-web-clipper/popup.html b/browser-extension/deer-web-clipper/popup.html new file mode 100644 index 00000000..14f81811 --- /dev/null +++ b/browser-extension/deer-web-clipper/popup.html @@ -0,0 +1,31 @@ + + + + + zncm Web Clipper + + + +
+

zncm Web Clipper

+ + +
+ + + + +
+

+
+ + + + + diff --git a/browser-extension/deer-web-clipper/popup.js b/browser-extension/deer-web-clipper/popup.js new file mode 100644 index 00000000..2bf7d403 --- /dev/null +++ b/browser-extension/deer-web-clipper/popup.js @@ -0,0 +1,151 @@ +function setStatus(message) { + document.getElementById("status").textContent = message; +} + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) { + throw new Error("No readable active tab found."); + } + return tab; +} + +async function captureFromActiveTab(tabId) { + const [result] = await chrome.scripting.executeScript({ + target: { tabId }, + func: deerExtractCurrentPage, + }); + if (!result?.result?.content) { + throw new Error("The current page has no readable text to send."); + } + return result.result; +} + +async function submitToZncm(page, settings, prompt) { + const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl); + const response = await fetch(`${apiBaseUrl}/api/browser-context/capture`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...deerAuthHeaders(settings), + }, + body: JSON.stringify({ + title: page.title, + url: page.url, + content: page.content, + content_type: page.contentType, + prompt, + }), + }); + + 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; +} + +async function sendCurrentPage() { + const sendButton = document.getElementById("send"); + sendButton.disabled = true; + setStatus("正在读取当前页面..."); + try { + await syncToken({ silent: true }); + const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + const prompt = document.getElementById("prompt").value.trim() || settings.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT; + const tab = await getActiveTab(); + const page = await captureFromActiveTab(tab.id); + setStatus("正在发送到 zncm..."); + const result = await submitToZncm(page, settings, prompt); + const webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); + await chrome.tabs.create({ url: `${webBaseUrl}/${result.chat_url}`.replace(/\/#/g, "/#") }); + setStatus("已发送,zncm 聊天页面已打开。"); + } catch (error) { + setStatus(error instanceof Error ? error.message : "发送失败。"); + } finally { + sendButton.disabled = false; + } +} + +function sendSyncTokenMessage() { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + return; + } + resolve(response || { ok: false, message: "Token 同步没有返回结果。" }); + }); + }); +} + +function sendPollNowMessage() { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: "zncm-poll-now" }, (response) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + return; + } + resolve(response || { ok: true, message: "Poll requested." }); + }); + }); +} + +async function syncToken(options = {}) { + const button = document.getElementById("syncToken"); + button.disabled = true; + if (!options.silent) { + setStatus("正在同步 zncm token..."); + } + try { + const response = await sendSyncTokenMessage(); + if (!options.silent) { + setStatus(response.message || "Token sync finished."); + } + return response; + } catch (error) { + if (!options.silent) { + setStatus(error instanceof Error ? error.message : "Token 同步失败。"); + } + return { ok: false, message: error instanceof Error ? error.message : "Token 同步失败。" }; + } finally { + button.disabled = false; + } +} + +async function pollNow() { + const button = document.getElementById("pollNow"); + button.disabled = true; + setStatus("正在检查 zncm 浏览器任务..."); + try { + await syncToken({ silent: true }); + const response = await sendPollNowMessage(); + setStatus(response.message || "任务检查完成。"); + } catch (error) { + setStatus(error instanceof Error ? error.message : "任务检查失败。"); + } finally { + button.disabled = false; + } +} + +async function togglePageAssistant() { + const enabled = document.getElementById("pageAssistantEnabled").checked; + await chrome.storage.sync.set({ pageAssistantEnabled: enabled }); + setStatus(enabled ? "页面问答框已开启。" : "页面问答框已关闭。"); +} + +async function init() { + const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + document.getElementById("prompt").value = settings.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT; + document.getElementById("pageAssistantEnabled").checked = Boolean(settings.pageAssistantEnabled); + document.getElementById("send").addEventListener("click", sendCurrentPage); + document.getElementById("syncToken").addEventListener("click", () => syncToken()); + document.getElementById("pollNow").addEventListener("click", pollNow); + document.getElementById("pageAssistantEnabled").addEventListener("change", togglePageAssistant); + document.getElementById("options").addEventListener("click", () => chrome.runtime.openOptionsPage()); +} + +void init(); diff --git a/browser-extension/deer-web-clipper/shared.js b/browser-extension/deer-web-clipper/shared.js new file mode 100644 index 00000000..35cf3f08 --- /dev/null +++ b/browser-extension/deer-web-clipper/shared.js @@ -0,0 +1,389 @@ +const DEER_CLIPPER_DEFAULT_PROMPT = + "\u8bf7\u57fa\u4e8e\u521a\u521a\u53d1\u9001\u7684\u7f51\u9875\u5185\u5bb9\uff0c\u5148\u7528\u4e2d\u6587\u603b\u7ed3\u8981\u70b9\uff0c\u5e76\u7b49\u5f85\u6211\u7ee7\u7eed\u63d0\u95ee\u3002"; + +const ZNCM_EXTENSION_CONFIG = globalThis.ZNCM_WEB_CLIPPER_CONFIG || {}; +const ZNCM_DEFAULT_WEB_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultWebBaseUrl || "").trim().replace(/\/+$/, ""); +const ZNCM_DEFAULT_API_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultApiBaseUrl || "").trim().replace(/\/+$/, ""); + +const DEER_CLIPPER_DEFAULTS = { + apiBaseUrl: ZNCM_DEFAULT_API_BASE_URL, + webBaseUrl: ZNCM_DEFAULT_WEB_BASE_URL, + autoDiscoverServiceUrl: true, + lastServiceSyncAt: "", + lastServiceSyncSource: "", + accessToken: "", + tokenSyncEnabled: true, + lastTokenSyncAt: "", + lastTokenSyncSource: "", + defaultPrompt: DEER_CLIPPER_DEFAULT_PROMPT, + pageAssistantEnabled: true, + taskPollingEnabled: true, + workerId: "", + taskPollIntervalSeconds: 5, + taskPageLoadTimeoutSeconds: 30, + closeTaskTabs: false, +}; + +function deerNormalizeBaseUrl(value) { + return String(value || "").trim().replace(/\/+$/, ""); +} + +function deerAuthHeaders(settings) { + const token = String(settings.accessToken || "").trim(); + if (!token) { + return {}; + } + return { + Authorization: /^bearer\s+/i.test(token) ? token : `Bearer ${token}`, + }; +} + +function deerMakeWorkerId() { + return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function deerExtractAuthTokenFromPage() { + const storageKeys = ["deerflow.auth"]; + const tokenFieldNames = new Set(["access_token", "accessToken", "token", "id_token", "idToken"]); + + function looksLikeToken(value) { + const text = String(value || "").trim(); + return text.length > 20 && !/\s/.test(text); + } + + function findToken(value, depth = 0) { + if (value == null || depth > 4) { + return ""; + } + if (typeof value === "string") { + const text = value.trim(); + if (!text) { + return ""; + } + if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) { + try { + return findToken(JSON.parse(text), depth + 1); + } catch { + return looksLikeToken(text) ? text : ""; + } + } + return looksLikeToken(text) ? text : ""; + } + if (typeof value !== "object") { + return ""; + } + for (const [key, nested] of Object.entries(value)) { + if (tokenFieldNames.has(key) && looksLikeToken(nested)) { + return String(nested).trim(); + } + } + for (const nested of Object.values(value)) { + const token = findToken(nested, depth + 1); + if (token) { + return token; + } + } + return ""; + } + + function scanStorage(storage, storageName) { + for (const key of storageKeys) { + const token = findToken(storage.getItem(key)); + if (token) { + return { token, source: `${storageName}:${key}` }; + } + } + + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key || !/(auth|token|session)/i.test(key)) { + continue; + } + const token = findToken(storage.getItem(key)); + if (token) { + return { token, source: `${storageName}:${key}` }; + } + } + return null; + } + + try { + return scanStorage(window.localStorage, "localStorage") || scanStorage(window.sessionStorage, "sessionStorage"); + } catch (error) { + return { token: "", source: "", error: error instanceof Error ? error.message : String(error) }; + } +} + +function deerExtractCurrentPage() { + const maxLocalChars = 220000; + + function cleanText(value) { + return String(value || "") + .replace(/\s+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + function escapeCell(value) { + return cleanText(value).replace(/\|/g, "\\|").replace(/\n/g, " "); + } + + function tableToMarkdown(table, index) { + const rows = Array.from(table.rows || []).map((row) => + Array.from(row.cells || []).map((cell) => escapeCell(cell.innerText)), + ); + if (rows.length === 0 || rows.every((row) => row.length === 0)) { + return ""; + } + const width = Math.max(...rows.map((row) => row.length)); + const normalized = rows.map((row) => { + const next = row.slice(); + while (next.length < width) next.push(""); + return next; + }); + const header = normalized[0]; + const divider = Array.from({ length: width }, () => "---"); + const body = normalized.slice(1); + return [ + `### Table ${index + 1}`, + "", + `| ${header.join(" | ")} |`, + `| ${divider.join(" | ")} |`, + ...body.map((row) => `| ${row.join(" | ")} |`), + ].join("\n"); + } + + const clone = document.body ? document.body.cloneNode(true) : null; + if (clone) { + clone.querySelectorAll("script, style, noscript, svg, canvas, iframe").forEach((node) => node.remove()); + } + + const title = document.title || ""; + const url = location.href; + const description = document.querySelector('meta[name="description"]')?.getAttribute("content") || ""; + const main = clone?.querySelector("article, main, [role='main']") || clone; + const text = cleanText(main?.innerText || document.body?.innerText || ""); + const tables = Array.from(document.querySelectorAll("table")) + .map((table, index) => tableToMarkdown(table, index)) + .filter(Boolean); + + const parts = []; + if (description.trim()) { + parts.push(`## Meta Description\n\n${cleanText(description)}`); + } + if (text) { + parts.push(`## Page Text\n\n${text}`); + } + if (tables.length > 0) { + parts.push(`## Tables\n\n${tables.join("\n\n")}`); + } + + let content = parts.join("\n\n"); + if (content.length > maxLocalChars) { + content = `${content.slice(0, maxLocalChars)}\n\n[Truncated locally by zncm Web Clipper]`; + } + + return { title, url, content, contentType: "markdown" }; +} + +// Injected into the page to build an indexed list of interactive elements plus +// the visible page text. Each interactive element is tagged with +// `data-deer-index` so a follow-up deerPerformAction call can find it by index. +function deerObservePage(maxElements) { + const limit = Math.max(10, Number(maxElements || 150)); + const DEER_ATTR = "data-deer-index"; + + document.querySelectorAll(`[${DEER_ATTR}]`).forEach((node) => node.removeAttribute(DEER_ATTR)); + + function isVisible(el) { + if (!(el instanceof Element)) return false; + const rect = el.getBoundingClientRect(); + if (rect.width <= 1 && rect.height <= 1) return false; + const style = window.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false; + if (el.disabled) return false; + return true; + } + + function labelFor(el) { + const aria = el.getAttribute("aria-label"); + if (aria && aria.trim()) return aria.trim(); + const placeholder = el.getAttribute("placeholder"); + const title = el.getAttribute("title"); + const name = el.getAttribute("name"); + const text = (el.innerText || el.textContent || "").trim(); + if (text) return text.replace(/\s+/g, " "); + if (placeholder && placeholder.trim()) return placeholder.trim(); + if (title && title.trim()) return title.trim(); + if (el.value && String(el.value).trim()) return String(el.value).trim(); + if (name && name.trim()) return name.trim(); + const alt = el.querySelector?.("img[alt]")?.getAttribute("alt"); + if (alt && alt.trim()) return alt.trim(); + return ""; + } + + const selector = [ + "a[href]", + "button", + "input:not([type=hidden])", + "select", + "textarea", + "[role=button]", + "[role=link]", + "[role=tab]", + "[role=menuitem]", + "[role=checkbox]", + "[role=radio]", + "[role=switch]", + "[contenteditable=true]", + "[onclick]", + ].join(","); + + const seen = new Set(); + const elements = []; + let index = 0; + for (const el of document.querySelectorAll(selector)) { + if (index >= limit) break; + if (seen.has(el) || !isVisible(el)) continue; + seen.add(el); + el.setAttribute(DEER_ATTR, String(index)); + const tag = el.tagName.toLowerCase(); + let label = labelFor(el); + if (label.length > 200) label = label.slice(0, 200); + const entry = { index, tag, label }; + const type = el.getAttribute("type") || el.getAttribute("role"); + if (type) entry.type = type; + if (tag === "input" || tag === "textarea" || tag === "select") { + const value = String(el.value || ""); + if (value) entry.value = value.slice(0, 80); + } + if (tag === "a") { + const href = el.getAttribute("href") || ""; + if (href) entry.href = href.slice(0, 200); + } + elements.push(entry); + index += 1; + } + + // Inline text extraction so this function stays self-contained when injected + // via chrome.scripting.executeScript (no dependency on other content-script fns). + let text = ""; + try { + const clone = document.body ? document.body.cloneNode(true) : null; + if (clone) { + clone.querySelectorAll(`script, style, noscript, svg, canvas, iframe, [${DEER_ATTR}-skip]`).forEach((node) => node.remove()); + } + const main = clone?.querySelector("article, main, [role='main']") || clone; + text = String(main?.innerText || document.body?.innerText || "") + .replace(/\s+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } catch { + text = ""; + } + + return { + url: location.href, + title: document.title || "", + elements, + text, + }; +} + +// Injected into the page to perform a single action on the element previously +// tagged by deerObservePage (by index). Returns {status, message}. +async function deerPerformAction(action) { + const DEER_ATTR = "data-deer-index"; + const type = String(action?.type || "").toLowerCase(); + + function findByIndex(idx) { + if (idx === undefined || idx === null || idx < 0) return null; + return document.querySelector(`[${DEER_ATTR}="${idx}"]`); + } + + function fireInput(el, value) { + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set; + if (setter) { + setter.call(el, value); + } else { + el.value = value; + } + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + + try { + if (type === "observe" || type === "extract") { + return { status: "ok", message: type === "extract" ? "Page text re-read." : "Observed page." }; + } + + if (type === "scroll") { + const el = findByIndex(action.index); + if (el) { + el.scrollIntoView({ block: "center", behavior: "instant" }); + return { status: "ok", message: "Scrolled element into view." }; + } + window.scrollBy(0, Math.round(window.innerHeight * 0.85)); + return { status: "ok", message: "Scrolled down one viewport." }; + } + + if (type === "wait") { + const selector = String(action.wait_for || "").trim(); + const timeoutMs = Math.max(500, Number(action.timeout_ms || 8000)); + if (!selector) { + await new Promise((resolve) => setTimeout(resolve, Math.min(timeoutMs, 3000))); + return { status: "ok", message: "Waited." }; + } + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (document.querySelector(selector)) { + return { status: "ok", message: `Element ${selector} appeared.` }; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return { status: "timeout", message: `Element ${selector} did not appear within ${timeoutMs}ms.` }; + } + + if (type === "click") { + const el = findByIndex(action.index); + if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` }; + el.scrollIntoView({ block: "center", behavior: "instant" }); + if (typeof el.focus === "function") el.focus(); + el.click(); + return { status: "ok", message: `Clicked element ${action.index}.` }; + } + + if (type === "type") { + const el = findByIndex(action.index); + if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` }; + el.scrollIntoView({ block: "center", behavior: "instant" }); + if (typeof el.focus === "function") el.focus(); + const value = String(action.text ?? ""); + if (el.isContentEditable) { + el.textContent = value; + el.dispatchEvent(new Event("input", { bubbles: true })); + } else { + fireInput(el, value); + } + if (action.submit) { + const enterInit = { bubbles: true, cancelable: true, key: "Enter", code: "Enter", keyCode: 13, which: 13 }; + el.dispatchEvent(new KeyboardEvent("keydown", enterInit)); + el.dispatchEvent(new KeyboardEvent("keyup", enterInit)); + const form = el.form || el.closest("form"); + if (form && typeof form.requestSubmit === "function") { + try { + form.requestSubmit(); + } catch { + form.submit?.(); + } + } + } + return { status: "ok", message: `Typed into element ${action.index}${action.submit ? " and submitted" : ""}.` }; + } + + return { status: "error", message: `Unsupported action type: ${type}` }; + } catch (error) { + return { status: "error", message: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/browser-extension/deer-web-clipper/styles.css b/browser-extension/deer-web-clipper/styles.css new file mode 100644 index 00000000..9b971b15 --- /dev/null +++ b/browser-extension/deer-web-clipper/styles.css @@ -0,0 +1,96 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: #171717; + background: #fafafa; + font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +h1 { + margin: 0 0 14px; + font-size: 16px; +} + +.popup, +.options { + display: grid; + gap: 12px; + padding: 14px; +} + +.popup { + width: 340px; +} + +.options { + max-width: 720px; +} + +label { + display: grid; + gap: 6px; + font-weight: 600; +} + +label.checkbox { + grid-template-columns: auto 1fr; + align-items: center; + font-weight: 500; +} + +label.checkbox input { + width: auto; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +input, +textarea { + width: 100%; + border: 1px solid #d4d4d4; + border-radius: 6px; + padding: 8px; + background: #fff; + color: inherit; + font: inherit; + font-weight: 400; +} + +textarea { + resize: vertical; +} + +button { + border: 0; + border-radius: 6px; + padding: 9px 12px; + background: #111827; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 600; +} + +button.secondary { + background: #e5e7eb; + color: #111827; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.status { + min-height: 20px; + margin: 0; + color: #525252; + white-space: pre-wrap; +} diff --git a/frontend-web/.env.development b/frontend-web/.env.development index e8c1e371..79771dc1 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -1,6 +1,9 @@ # 本地开发(pnpm dev)— 与原先 .env 保持一致 # 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高) +# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 +VITE_USE_REAL_DATA=true + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api diff --git a/frontend-web/.env.example b/frontend-web/.env.example index f0dda406..9653a1c4 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -28,6 +28,12 @@ # --- 生产打包(见 .env.production)--- # 部署前修改 .env.production 中的地址,再执行 pnpm build +# --- 任务看板(任务分析 / 三情分析)数据源开关 --- +# true=走真实接口;false 或未设=这两页使用本地假数据(演示用,无需后端)。 +# .env.development / .env.production / .env.network / .env.sit 均默认 true(真实数据), +# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。 +# VITE_USE_REAL_DATA=true + # MaxKey 鉴权根地址(无界 onlineTicket 换 token) # VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn # VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer diff --git a/frontend-web/.env.network b/frontend-web/.env.network index bf2a9011..923b70c7 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -1,6 +1,9 @@ # 本地开发(pnpm dev)— 与原先 .env 保持一致 # 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高) +# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 +VITE_USE_REAL_DATA=true + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api diff --git a/frontend-web/.env.production b/frontend-web/.env.production index d7dac2cd..413a18cf 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -1,6 +1,9 @@ # 生产打包(pnpm build)— 部署前请改成实际内网/生产地址 # 打包后变量会写入 dist,运行时不可再改 +# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 +VITE_USE_REAL_DATA=true + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index dbaa36d4..09d23e7b 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -1,6 +1,9 @@ # SIT 环境打包(pnpm build:sit / npm run build:sit)— 接口地址为 UAT 的 sit 版本 # 打包后变量会写入 dist,运行时不可再改 +# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 +VITE_USE_REAL_DATA=true + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api diff --git a/frontend-web/docs/product-knowledge-base-dev.md b/frontend-web/docs/product-knowledge-base-dev.md new file mode 100644 index 00000000..be287bfc --- /dev/null +++ b/frontend-web/docs/product-knowledge-base-dev.md @@ -0,0 +1,1243 @@ +# 产品内知识库沉淀系统开发文档 + +## 1. 背景 + +当前项目是一个 DeerFlow 定制化离线部署系统,包含: + +- 前端:`frontend-web/`,Vite + React 19 + TypeScript。 +- 后端:`offline-backend-20260512/backend/`,FastAPI Gateway + LangGraph agent runtime。 +- 现有能力:对话线程、工具搜索、引用来源、Markdown 渲染、外部知识库 iframe 页面。 + +本需求希望将“每次问答、搜索、工具调用、生成报告”等高价值内容自动或手动沉淀为产品内知识库,并在后续问答中可被检索和召回。 + +本方案以 `ar9av/obsidian-wiki` 为底层知识组织规范:优先复用它的 vault 目录结构、Markdown frontmatter、`index.md`、`log.md`、`hot.md`、`.manifest.json`、wikilink、`wiki-capture`、`wiki-query`、`wiki-export`、`graph-colorize` 等原生技能/页面产物。产品侧负责把这些能力封装成后端 API 和 React 页面。 + +## 2. 目标 + +实现一套产品内知识库系统: + +1. 支持从单次对话线程沉淀知识。 +2. 支持从搜索结果、工具调用结果沉淀知识。 +3. 支持自动抽取摘要、标签、实体、来源、关键结论。 +4. 支持知识列表、详情、搜索、编辑、删除。 +5. 支持保存 Obsidian 兼容 Markdown 文件。 +6. 支持后续问答时召回相关知识,并显示引用来源。 +7. 首版支持知识图谱页面,后续可增强实体关系抽取、自动去重、知识合并。 + +## 3. 首版交付范围 + +首版必须实现: + +- 产品内知识库页面:列表、详情、搜索、编辑、删除/归档。 +- 产品内 Markdown 编辑体验:支持编辑正文、预览 Markdown、编辑标题/标签/状态/来源信息。 +- 当前对话沉淀:在聊天线程页面点击“保存到知识库”,生成 obsidian-wiki 兼容 Markdown 笔记。 +- 历史线程批量入库:提供后台接口和前端入口,可批量扫描已有线程并沉淀为知识库内容。 +- 搜索和工具结果沉淀:保存 query、结果标题、摘要、URL、工具名等来源信息。 +- 全局共享知识库:所有用户共用同一套 vault 和数据库索引,不按用户隔离。 +- 审计记录:写入、更新、删除记录 `created_by` / `updated_by`,用于追踪操作来源。 +- Obsidian 兼容 vault:后端自行维护 `index.md`、`log.md`、`hot.md`、`.manifest.json`、wikilinks 和 frontmatter。 +- 图谱页面:后端自行生成 `wiki-export/graph.html` 和 `graph.json`,前端直接 iframe 展示 `graph.html`。 +- 不安装、不调用 `obsidian-wiki` CLI。 +- 知识库功能由新增 `knowledge` 模块独立实现。 + +首版暂缓实现: + +- 精确复刻 Obsidian 桌面端所有交互细节。 +- 图数据库服务。首版图谱用 Markdown wikilinks + `graph.json` / `graph.html` 实现,不额外引入图数据库中间件。 +- 向量 RAG 自动注入。首版先完成知识沉淀、管理、关键词搜索和图谱,向量召回后续增强。 + +## 4. 总体架构 + +数据流: + +```text +用户问答 / 搜索 / 工具调用 / 生成文档 + ↓ +Knowledge Event 采集 + ↓ +Obsidian Wiki Adapter +按 obsidian-wiki 原生技能规范抽取、分类、写入 vault + ↓ +三类存储 +1. Markdown Vault:obsidian-wiki 原生目录、frontmatter、wikilinks +2. 数据库:产品检索、列表、来源映射、创建/修改审计 +3. 向量索引:语义检索和 RAG 召回 + ↓ +前端知识库页面 +列表、搜索、详情、来源、编辑、图谱 + ↓ +后续问答自动召回相关知识 +``` + +推荐新增后端模块: + +```text +offline-backend-20260512/backend/app/gateway/routers/knowledge.py +offline-backend-20260512/backend/packages/harness/deerflow/knowledge/ +``` + +推荐新增前端模块: + +```text +frontend-web/src/core/knowledge/ +frontend-web/src/pages/KnowledgeBasePage.tsx +frontend-web/src/components/knowledge/ +``` + +## 4.1 obsidian-wiki 原生复用策略 + +必须优先复用 `obsidian-wiki` 的原生约定,而不是另起一套知识库格式。 + +### 4.1.1 复用对象 + +从 `ar9av/obsidian-wiki` 复用以下内容: + +- `.skills/wiki-capture/SKILL.md` + - 用作“对话沉淀为知识笔记”的抽取和写作模板。 + - 关键要求:写成 declarative knowledge,不保存聊天流水账。 + +- `.skills/wiki-query/SKILL.md` + - 用作知识检索策略参考。 + - 关键要求:先读 `hot.md` / `index.md` / frontmatter,再逐步升级到正文。 + +- `.skills/wiki-export/SKILL.md` + - 用作知识图谱导出规范。 + - 复用输出:`wiki-export/graph.json`、`graph.graphml`、`cypher.txt`、`graph.html`。 + +- `.skills/graph-colorize/SKILL.md` + - 后续用于 Obsidian graph 配色。 + +- `.skills/wiki-status/SKILL.md` + - 后续用于知识库状态、孤立页面、delta 分析。 + +- `.skills/cross-linker/SKILL.md` + - 后续用于自动补充 wikilinks。 + +- `.skills/wiki-dedup/SKILL.md` + - 后续用于重复知识合并。 + +- vault 文件: + - `index.md` + - `log.md` + - `hot.md` + - `.manifest.json` + - `_insights.md` + - `_meta/taxonomy.md` + +### 4.1.2 产品封装原则 + +产品后端新增 `ObsidianWikiAdapter`,负责: + +- 初始化 vault。 +- 调用或模拟 `wiki-capture` 的写入流程。 +- 写入 `index.md`、`log.md`、`hot.md`。 +- 维护 `.manifest.json`。 +- 按 `wiki-export` 规范生成 `wiki-export/graph.html/json`。 +- 将 vault 页面同步到产品数据库索引。 + +前端不直接依赖 obsidian-wiki 技能文件。前端只调用 `/api/knowledge/*`。 + +### 4.1.3 调用方式优先级 + +优先级如下: + +1. 运行时不使用 `obsidian-wiki` CLI。 +2. 后端内置 obsidian-wiki 兼容实现,输出必须兼容 obsidian-wiki vault。 +3. 后端自行初始化 vault、写入 Markdown、维护 `index.md` / `log.md` / `hot.md` / `.manifest.json`。 +4. 后端自行按 `wiki-export` 规范生成 `wiki-export/graph.html` / `graph.json`。 +5. 对 `wiki-export/graph.html` 这类原生产物,优先直接作为静态页面 iframe 到产品内。 +6. 对 Markdown 笔记列表、详情、编辑,使用产品自研 React 页面读取同一个 vault。 + +## 5. 与现有系统的关系 + +可复用现有能力: + +- `app/gateway/routers/threads.py` + - `GET /api/threads/{thread_id}/state` 可读取线程 messages。 + - `GET /api/threads/{thread_id}/reference-batches` 可读取工具搜索/引用来源。 + +- 前端 Markdown 能力 + - `frontend-web/src/strategy-components/MarkdownRenderer.tsx` + - `frontend-web/src/core/threads/export.ts` + +- 现有知识库入口 + - `frontend-web/src/strategy-components/components/resource-management/KnowledgeBaseResourceManagement.tsx` + - 当前是 iframe,后续可替换为原生知识库管理页面。 + +必须复用/兼容的外部项目能力: + +- `git-clone/obsidian-wiki/.skills/wiki-capture/SKILL.md` +- `git-clone/obsidian-wiki/.skills/wiki-query/SKILL.md` +- `git-clone/obsidian-wiki/.skills/wiki-export/SKILL.md` +- `git-clone/obsidian-wiki/.skills/wiki-status/SKILL.md` +- `git-clone/obsidian-wiki/.skills/wiki-dedup/SKILL.md` +- `git-clone/obsidian-wiki/.skills/cross-linker/SKILL.md` + +## 6. 分阶段实现 + +### 阶段一:首版完整交付 + +目标:交付可用的产品内共享知识库。用户可以手动沉淀当前对话,也可以批量沉淀历史线程;所有知识写入同一套 obsidian-wiki 兼容 vault,并在产品内完成浏览、搜索、编辑、删除和图谱查看。 + +范围: + +- 新增知识笔记数据库表。 +- 新增 `ObsidianWikiAdapter`。 +- 新增 obsidian-wiki 兼容 Markdown vault 写入能力。 +- 初始化并维护 `index.md`、`log.md`、`hot.md`、`.manifest.json`。 +- 新增 `POST /api/knowledge/ingest/thread/{thread_id}`。 +- 新增 `POST /api/knowledge/ingest/threads/batch`,支持批量扫描并沉淀历史线程。 +- 新增 `POST /api/knowledge/ingest/search`,支持搜索和工具结果来源沉淀。 +- 新增 `GET /api/knowledge/notes`。 +- 新增 `GET /api/knowledge/notes/{note_id}`。 +- 新增 `PUT /api/knowledge/notes/{note_id}`。 +- 新增 `DELETE /api/knowledge/notes/{note_id}`。 +- 新增 `POST /api/knowledge/search`,首版支持关键词/frontmatter/Markdown 内容检索。 +- 新增 `POST /api/knowledge/export/graph`,生成 `wiki-export/graph.html` 和 `graph.json`。 +- 前端新增“保存到知识库”按钮。 +- 前端新增“批量导入历史线程”入口。 +- 前端新增知识库列表、详情、编辑、搜索、删除/归档页面。 +- 前端新增图谱页面,iframe 展示 `wiki-export/graph.html`。 +- 知识库全局共享,不按用户隔离。 + +阶段一暂缓: + +- 向量检索。 +- 自动合并重复知识。 +- agent 回答前的向量 RAG 自动注入。 + +### 阶段二:向量检索和 RAG 召回 + +目标:用户后续提问时,自动检索知识库并注入 agent 上下文。 + +范围: + +- 先实现 obsidian-wiki 原生 `wiki-query` 风格的分层检索: + - `hot.md` + - `index.md` + - frontmatter + - 相关正文片段 +- 新增 Markdown 分块。 +- 新增 embedding 生成与索引。 +- 新增 `POST /api/knowledge/search`。 +- 在 agent 调用前执行知识召回。 +- 回答中展示引用的知识笔记和来源。 + +### 阶段三:自动沉淀和知识治理 + +目标:聊天完成后自动判断是否值得沉淀,并支持去重、合并、编辑审核。 + +范围: + +- 后台任务队列。 +- 价值判断:是否值得入库。 +- 重复检测。 +- 版本记录。 +- 合并建议。 +- 敏感信息过滤。 +- 审核状态:draft、approved、archived。 + +### 阶段四:图谱增强 + +目标:在首版 `graph.html` / `graph.json` 基础上增强实体、关系和交互能力。 + +范围: + +- 优先复用 obsidian-wiki `wiki-export` 输出。 +- 直接生成/服务 `wiki-export/graph.html` 作为第一版图谱页面。 +- 读取 `wiki-export/graph.json` 给产品自研图谱组件使用。 +- 后续再做实体抽取、关系抽取和自定义图谱。 +- `GET /api/knowledge/graph`。 +- 前端图谱页面。 + +## 7. 数据库设计 + +首版可使用当前项目已有数据库体系。字段名建议使用 snake_case。 + +### 7.1 knowledge_notes + +知识笔记主表。 + +```sql +CREATE TABLE knowledge_notes ( + id VARCHAR(64) PRIMARY KEY, + title VARCHAR(512) NOT NULL, + summary TEXT, + content_md LONGTEXT NOT NULL, + vault_path VARCHAR(1024), + source_type VARCHAR(64) NOT NULL, + source_id VARCHAR(256), + status VARCHAR(32) NOT NULL DEFAULT 'approved', + confidence FLOAT DEFAULT 0, + created_by VARCHAR(128), + updated_by VARCHAR(128), + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +``` + +`source_type` 可选: + +- `thread` +- `search` +- `tool` +- `file` +- `manual` +- `report` + +`status` 可选: + +- `draft` +- `approved` +- `archived` + +### 7.2 knowledge_sources + +知识来源表。 + +```sql +CREATE TABLE knowledge_sources ( + id VARCHAR(64) PRIMARY KEY, + note_id VARCHAR(64) NOT NULL, + source_type VARCHAR(64) NOT NULL, + thread_id VARCHAR(128), + message_id VARCHAR(128), + tool_name VARCHAR(128), + title VARCHAR(512), + url TEXT, + snippet TEXT, + raw_json LONGTEXT, + created_by VARCHAR(128), + created_at DATETIME NOT NULL +); +``` + +### 7.3 knowledge_tags + +```sql +CREATE TABLE knowledge_tags ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(128) NOT NULL, + created_at DATETIME NOT NULL +); +``` + +### 7.4 knowledge_note_tags + +```sql +CREATE TABLE knowledge_note_tags ( + note_id VARCHAR(64) NOT NULL, + tag_id VARCHAR(64) NOT NULL, + PRIMARY KEY (note_id, tag_id) +); +``` + +### 7.5 knowledge_entities + +阶段五使用。 + +```sql +CREATE TABLE knowledge_entities ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(256) NOT NULL, + entity_type VARCHAR(64), + created_at DATETIME NOT NULL +); +``` + +### 7.6 knowledge_relations + +阶段五使用。 + +```sql +CREATE TABLE knowledge_relations ( + id VARCHAR(64) PRIMARY KEY, + from_note_id VARCHAR(64), + to_note_id VARCHAR(64), + from_entity_id VARCHAR(64), + to_entity_id VARCHAR(64), + relation_type VARCHAR(128) NOT NULL, + weight FLOAT DEFAULT 1, + created_at DATETIME NOT NULL +); +``` + +### 7.7 knowledge_embeddings + +阶段三使用。如果使用外部向量库,此表只保存索引映射。 + +```sql +CREATE TABLE knowledge_embeddings ( + id VARCHAR(64) PRIMARY KEY, + note_id VARCHAR(64) NOT NULL, + chunk_index INT NOT NULL, + content TEXT NOT NULL, + vector_id VARCHAR(256), + created_at DATETIME NOT NULL +); +``` + +## 8. Markdown Vault 设计 + +默认存储路径: + +```text +offline-backend-20260512/backend/data/knowledge/obsidian-vault/ +``` + +也可以通过配置项指定: + +```yaml +knowledge: + enabled: true + provider: obsidian_wiki + vault_dir: data/knowledge/obsidian-vault + auto_ingest_enabled: false + max_thread_messages: 80 +``` + +必须兼容 obsidian-wiki 原生结构: + +```text +vault/ + index.md + log.md + hot.md + .manifest.json + _meta/ + taxonomy.md + concepts/ + entities/ + skills/ + references/ + synthesis/ + journal/ + projects/ + deerflow/ + concepts/ + references/ + synthesis/ + journal/ + wiki-export/ + graph.json + graph.graphml + cypher.txt + graph.html +``` + +页面分类遵循 `wiki-capture`: + +- `synthesis`:多步骤分析、方案、结论。 +- `concepts`:概念、框架、模型。 +- `references`:外部来源、搜索结果、文章资料。 +- `synthesis` decision 类型:架构或设计决策。 +- `journal`:完整会话摘要。 +- `projects//...`:项目相关知识优先进入项目空间。 + +Markdown frontmatter 必须优先兼容 `wiki-capture`: + +```markdown +--- +title: >- + 知识标题 +category: synthesis +tags: [deerflow, knowledge-base, agent] +sources: + - conversation:2026-06-08 + - thread:thread_id + - message:message_id +created: 2026-06-08T15:30:00+08:00 +updated: 2026-06-08T15:30:00+08:00 +summary: >- + 一到两句话说明这页知识保存了什么。 +provenance: + extracted: 0.7 + inferred: 0.2 + ambiguous: 0.1 +base_confidence: 0.82 +lifecycle: draft +lifecycle_changed: 2026-06-08 +relationships: + - target: "[[concepts/knowledge-graph]]" + type: uses +--- + +# 知识标题 + +## Context + +问题背景或触发场景。 + +## Finding / Decision + +沉淀后的知识本体。不要写成“用户问了什么,AI 回答了什么”的聊天摘要。 + +## Reasoning + +为什么成立、取舍是什么、不确定性在哪里。推断内容使用 `^[inferred]` 标记。 + +## Implications + +后续使用建议、风险、下一步。 + +## Related + +- [[concepts/knowledge-graph]] +- [[projects/deerflow/synthesis/product-knowledge-base]] +``` + +写入任何页面后必须同步: + +- `index.md`:加入页面索引。 +- `log.md`:追加 `CAPTURE` / `INGEST` / `QUERY` / `EXPORT` 记录。 +- `hot.md`:更新近期活动摘要。 +- `.manifest.json`:记录 source、hash、vault path、更新时间,用于去重和 delta。 + +## 9. 后端接口设计 + +统一前缀: + +```text +/api/knowledge +``` + +### 9.1 从线程沉淀知识 + +```http +POST /api/knowledge/ingest/thread/{thread_id} +``` + +请求: + +```json +{ + "mode": "summary", + "title": "", + "status": "approved", + "tags": ["DeerFlow"], + "include_sources": true +} +``` + +字段说明: + +- `mode`: `summary` 或 `full` +- `title`: 可选,空时自动生成 +- `status`: `draft` 或 `approved` +- `tags`: 用户手动附加标签 +- `include_sources`: 是否解析工具/搜索来源 + +响应: + +```json +{ + "note": { + "id": "kb_xxx", + "title": "多智能体任务调度方案", + "summary": "...", + "source_type": "thread", + "source_id": "thread_id", + "vault_path": "threads/2026/06/xxx.md", + "created_at": "2026-06-08T15:30:00+08:00" + } +} +``` + +### 9.2 批量沉淀历史线程 + +```http +POST /api/knowledge/ingest/threads/batch +``` + +请求: + +```json +{ + "limit": 100, + "offset": 0, + "status": "approved", + "include_sources": true, + "skip_existing": true +} +``` + +字段说明: + +- `limit`: 本次最多处理多少个线程。 +- `offset`: 线程分页偏移。 +- `status`: 生成知识的默认状态。 +- `include_sources`: 是否解析工具/搜索来源。 +- `skip_existing`: 是否根据 `.manifest.json` / source hash 跳过已沉淀线程。 + +响应: + +```json +{ + "ok": true, + "processed": 80, + "created": 52, + "skipped": 28, + "failed": 0, + "items": [ + { + "thread_id": "thread_id", + "note_id": "kb_xxx", + "status": "created" + } + ] +} +``` + +### 9.3 从搜索结果沉淀知识 + +```http +POST /api/knowledge/ingest/search +``` + +请求: + +```json +{ + "query": "LangGraph checkpoint", + "results": [ + { + "title": "Example", + "url": "https://example.com", + "snippet": "..." + } + ], + "thread_id": "optional_thread_id", + "message_id": "optional_message_id" +} +``` + +### 9.4 手动创建知识 + +```http +POST /api/knowledge/notes +``` + +请求: + +```json +{ + "title": "知识标题", + "content_md": "# 知识标题\n\n正文", + "tags": ["业务"], + "status": "approved" +} +``` + +### 9.5 知识列表 + +```http +GET /api/knowledge/notes?keyword=&tag=&source_type=&status=&limit=20&offset=0 +``` + +响应: + +```json +{ + "items": [ + { + "id": "kb_xxx", + "title": "知识标题", + "summary": "摘要", + "tags": ["DeerFlow"], + "source_type": "thread", + "source_id": "thread_id", + "updated_at": "2026-06-08T15:30:00+08:00" + } + ], + "total": 1 +} +``` + +### 9.6 知识详情 + +```http +GET /api/knowledge/notes/{note_id} +``` + +响应: + +```json +{ + "id": "kb_xxx", + "title": "知识标题", + "summary": "摘要", + "content_md": "# 知识标题...", + "tags": ["DeerFlow"], + "sources": [ + { + "id": "src_xxx", + "source_type": "thread", + "thread_id": "thread_id", + "message_id": "message_id", + "title": "来源标题", + "url": "https://example.com", + "snippet": "..." + } + ] +} +``` + +### 9.7 更新知识 + +```http +PUT /api/knowledge/notes/{note_id} +``` + +请求: + +```json +{ + "title": "新标题", + "content_md": "# 新内容", + "tags": ["新标签"], + "status": "approved" +} +``` + +### 9.8 删除知识 + +```http +DELETE /api/knowledge/notes/{note_id} +``` + +建议默认软删除:设置 `status = archived`。 + +### 9.9 知识搜索 + +阶段三实现。 + +```http +POST /api/knowledge/search +``` + +请求: + +```json +{ + "query": "如何做多智能体调度", + "mode": "hybrid", + "limit": 8 +} +``` + +`mode` 可选: + +- `keyword` +- `vector` +- `hybrid` + +响应: + +```json +{ + "items": [ + { + "note_id": "kb_xxx", + "chunk_id": "chunk_xxx", + "title": "多智能体任务调度方案", + "snippet": "相关片段", + "score": 0.87, + "sources": [] + } + ] +} +``` + +### 9.10 知识图谱 + +阶段五实现。 + +```http +GET /api/knowledge/graph?keyword=&limit=100 +``` + +响应: + +```json +{ + "nodes": [ + { + "id": "kb_xxx", + "type": "note", + "label": "知识标题" + } + ], + "edges": [ + { + "id": "rel_xxx", + "source": "kb_a", + "target": "kb_b", + "label": "related", + "weight": 1 + } + ] +} +``` + +### 9.11 Export Obsidian Wiki Graph + +First implementation should reuse obsidian-wiki `wiki-export` compatible output. + +```http +POST /api/knowledge/export/graph +``` + +Response: + +```json +{ + "ok": true, + "files": { + "graph_json": "/api/knowledge/export/files/graph.json", + "graph_html": "/api/knowledge/export/files/graph.html", + "graph_graphml": "/api/knowledge/export/files/graph.graphml", + "cypher": "/api/knowledge/export/files/cypher.txt" + }, + "stats": { + "nodes": 12, + "edges": 18 + } +} +``` + +### 9.12 Serve Obsidian Wiki Export Files + +```http +GET /api/knowledge/export/files/{filename} +``` + +Allowed files: + +- `graph.json` +- `graph.graphml` +- `cypher.txt` +- `graph.html` + +Frontend graph page first version: + +```text +iframe src="/api/knowledge/export/files/graph.html" +``` + +## 10. 后端服务设计 + +建议拆分: + +```text +deerflow/knowledge/ + __init__.py + models.py + repository.py + obsidian_adapter.py + skill_templates.py + markdown_vault.py + extractor.py + sources.py + manifest.py + exporter.py + chunking.py + embeddings.py + search.py +``` + +职责: + +- `models.py` + - Pydantic/领域模型。 + +- `repository.py` + - 数据库读写。 + - 不直接调用 LLM。 + +- `obsidian_adapter.py` + - 核心适配器。 + - 对外提供 `capture_thread`、`capture_search`、`list_notes`、`read_note`、`update_note`、`export_graph`。 + - 输出必须兼容 obsidian-wiki vault。 + +- `skill_templates.py` + - 读取或内置 `wiki-capture`、`wiki-query`、`wiki-export` 的关键模板。 + - 后端 LLM 抽取 prompt 应以这些技能为准。 + +- `markdown_vault.py` + - 负责生成 obsidian-wiki 兼容 frontmatter。 + - 负责安全文件名。 + - 负责写入/更新 Markdown 文件。 + - 负责 wikilink 解析和基础 frontmatter 解析。 + +- `extractor.py` + - 负责从 thread/search/tool 中抽取知识。 + - 首版可用规则生成摘要,正式版可调用 LLM。 + - LLM 输出必须映射为 `wiki-capture` 的类型:synthesis、concept、source、decision、session。 + +- `sources.py` + - 负责归一化来源。 + - 复用 `threads.py` 里的 reference normalize 逻辑时,建议抽公共函数,避免复制大段代码。 + +- `manifest.py` + - 读写 `.manifest.json`。 + - 记录 source hash,避免重复沉淀。 + +- `exporter.py` + - 内置实现 obsidian-wiki `wiki-export` 等价逻辑。 + - 生成 `wiki-export/graph.json` 和 `wiki-export/graph.html`。 + - 前端图谱优先 iframe `graph.html`。 + +- `chunking.py` + - Markdown 分块。 + +- `embeddings.py` + - embedding 生成和索引写入。 + +- `search.py` + - 关键词、向量、混合搜索。 + +## 11. 知识抽取策略 + +抽取策略必须优先遵循 obsidian-wiki 的 `wiki-capture`: + +- 保存知识本体,不保存聊天流水账。 +- 使用 declarative knowledge,不写“用户问了什么,AI 回答了什么”。 +- 明确分类为 `synthesis`、`concept`、`source`、`decision`、`session`。 +- 项目相关内容写入 `projects//...`。 +- 推断内容使用 `^[inferred]`。 +- 不确定或冲突内容使用 `^[ambiguous]`。 +- 每篇笔记尽量包含至少 2 个 `[[wikilinks]]`。 + +从线程抽取时,只保留高价值内容: + +- 用户明确提问。 +- 助手最终回答。 +- 工具搜索结果。 +- 被引用的来源。 +- 生成的结论、方案、计划、代码设计。 + +过滤: + +- 空消息。 +- 纯寒暄。 +- 中间推理内容。 +- 重复 token 流。 +- 错误堆栈中无价值的噪声。 +- 过长原文全文。 + +首版规则: + +1. 读取 thread state 的 `messages`。 +2. 按 human 消息切分 turn。 +3. 找每个 turn 的最后一个有正文的 ai 消息。 +4. 拼成沉淀上下文。 +5. 自动生成标题: + - 优先使用 thread title。 + - 没有 title 时使用第一条用户问题的前 40 字。 +6. 自动生成 summary: + - 首版可截取或简单规则总结。 + - 正式版用 LLM 生成结构化 JSON。 + +LLM 抽取推荐输出: + +```json +{ + "title": "知识标题", + "summary": "摘要", + "key_points": ["要点一", "要点二"], + "actions": ["建议一"], + "tags": ["标签"], + "entities": [ + {"name": "LangGraph", "type": "technology"} + ], + "relations": [ + {"from": "LangGraph", "to": "checkpoint", "type": "uses"} + ], + "confidence": 0.82, + "should_ingest": true, + "reason": "包含可复用架构决策" +} +``` + +## 12. 前端页面设计 + +### 12.1 路由 + +新增路由建议: + +```text +/page/knowledge +/page/knowledge/:noteId +``` + +如果必须放在工作台内,可放: + +```text +/page/workspace/knowledge +``` + +### 12.2 页面结构 + +知识库主页面: + +```text +顶部工具栏 +- 搜索框 +- 来源类型筛选 +- 标签筛选 +- 新建知识按钮 + +左侧/主区域 +- 知识列表 +- 标题 +- 摘要 +- 标签 +- 来源类型 +- 更新时间 + +右侧/详情区域 +- Markdown 内容 +- 来源列表 +- 编辑按钮 +- 删除/归档按钮 +``` + +### 12.3 对话页入口 + +在聊天线程页面增加按钮: + +```text +保存到知识库 +``` + +交互: + +1. 用户点击按钮。 +2. 弹窗展示: + - 标题输入框。 + - 标签输入。 + - 状态:草稿/正式。 + - 是否包含搜索来源。 +3. 调用 `POST /api/knowledge/ingest/thread/{thread_id}`。 +4. 成功后 toast 提示。 +5. 可点击进入知识详情。 + +### 12.4 知识详情页 + +功能: + +- Markdown 渲染。 +- 显示 frontmatter 中的标签、来源、更新时间。 +- 来源区支持跳转: + - thread 来源跳回原对话。 + - url 来源打开外链。 +- 编辑模式: + - 标题。 + - Markdown 内容。 + - 标签。 + - 状态。 + +## 13. RAG 召回接入 + +阶段二再做,不建议首版强行接入。 + +召回策略优先复用 obsidian-wiki `wiki-query` 的分层检索,而不是直接上向量库: + +1. 读取 `hot.md`,获取近期知识上下文。 +2. 读取 `index.md`,用标题、摘要、标签筛候选页面。 +3. 扫描 frontmatter:`title`、`tags`、`aliases`、`summary`、`tier`。 +4. 只读取候选页面相关片段。 +5. 必要时才读取全文。 +6. 向量检索作为增强,不作为首个唯一检索方式。 + +推荐接入点: + +1. 用户发送问题。 +2. 后端进入 agent 运行前。 +3. 调用 `KnowledgeSearchService.search(query, limit=5)`。 +4. 将搜索结果转换为系统上下文: + +```text +以下是产品知识库中与本问题相关的资料。回答时优先参考,若使用其中信息,请在回答中标注来源。 + +[知识 1] 标题 +摘要/片段 +来源:note_id / url +``` + +注意: + +- 不要注入过多内容,建议限制 3-5 条。 +- 每条只注入 snippet,不要注入整篇 Markdown。 +- 必须携带来源 id,方便前端引用展示。 +- 知识库为全局共享,检索所有已批准的公共知识。 + +## 14. 权限与安全 + +必须实现: + +- 知识库为全局共享,不按用户隔离。 +- 所有用户读取同一套 vault 和知识索引。 +- 写入、更新、删除操作记录 `created_by` / `updated_by`,用于审计。 +- Markdown 文件路径必须防止路径穿越。 +- 文件名必须 sanitize。 +- 删除默认软删除。 + +建议实现: + +- 自动沉淀前做敏感信息检测。 +- 对 token、密钥、手机号、身份证等内容做脱敏。 +- 提供“不要沉淀本次对话”的用户开关。 + +## 15. 配置项 + +建议在 `config.yaml` 中新增: + +```yaml +knowledge: + enabled: true + provider: obsidian_wiki + vault_dir: data/knowledge/obsidian-vault + project_name: deerflow + auto_ingest_enabled: false + auto_ingest_min_chars: 300 + max_thread_messages: 80 + max_source_items: 20 + export: + enabled: true + graph_html_enabled: true + graph_json_enabled: true + rag_enabled: false + rag_limit: 5 + query: + use_hot_md: true + use_index_md: true + use_frontmatter_scan: true + embedding: + enabled: false + provider: local + model: "" +``` + +## 16. 验收标准 + +阶段一验收: + +- 可以从一个已有 thread 生成知识笔记。 +- 可以批量扫描历史线程并生成知识笔记。 +- 搜索结果/工具结果能作为 sources 保存。 +- 数据库中存在 `knowledge_notes` 记录。 +- vault 中生成 Markdown 文件。 +- `index.md`、`log.md`、`hot.md`、`.manifest.json` 会同步更新。 +- 前端可以看到知识列表。 +- 前端可以查看知识详情。 +- 前端可以编辑标题、标签、状态和 Markdown 正文。 +- 前端可以关键词搜索知识。 +- 前端可以删除/归档知识。 +- 前端可以打开图谱页面,iframe 展示 `wiki-export/graph.html`。 +- 所有用户都能看到已批准的全局知识。 +- 删除知识后列表不再显示。 + +阶段二验收: + +- `POST /api/knowledge/search` 能返回相关知识。 +- agent 回答能使用知识库内容。 +- 回答中能显示知识来源。 + +阶段三验收: + +- 可配置自动沉淀开关。 +- 自动沉淀不会保存明显无价值寒暄。 +- 重复内容不会无限新增。 +- 用户可编辑和归档自动沉淀的知识。 + +## 17. 推荐开发顺序 + +1. 创建后端数据模型和 repository。 +2. 创建 `ObsidianWikiAdapter`,初始化 obsidian-wiki 兼容 vault。 +3. 创建 Markdown vault 写入工具,同步 `index.md`、`log.md`、`hot.md`、`.manifest.json`。 +4. 实现 `POST /api/knowledge/ingest/thread/{thread_id}`。 +5. 实现 `POST /api/knowledge/ingest/threads/batch`。 +6. 实现 `POST /api/knowledge/ingest/search`。 +7. 实现 `GET /api/knowledge/notes` 和详情接口。 +8. 实现 `PUT /api/knowledge/notes/{note_id}` 和 `DELETE /api/knowledge/notes/{note_id}`。 +9. 实现 `POST /api/knowledge/search` 的关键词/frontmatter/Markdown 内容检索。 +10. 增加 `POST /api/knowledge/export/graph`,生成并服务 `wiki-export/graph.html`。 +11. 前端创建 `core/knowledge/api.ts`。 +12. 前端实现知识列表、详情、编辑、搜索、图谱页面。 +13. 对话页增加“保存到知识库”按钮。 +14. 前端增加“批量导入历史线程”入口。 +15. 补充基础测试。 +16. 再进入向量 RAG、自动沉淀、自动合并阶段。 + +## 18. 测试建议 + +后端测试: + +- 从 thread state 生成知识。 +- 批量历史线程入库。 +- 搜索/工具来源入库。 +- 空 thread 返回合理错误。 +- 任意可读取的 thread 都可沉淀到全局知识库;需要按现有 thread 权限判断是否可读取来源。 +- Markdown 文件路径安全。 +- 知识列表分页正确。 +- 更新知识同步更新 vault 文件。 +- 删除知识为软删除。 + +前端测试: + +- 知识列表加载态、空态、错误态。 +- 搜索和筛选。 +- 详情 Markdown 渲染。 +- 知识编辑和保存。 +- 图谱 iframe 页面。 +- 批量导入历史线程入口。 +- 保存到知识库弹窗。 +- 保存成功后跳转详情。 + +手动测试: + +1. 新建一轮对话。 +2. 点击保存到知识库。 +3. 打开知识库页面。 +4. 查看生成的知识笔记。 +5. 编辑标题和内容。 +6. 批量导入历史线程。 +7. 打开图谱页面。 +8. 删除或归档知识。 + +## 19. 给开发模型的任务提示词 + +可将下面提示词交给其他模型: + +```text +请基于 frontend-web/docs/product-knowledge-base-dev.md 实现阶段一:首版完整交付。 + +要求: +1. 后端新增 /api/knowledge 相关接口。 +2. 从 /api/threads/{thread_id}/state 读取 messages,生成知识笔记。 +3. 新增独立 knowledge 模块,知识库相关能力都在该模块内实现。 +4. 新增 ObsidianWikiAdapter,复用 ar9av/obsidian-wiki 的原生规范和技能思路。 +5. 将知识元数据写入数据库,将 content_md 写入 obsidian-wiki 兼容 Markdown vault。 +6. 写入后必须同步 index.md、log.md、hot.md、.manifest.json。 +7. Markdown frontmatter 和正文结构参考 obsidian-wiki 的 wiki-capture 技能。 +8. 前端新增知识库列表、详情、编辑、搜索、图谱页面。 +9. 在聊天线程页面增加“保存到知识库”入口。 +10. 增加批量导入历史线程入口。 +11. 知识库为全局共享,不做 user_id 隔离;写入时记录 created_by / updated_by 作为审计。 +12. 图谱第一版生成并服务 wiki-export 兼容的 graph.html/json。 +13. 首版实现关键词/frontmatter/Markdown 内容搜索;向量 RAG 后续增强。 +14. 遵循当前项目已有 FastAPI、SQLAlchemy、React、TanStack Query、UI 组件风格。 +15. 完成后运行后端相关测试和前端 typecheck/build。 +``` + +## 20. 风险与注意事项 + +- 知识库模块独立实现,相关能力都收敛在新增 `knowledge` 模块内。 +- 不要另起一套与 obsidian-wiki 不兼容的 Markdown 结构。 +- 不要忽略 `index.md`、`log.md`、`hot.md`、`.manifest.json`,这些是 obsidian-wiki 的关键原生资产。 +- 不要让前端直接读服务器 Markdown 文件,应通过 API。 +- 不要同步阻塞聊天流式响应,自动沉淀应后台执行。 +- 不要保存完整工具 raw_json 到前端展示,详情页只展示必要字段。 +- 不要把所有搜索结果无脑入库,应先关联来源,再由知识笔记引用。 +- 首版不额外引入图数据库中间件,图谱优先使用 `wiki-export/graph.html` 和 `graph.json`。 diff --git a/frontend-web/package.json b/frontend-web/package.json index e54c5f66..c74784bd 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -74,6 +74,7 @@ "hast": "^1.0.0", "katex": "^0.16.28", "lucide-react": "^0.562.0", + "mermaid": "11.14.0", "motion": "^12.26.2", "nanoid": "^5.1.6", "next-themes": "^0.4.6", diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index a24f9b53..752afc7a 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -15,8 +15,11 @@ import { HashRouter, useLocation } from "react-router-dom"; import LandingPage from "./pages/LandingPage"; import LoginPage from "./pages/LoginPage"; import PageRoutes from "./pages/PageRoutes"; -import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage"; +import PublicScheduledTaskPage, { + PublicScheduledTaskFullscreenPage, +} from "./pages/PublicScheduledTaskPage"; import PublicSharePage from "./pages/PublicSharePage"; +import PublicLeaderboardPage from "./pages/PublicLeaderboardPage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import EmbedChatPage from "./pages/EmbedChatPage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; @@ -64,6 +67,8 @@ export function App() { } /> {/* Public read-only conversation viewer — no authentication. */} } /> + {/* Public read-only user leaderboard publish page — no authentication. */} + } /> {/* Public read-only roundtable share — landing + full result report. */} } /> + } + /> {/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */} void; } interface BadgeItem { @@ -184,7 +189,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) { ); } -export function AgentCard({ agent, layout = "card" }: AgentCardProps) { +export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) { const isList = layout === "list"; const { t } = useI18n(); const router = useRouter(); @@ -207,6 +212,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) { const [selectedSkills, setSelectedSkills] = useState( agent.skills ?? [], ); + const [skillSearch, setSkillSearch] = useState(""); const [detailSkillName, setDetailSkillName] = useState(null); // The /api/agents list response intentionally omits `soul` (perf optimization @@ -244,6 +250,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) { setEditPublished(agent.published); setEditIsPrivate(agent.is_private); setSelectedSkills(agent.skills ?? []); + setSkillSearch(""); }, [ agent.description, agent.is_private, @@ -436,7 +443,24 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) { }); } - const enabledSkills = skills.filter((skill) => skill.enabled); + const enabledSkills = useMemo( + () => skills.filter((skill) => skill.enabled), + [skills], + ); + + // Search over English name, Chinese name (name_zh) and description, so both + // 中英文 work. Already-selected skills stay searchable too (selection is + // independent of this filter). + const filteredSkills = useMemo(() => { + const q = skillSearch.trim().toLowerCase(); + if (!q) return enabledSkills; + return enabledSkills.filter( + (skill) => + skill.name.toLowerCase().includes(q) || + (skill.name_zh ?? "").toLowerCase().includes(q) || + (skill.description ?? "").toLowerCase().includes(q), + ); + }, [enabledSkills, skillSearch]); // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by // both the card header and the compact list row. @@ -497,6 +521,47 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) { ); + // 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。点击可按发布者筛选。 + const publisherLabel = agent.owner_username + ? desensitize(agent.owner_username) + : ""; + const canFilterByPublisher = + !isBuiltin && !!agent.owner_username && !!agent.user_id && !!onPublisherClick; + const publisherNode = + !isBuiltin && agent.owner_username ? ( + + ) : null; + + // 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。 + const skillNameZhMap = useMemo(() => { + const map = new Map(); + for (const s of skills) { + if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim()); + } + return map; + }, [skills]); + const skillBadgeItems = [ ...(agent.tool_groups ?? []).map((group) => ({ key: `tg:${group}`, @@ -519,7 +584,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) { setDetailSkillName(skill); }} > - {desensitize(skill)} + {skillNameZhMap.get(skill) ?? desensitize(skill)} ), })), @@ -585,15 +650,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) { )} - + {isOwned && ( + + )} diff --git a/frontend-web/src/components/workspace/knowledge-rag-toggle.tsx b/frontend-web/src/components/workspace/knowledge-rag-toggle.tsx new file mode 100644 index 00000000..42c12c40 --- /dev/null +++ b/frontend-web/src/components/workspace/knowledge-rag-toggle.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Database } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useThreadSettings } from "@/core/settings"; +import { cn } from "@/lib/utils"; + +import { Tooltip } from "./tooltip"; + +/** + * Per-conversation toggle for knowledge-base RAG injection (phase 2). + * + * Writes ``context.knowledge_rag_enabled`` into the thread settings, which the + * run forwards as ``runtime.context`` so ``KnowledgeRagMiddleware`` injects + * recalled knowledge before each model call for this conversation only. + */ +export function KnowledgeRagToggle({ threadId }: { threadId: string }) { + const [settings, setSettings] = useThreadSettings(threadId); + const enabled = settings.context.knowledge_rag_enabled === true; + + const toggle = () => { + setSettings("context", { + ...settings.context, + knowledge_rag_enabled: !enabled, + }); + }; + + return ( + + + + ); +} diff --git a/frontend-web/src/components/workspace/knowledge-save-trigger.tsx b/frontend-web/src/components/workspace/knowledge-save-trigger.tsx new file mode 100644 index 00000000..17cd62ba --- /dev/null +++ b/frontend-web/src/components/workspace/knowledge-save-trigger.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { BookMarked } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { useKnowledgeActions } from "@/core/knowledge/hooks"; + +import { ModelNameSelect } from "./model-name-select"; +import { useThread } from "./messages/context"; +import { Tooltip } from "./tooltip"; + +export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) { + const { thread } = useThread(); + const { ingestThread } = useKnowledgeActions(); + // Optional model override for the extraction (null = configured default). + const [model, setModel] = useState(null); + + if (thread.messages.length === 0) { + return null; + } + + // One click → fire-and-forget. The backend sediments the thread into the + // wiki asynchronously (title/tags auto-generated), so the click never blocks. + const handleSave = async () => { + try { + await ingestThread.mutateAsync({ + threadId, + payload: { background: true, model_name: model }, + }); + toast.success("已加入知识库保存队列,正在后台整理…"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } + }; + + return ( +
+ + + + +
+ ); +} diff --git a/frontend-web/src/components/workspace/model-name-select.tsx b/frontend-web/src/components/workspace/model-name-select.tsx new file mode 100644 index 00000000..327aaa63 --- /dev/null +++ b/frontend-web/src/components/workspace/model-name-select.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useModels } from "@/core/models/hooks"; +import { cn } from "@/lib/utils"; + +/** Sentinel for "use the backend's default/configured model" (no override). */ +const DEFAULT_VALUE = "__default__"; + +/** + * Compact dropdown to optionally pick an LLM model for a one-off action + * (skill-name generation, knowledge ingestion, …). The value is the model + * `name`; `null` means "use the configured default" (no override sent). + */ +export function ModelNameSelect({ + value, + onChange, + disabled, + className, + triggerClassName, + defaultLabel = "默认模型", +}: { + value: string | null; + onChange: (modelName: string | null) => void; + disabled?: boolean; + className?: string; + triggerClassName?: string; + defaultLabel?: string; +}) { + const { models } = useModels(); + return ( + + ); +} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 7def3260..46026eb8 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -7,7 +7,10 @@ import { FileText, MoreHorizontal, Pencil, + Pin, + PinOff, Share2, + Star, Trash2, } from "lucide-react"; import Link from "next/link"; @@ -54,9 +57,17 @@ import { useDeleteThread, useRenameThread, useThreads, + useToggleThreadFavorite, + useToggleThreadPin, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; -import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { + isThreadFavorite, + isThreadPinned, + pathOfThread, + sortThreadsPinnedFirst, + titleOfThread, +} from "@/core/threads/utils"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { isIMEComposing } from "@/lib/ime"; @@ -82,6 +93,8 @@ export function RecentChatList() { const { data: threads = [] } = useThreads(); const { mutate: deleteThread } = useDeleteThread(); const { mutate: renameThread } = useRenameThread(); + const { toggle: togglePin } = useToggleThreadPin(); + const { toggle: toggleFavorite } = useToggleThreadFavorite(); // Rename dialog state const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -160,11 +173,13 @@ export function RecentChatList() { [t], ); - const visibleThreads = threads.filter( - (thread) => - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && - thread.metadata?.thread_type !== "roundtable", + const visibleThreads = sortThreadsPinnedFirst( + threads.filter( + (thread) => + thread.metadata?.thread_type !== "scheduler" && + thread.metadata?.thread_type !== "bootstrap" && + thread.metadata?.thread_type !== "roundtable", + ), ); if (visibleThreads.length === 0) { @@ -195,6 +210,8 @@ export function RecentChatList() {
{visibleThreads.map((thread) => { const isActive = pathOfThread(thread) === pathname; + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); return ( - {desensitize(titleOfThread(thread, t.pages.newChat))} + {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( @@ -243,6 +265,36 @@ export function RecentChatList() { {t.common.rename} + + togglePin(thread.thread_id, !pinned) + } + > + {pinned ? ( + + ) : ( + + )} + {pinned ? t.common.unpin : t.common.pin} + + + toggleFavorite(thread.thread_id, !favorited) + } + > + + + {favorited + ? t.common.unfavorite + : t.common.favorite} + + handleShare(thread)} > diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 6e530a7c..2796265f 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -30,6 +30,10 @@ import { } from "@/core/system-settings/hooks"; import { cn } from "@/lib/utils"; +import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; +import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage"; +import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage"; + import { SettingsSection } from "./settings-section"; const languageOptions: { value: Locale; label: string }[] = [ @@ -336,6 +340,17 @@ export function AppearanceSettingsPage() {
)} + + {isAdmin && ( + <> + + + + + + + + )} ); } diff --git a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx index 477f039e..a33e9562 100644 --- a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react"; +import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert, Briefcase, UserRound, Sparkles, Clock, History, Archive } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; @@ -458,12 +458,24 @@ const CTX_LABELS: Record = { topOfMind: "当前重点", }; +const CTX_ICONS: Record = { + workContext: Briefcase, + personalContext: UserRound, + topOfMind: Sparkles, +}; + const HISTORY_LABELS: Record = { recentMonths: "近期", earlierContext: "早期背景", longTermBackground: "长期背景", }; +const HISTORY_ICONS: Record = { + recentMonths: Clock, + earlierContext: History, + longTermBackground: Archive, +}; + const CATEGORY_LABELS: Record> = { zh: { preference: "偏好", @@ -572,12 +584,15 @@ function MemoryV1View({ {categoryLabel} -
- - {Math.round(fact.confidence * 100)}% +
+ + 置信度 {Math.round(fact.confidence * 100)}% {/* Confidence Progress Bar */} -
+
{/* 用户画像 & 历史摘要 Tab */} - + {/* 用户画像 (userContext) */} -
-

用户画像

+
+
+ + + +

用户画像

+
{ctxEntries.length === 0 ? ( -

暂无画像信息,对话后系统会自动归纳。

+

+ 暂无画像信息,对话后系统会自动归纳。 +

) : ( -
- {ctxEntries.map(([key, value]) => ( -
-
- {CTX_LABELS[key] ?? key} -
-
{desensitize(value)}
-
- ))} -
+
+ {ctxEntries.map(([key, value]) => { + const Icon = CTX_ICONS[key] ?? UserRound; + return ( +
+
+ + {CTX_LABELS[key] ?? key} +
+

+ {desensitize(value)} +

+
+ ); + })} +
)} -
+ {/* 历史摘要 (history) */} -
-

历史摘要

+
+
+ + + +

历史摘要

+
{historyEntries.length === 0 ? ( -

暂无历史摘要。

+

+ 暂无历史摘要。 +

) : ( -
- {historyEntries.map(([key, value]) => ( -
-
- {HISTORY_LABELS[key] ?? key} -
-
{desensitize(value)}
-
- ))} -
+
+ {historyEntries.map(([key, value]) => { + const Icon = HISTORY_ICONS[key] ?? History; + return ( +
+ +
+ + {HISTORY_LABELS[key] ?? key} +
+

+ {desensitize(value)} +

+
+ ); + })} +
)} -
+
diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index afd3cfc3..10e3d3d1 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -22,6 +22,8 @@ import { Trash2Icon, TrendingUpIcon, Upload, + UserIcon, + XIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -55,6 +57,7 @@ import { import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; @@ -67,9 +70,12 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import { useDeleteCustomSkill, useDuplicateSkill, + useGenerateMissingSkillNameZh, + useGenerateSkillNameZh, useInstallSkillUpload, useReconcileCustomSkills, useSetSkillFeatured, + useSetSkillNameZh, useSetSkillPinned, useSetSkillPublished, useSkills, @@ -152,8 +158,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { const [tagIds, setTagIds] = useState([]); const [sort, setSort] = useState("time_desc"); const [page, setPage] = useState(1); + // Publisher filter: click a skill's 发布者 to keep only that user's skills. + // {id} is owner_user_id; {label} the desensitized display name. + const [publisher, setPublisher] = useState<{ id: string; label: string } | null>( + null, + ); const [viewMode, setViewMode] = useViewMode("skill"); const reconcileMutation = useReconcileCustomSkills(); + const fillNamesMutation = useGenerateMissingSkillNameZh(); + // Optional model override for the admin "一键补充中文名" action (null = default). + const [fillModel, setFillModel] = useState(null); // Name + tag filtering is resolved server-side via the /api/skills query. const { skills, isLoading, error } = useSkills({ search, tagIds, sort }); @@ -200,7 +214,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { }; }, [skills, currentUserId]); - const filteredSkills = filter === "mine" ? activeMine : activeAll; + const scopedSkills = filter === "mine" ? activeMine : activeAll; + const filteredSkills = useMemo( + () => + publisher + ? scopedSkills.filter((s) => s.owner_user_id === publisher.id) + : scopedSkills, + [scopedSkills, publisher], + ); const orderedSkills = useMemo( () => filteredSkills @@ -225,12 +246,34 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { }, [page, totalPages]); useEffect(() => { setPage(1); - }, [search, tagIds, sort]); + }, [search, tagIds, sort, publisher?.id, filter]); const pagedSkills = useMemo( () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [orderedSkills, page], ); + const handleFillNames = async () => { + try { + const result = await fillNamesMutation.mutateAsync(fillModel); + const okCount = result.generated.length; + const failCount = result.failed.length; + if (okCount === 0 && failCount === 0) { + toast.info("所有技能均已配置中文名称"); + } else if (failCount === 0) { + toast.success(`已为 ${okCount} 个技能补充中文名称`); + } else if (result.model_error) { + // Failures came from the model itself — say so explicitly. + toast.warning( + `已补充 ${okCount} 个,${failCount} 个因大模型报错失败:${result.model_error}`, + ); + } else { + toast.warning(`已补充 ${okCount} 个,${failCount} 个生成失败,可稍后重试或手动填写`); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }; + const handleRescan = async () => { try { const result = await reconcileMutation.mutateAsync(); @@ -321,6 +364,37 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { ? t.settings.skills.rescanSkillsRunning : t.settings.skills.rescanSkills} + {isAdmin && ( + <> + + + + )}
} /> + {publisher && ( +
+ 筛选发布者: + +
+ )} {isLoading && (
{t.common.loading}
)} @@ -384,6 +472,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { currentUserId={currentUserId} isAdmin={isAdmin} layout={viewMode === "card" ? "card" : "row"} + onPublisherClick={setPublisher} /> ))}
@@ -441,12 +530,16 @@ export function SkillRow({ currentUserId, isAdmin, layout = "row", + onPublisherClick, }: { skill: Skill; currentUserId: string | null; isAdmin: boolean; /** "row" = full-width horizontal item (default); "card" = grid card cell. */ layout?: "row" | "card"; + /** Click the 发布者 line to filter the list to that publisher. Receives the + * owner_user_id + its desensitized display label. */ + onPublisherClick?: (publisher: { id: string; label: string }) => void; }) { const isCard = layout === "card"; const { t } = useI18n(); @@ -549,8 +642,13 @@ export function SkillRow({ const badgeGroup = (
- {desensitize(skill.name)} + {skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)} + {skill.name_zh?.trim() && ( + + {desensitize(skill.name)} + + )} {isBuiltin && ( {t.settings.skills.badgeBuiltin} @@ -631,6 +729,33 @@ export function SkillRow({ const metaNodes = ( <> + {!isBuiltin && skill.owner_username && ( + + )} {skill.use_count !== undefined && (
{skill.use_count > 0 @@ -649,15 +774,15 @@ export function SkillRow({ const actionButtons = ( <> - + {canManage && ( +
+
+ + + + + + )} + + {!installedName && ( + <>
{ @@ -1148,6 +1391,8 @@ function InstallSkillDialog({ {installMutation.isPending ? dialog.installing : dialog.install} + + )} ); diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx index 94148451..733cbf27 100644 --- a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -11,6 +11,7 @@ import { PencilIcon, SlidersHorizontalIcon, SparklesIcon, + XIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -24,16 +25,20 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel"; import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog"; import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { + useGenerateSkillNameZh, useSetSkillDetail, + useSetSkillNameZh, useSkill, useSkillContent, useSkillFiles, @@ -98,9 +103,15 @@ export function SkillDetailDialog({ const activeName = open ? skillName : null; const { data: skill, isLoading: isSkillLoading } = useSkill(activeName); const setDetail = useSetSkillDetail(); + const setNameZh = useSetSkillNameZh(); + const generateNameZh = useGenerateSkillNameZh(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); + const [editingNameZh, setEditingNameZh] = useState(false); + const [nameZhDraft, setNameZhDraft] = useState(""); + // Optional model override for the "AI 生成" button (null = configured default). + const [nameZhModel, setNameZhModel] = useState(null); const [activeTab, setActiveTab] = useState< "detail" | "display" | "source" | "files" | null >(null); @@ -109,13 +120,20 @@ export function SkillDetailDialog({ const canManage = useMemo(() => canManageSkill(skill), [skill]); const isOwner = useMemo(() => isSkillOwner(skill), [skill]); + // Admins may browse any skill's files (the backend authorizes this); owners + // may browse their own custom skill. Edit-ability is decided separately by + // the backend's `filesData.editable` flag. + const canViewFiles = useMemo(() => { + if (!skill) return false; + return isOwner || getStoredAuth()?.system_role === "admin"; + }, [skill, isOwner]); const { data: contentData, isLoading: isContentLoading, error: contentError, } = useSkillContent(open ? skillName : null); - const filesEnabled = open && isOwner && activeTab === "files"; + const filesEnabled = open && canViewFiles && activeTab === "files"; const { data: filesData, isLoading: isFilesLoading, @@ -124,11 +142,16 @@ export function SkillDetailDialog({ useEffect(() => { setEditing(false); + setEditingNameZh(false); setActiveTab(null); setShowRawSource(false); setOpenedFilePath(null); }, [open, skillName]); + useEffect(() => { + setNameZhDraft(skill?.name_zh ?? ""); + }, [skill?.name, skill?.name_zh]); + const skillBody = useMemo(() => { return stripYamlFrontmatter(contentData?.content ?? ""); }, [contentData]); @@ -152,6 +175,39 @@ export function SkillDetailDialog({ } } + function startEditingNameZh() { + setNameZhDraft(skill?.name_zh ?? ""); + setEditingNameZh(true); + } + + async function handleSaveNameZh() { + if (!skill) return; + try { + await setNameZh.mutateAsync({ + skillName: skill.name, + nameZh: nameZhDraft.trim(), + }); + toast.success("中文名称已保存"); + setEditingNameZh(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleGenerateNameZh() { + if (!skill) return; + try { + const result = await generateNameZh.mutateAsync({ + skillName: skill.name, + modelName: nameZhModel, + }); + setNameZhDraft(result.name_zh ?? ""); + toast.success("已生成中文名称"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + async function copySource() { await navigator.clipboard.writeText(contentData?.content ?? ""); toast.success("Copied SKILL.md content"); @@ -174,25 +230,105 @@ export function SkillDetailDialog({
-
- - {desensitize(skill?.name ?? skillName ?? "")} - - {skill?.builtin && ( - - {tx.badgeBuiltin} - - )} - {skill?.published && ( - + + {desensitize(skill?.name ?? skillName ?? "")} + + setNameZhDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void handleSaveNameZh(); + if (event.key === "Escape") setEditingNameZh(false); + }} + placeholder={desensitize(skill?.name ?? skillName ?? "")} + className="h-8 max-w-[14rem] flex-1 text-sm" + autoFocus + disabled={generateNameZh.isPending || setNameZh.isPending} + /> + +
+ {generateNameZh.isPending ? ( + + ) : ( + + )} + AI 生成 + + + +
+ ) : ( +
+ + {skill?.name_zh?.trim() + ? skill.name_zh + : desensitize(skill?.name ?? skillName ?? "")} + + {skill?.name_zh?.trim() && ( + + {desensitize(skill?.name ?? skillName ?? "")} + + )} + {canManage && ( + + )} + {skill?.builtin && ( + + {tx.badgeBuiltin} + + )} + {skill?.published && ( + + + {tx.badgePublished} + + )} +
+ )} {skill?.description ? desensitize(skill.description) : "-"} @@ -227,7 +363,7 @@ export function SkillDetailDialog({ 展示适配 )} - {isOwner && ( + {canViewFiles && ( {tx.filesSectionTitle} @@ -349,7 +485,7 @@ export function SkillDetailDialog({ )} - {isOwner && ( + {canViewFiles && ( void; - tagIds: string[]; - onTagIdsChange: (value: string[]) => void; + tagIds?: string[]; + onTagIdsChange?: (value: string[]) => void; className?: string; actions?: ReactNode; + /** Show an inline spinner while the caller's query is fetching. */ + searching?: boolean; + /** Hide the tag chip row for resources that have no tags. */ + hideTags?: boolean; + /** Override the search input placeholder. */ + searchPlaceholder?: string; }) { const { t } = useI18n(); const { data: tags } = useTags(targetType); @@ -47,11 +63,21 @@ export function TagSearchBar({ const [expanded, setExpanded] = useState(false); const cbRef = useRef(onSearchChange); cbRef.current = onSearchChange; + // Keep the pending debounce timer so an explicit search (Enter / 搜索 button) + // can cancel it and fire immediately instead of racing the 300ms delay. + const timerRef = useRef | null>(null); useEffect(() => { - const id = setTimeout(() => cbRef.current(input.trim()), 300); - return () => clearTimeout(id); + timerRef.current = setTimeout(() => cbRef.current(input.trim()), 300); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; }, [input]); + const flushSearch = () => { + if (timerRef.current) clearTimeout(timerRef.current); + cbRef.current(input.trim()); + }; + const allTags = tags ?? []; const canCollapse = allTags.length > COLLAPSE_THRESHOLD; // When collapsed, always keep the selected tags visible so the active @@ -62,7 +88,7 @@ export function TagSearchBar({ : allTags; const toggleTag = (id: string) => { - onTagIdsChange( + onTagIdsChange?.( tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id], ); }; @@ -75,8 +101,14 @@ export function TagSearchBar({ setInput(e.target.value)} - placeholder={t.adminTags.searchPlaceholder} - className="pl-8" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + flushSearch(); + } + }} + placeholder={searchPlaceholder ?? t.adminTags.searchPlaceholder} + className={cn("pl-8", searching && "pr-9")} name="tag-search" // A `type="password"` field elsewhere on the page (e.g. the private // square access gate) makes browsers / password managers pick the @@ -88,7 +120,24 @@ export function TagSearchBar({ data-lpignore="true" data-form-type="other" /> + {searching && ( + + )}
+ {actions && (
{actions} @@ -96,11 +145,11 @@ export function TagSearchBar({ )}
- {allTags.length > 0 && ( + {!hideTags && allTags.length > 0 && (
@@ -438,8 +440,8 @@ function PodiumCard({ if (!user) { return ( -
-
第 {rank} 名
+
+
第 {rank} 名
暂无数据
); @@ -450,8 +452,8 @@ function PodiumCard({ type="button" onClick={() => onSelect(user)} className={cn( - "flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#230D0F]", - active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]", + "flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#02244C]", + active ? "border-[#C1121F] dark:border-[#2563EB] ring-2 ring-[#F1D6D6] dark:border-[#7FACF2] dark:ring-[#2563EB]/20" : "border-[#DADCE0] dark:border-[#213D5E]", )} >
@@ -462,11 +464,11 @@ function PodiumCard({ {user.system_role === "admin" && 管理员}
-
+
{initials(user)}
{user.email || user.user_id}
-
{scoreLabel(user, tab)}
+
{scoreLabel(user, tab)}
运行 {fmtNumber(user.run_count)} 问答 {fmtNumber(user.qa_count)} @@ -482,15 +484,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP const [metric, setMetric] = useState<"all" | TrendMetric>("all"); const chartId = useId().replace(/:/g, ""); if (points.length === 0) { - return
暂无趋势数据
; + return
暂无趋势数据
; } const visible = points.slice(-32); const series = [ - { key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" }, - { key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" }, - { key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" }, - { key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", description: "每天模型输入输出 Token 总量" }, + { key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]", description: "每天发起的运行次数" }, + { key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E] dark:bg-teal-300/15 dark:text-teal-100", description: "每天产生的有效提问次数" }, + { key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309] dark:bg-amber-300/15 dark:text-amber-100", description: "每天有行为的用户数量" }, + { key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" }, ] as const; const activeMetric: TrendMetric = metric === "all" ? "runs" : metric; const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0]; @@ -533,15 +535,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP "min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm", large && "px-4 py-3", metric === "all" - ? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]" - : "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]", + ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]" + : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]", )} > - 全部趋势 + 全部趋势 {fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))} - {large && 同时显示运行、问答、活跃与 Token} + {large && 同时显示运行、问答、活跃与 Token} )} {series.map((item) => ( @@ -553,8 +555,8 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP "min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm", large && "px-4 py-3", metric === item.key - ? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]" - : "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]", + ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]" + : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]", )} title={item.description} > @@ -562,7 +564,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP {fmtNumber(totalFor(item.key))} - {(large || compact) && {item.description}} + {(large || compact) && {item.description}} ))}
@@ -583,15 +585,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
{metricCards({ includeAll: true, large: true })}
-
-
当前查看
+
+
当前查看
{metric === "all" ? "全部指标" : activeSeries.label}
-
最新 {metric === "all" ? "运行" : activeSeries.label}
+
最新 {metric === "all" ? "运行" : activeSeries.label}
{fmtNumber(latestValue)}
-
= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}> +
= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}> 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
-
+
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
@@ -679,7 +681,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP return (
{metricCards({ includeAll: false })} -
+
每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。 {analysisDialog( "个人使用趋势", @@ -693,7 +695,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP return (
-

+

每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。

@@ -704,13 +706,13 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP )}
-
-
最新 {metric === "all" ? "运行" : activeSeries.label}
+
+
最新 {metric === "all" ? "运行" : activeSeries.label}
{fmtNumber(latestValue)}
-
= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}> +
= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}> 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
-
+
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
@@ -735,11 +737,11 @@ function UserTable({ }) { const max = Math.max(1, ...users.map((user) => userScore(user, tab))); if (users.length === 0) { - return
暂无用户统计
; + return
暂无用户统计
; } return ( -
+
{users.map((user, index) => { const score = userScore(user, tab); return ( @@ -748,11 +750,11 @@ function UserTable({ type="button" onClick={() => onSelect(user)} className={cn( - "grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#462123] dark:hover:bg-[#19090A]", - selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10", + "grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#213D5E] dark:hover:bg-[#011226]", + selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#2563EB]/10", )} > -
+
{rankOffset + index + 1}
@@ -765,9 +767,9 @@ function UserTable({
-
+
@@ -801,7 +803,7 @@ function SkillRanking({ }) { const max = Math.max(1, ...items.map((item) => item.call_count)); return ( -
+
@@ -810,7 +812,7 @@ function SkillRanking({ {items.length} 项
- {items.length === 0 &&
{empty}
} + {items.length === 0 &&
{empty}
} {items.slice(0, 10).map((item, index) => (
{index + 1} @@ -821,7 +823,7 @@ function SkillRanking({ {fmtNumber(item.user_count)} 人
-
+
item.count)); return ( -
+
@@ -868,7 +870,7 @@ function DimensionRanking({ {items.length} 项
- {items.length === 0 &&
{empty}
} + {items.length === 0 &&
{empty}
} {items.slice(0, 8).map((item, index) => (
{index + 1} @@ -879,7 +881,7 @@ function DimensionRanking({ {fmtNumber(item.user_count)} 人
-
+
-
+
+

{title}

@@ -922,8 +924,8 @@ function QuestionPanel({ {items.length}
-
- {items.length === 0 &&
暂无问题记录
} +
+ {items.length === 0 &&
暂无问题记录
} {items.map((item, index) => (
{clampText(item.text, 180)}
@@ -942,8 +944,8 @@ function QuestionPanel({ function Metric({ label, value }: { label: string; value: number }) { return ( -
-
{label}
+
+
{label}
{fmtNumber(value)}
); @@ -971,7 +973,7 @@ function UserDetailPanel({ if (isLoading || !data) { return ( -
+
加载用户详情
@@ -981,13 +983,13 @@ function UserDetailPanel({ return (
-
+
{data.user.email || data.user.user_id}
-
+
最近活跃 {fmtTime(data.user.last_active_at)}
@@ -996,7 +998,7 @@ function UserDetailPanel({
-
+
@@ -1016,7 +1018,7 @@ function UserDetailPanel({
- + 个人使用趋势
@@ -1051,6 +1053,7 @@ export default function AdminUserLeaderboardPage() { [range], ); const { data, isLoading, error } = useUserLeaderboard(apiRange, 100); + const refreshToday = useRefreshTodayLeaderboard(); const [reportOpen, setReportOpen] = useState(false); const { models: reportModels, isLoading: reportModelsLoading } = useModels({ enabled: reportOpen }); const [reportModelName, setReportModelName] = useState(DEFAULT_REPORT_MODEL); @@ -1226,31 +1229,38 @@ export default function AdminUserLeaderboardPage() { }, [apiRange.since, apiRange.until, reportModelName]); return ( -
+
-
-
-
-
- - 管理员分析 + {/* 整页统一滚动:标题与操作工具条不再固定置顶,随内容一起滚动,避免可视区域被压缩 */} + +
+
+ {/* 标题维度:仅承载页面标题与说明 */} +
+
+
+ + 管理员分析 +
+

用户排行榜

+

+ 统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。 +

-

用户排行榜

-

- 统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。 -

-
- -
+
+ + {/* 操作维度:与标题区分开的控制工具条(操作按钮 + 时间筛选) */} +
+
+ + - + AI 榜单分析报告 基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。 -
+
{reportStatus === "cached" && 已读取缓存} {reportResolvedModel && 模型 {reportResolvedModel}} - {reportGeneratedAt && 生成于 {fmtTime(reportGeneratedAt)}} + {reportGeneratedAt && 生成于 {fmtTime(reportGeneratedAt)}}
{data && reportStatus === "idle" && !reportText && (
-
+
- + 最近关注
{data.recent_questions.slice(0, 3).map((item, index) => (
- {fmtTime(item.asked_at)} + {fmtTime(item.asked_at)} {clampText(item.text, 58)}
))} - {data.recent_questions.length === 0 &&
暂无数据
} + {data.recent_questions.length === 0 &&
暂无数据
}
-
+
- + 高频问题
@@ -1337,34 +1381,34 @@ export default function AdminUserLeaderboardPage() { {index + 1}. {clampText(item.text, 72)}
))} - {data.frequent_questions.length === 0 &&
暂无数据
} + {data.frequent_questions.length === 0 &&
暂无数据
}
-
+
- + 使用频率
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
{item.name} - {fmtNumber(item.call_count)} 次 + {fmtNumber(item.call_count)} 次
))} - {data.skills.length + data.tools.length === 0 &&
暂无数据
} + {data.skills.length + data.tools.length === 0 &&
暂无数据
}
)} - + {reportStatus === "idle" && !reportText && ( -
+
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
)} {reportGenerating && !reportText && ( -
+
正在调用大模型生成报告,内容会流式显示
@@ -1375,13 +1419,13 @@ export default function AdminUserLeaderboardPage() {
)} {reportText && ( -
+
)} {reportText && ( -
+
+
event.currentTarget.showPicker?.()} onFocus={(event) => event.currentTarget.showPicker?.()} onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))} - className="h-9 w-36 bg-white dark:bg-[#19090A]" + className="h-9 w-36 bg-white dark:bg-[#011226]" />
@@ -1413,7 +1459,7 @@ export default function AdminUserLeaderboardPage() { onClick={(event) => event.currentTarget.showPicker?.()} onFocus={(event) => event.currentTarget.showPicker?.()} onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))} - className="h-9 w-36 bg-white dark:bg-[#19090A]" + className="h-9 w-36 bg-white dark:bg-[#011226]" />
+
-
{isLoading && ( -
+
正在统计排行榜数据
-
+
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
@@ -1450,12 +1496,12 @@ export default function AdminUserLeaderboardPage() { )} {!isLoading && !error && leaderboardPending && !leaderboardHasRows && ( -
+
- + 排行榜正在后台统计
-
+
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
@@ -1495,7 +1541,7 @@ export default function AdminUserLeaderboardPage() { /> )} -
+

榜单前三

@@ -1534,7 +1580,7 @@ export default function AdminUserLeaderboardPage() { value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索用户邮箱或 ID" - className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]" + className="h-9 w-64 bg-white pl-8 dark:bg-[#011226]" />
@@ -1558,8 +1604,8 @@ export default function AdminUserLeaderboardPage() {
-
-
+
+

用户详情

@@ -1575,7 +1621,7 @@ export default function AdminUserLeaderboardPage() {
-
+
diff --git a/frontend-web/src/pages/AdminUserLeaderboardSettingsPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardSettingsPage.tsx index 4113c4c1..712c97d1 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardSettingsPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardSettingsPage.tsx @@ -61,17 +61,17 @@ export default function AdminUserLeaderboardSettingsPage() { }; return ( -
+
-
+
-
+
管理员设置

排行榜设置

-

+

这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。

@@ -86,14 +86,14 @@ export default function AdminUserLeaderboardSettingsPage() {
-
+
- + 统计排除名单
-

+

名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。

@@ -111,21 +111,21 @@ export default function AdminUserLeaderboardSettingsPage() { value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号邮箱或 ID" - className="h-10 border-[#F1D6D6] bg-white pl-9" + className="h-10 border-[#F1D6D6] bg-white pl-9 dark:border-[#213D5E] dark:bg-[#011226] dark:text-white" /> {query.trim() && candidates.length > 0 && ( -
+
{candidates.map((user) => ( @@ -134,15 +134,15 @@ export default function AdminUserLeaderboardSettingsPage() {
)}
-
+
{selectedUsers.length === 0 ? ( - 暂无排除账号 + 暂无排除账号 ) : (
{selectedUsers.map((user) => ( {userLabel(user)} @@ -157,14 +157,14 @@ export default function AdminUserLeaderboardSettingsPage() {
-
+
- + 统计规则
-

+

关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。

@@ -180,11 +180,11 @@ export default function AdminUserLeaderboardSettingsPage() { ].map(([key, title, desc]) => (
diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index d4ba237c..ed2a3f9c 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -1,95 +1,256 @@ -"use client"; - -import { DownloadIcon } from "lucide-react"; -import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; -import { - WorkspaceBody, - WorkspaceContainer, - WorkspaceHeader, -} from "@/components/workspace/workspace-container"; -import { useI18n } from "@/core/i18n/hooks"; -import { useThreads } from "@/core/threads/hooks"; -import { usePathOfThread, titleOfThread } from "@/core/threads/utils"; -import { formatTimeAgo } from "@/core/utils/datetime"; -import { desensitize } from "@/lib/desensitize"; - -export default function ChatsPage() { - const pathOfThread = usePathOfThread(); - const { t } = useI18n(); - const { data: threads } = useThreads(); - const [search, setSearch] = useState(""); - const [importOpen, setImportOpen] = useState(false); - - useEffect(() => { - document.title = `${t.pages.chats} - ${t.pages.appName}`; - }, [t.pages.chats, t.pages.appName]); - - const filteredThreads = useMemo(() => { - return threads?.filter((thread) => { - return ( - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "roundtable" && - titleOfThread(thread).toLowerCase().includes(search.toLowerCase()) - ); - }); - }, [threads, search]); - return ( - - - -
-
-
- -
- setSearch(e.target.value)} - /> -
-
- -
- {filteredThreads?.map((thread) => ( - -
-
-
{desensitize(titleOfThread(thread, t.pages.newChat))}
-
- {thread.updated_at && ( -
- {formatTimeAgo(thread.updated_at)} -
- )} -
- - ))} -
-
-
-
-
- -
- ); -} +"use client"; + +import { + ChevronLeft, + ChevronRight, + DownloadIcon, + Pin, + PinOff, + Star, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; +import { + WorkspaceBody, + WorkspaceContainer, + WorkspaceHeader, +} from "@/components/workspace/workspace-container"; +import { useI18n } from "@/core/i18n/hooks"; +import { + useThreads, + useToggleThreadFavorite, + useToggleThreadPin, +} from "@/core/threads/hooks"; +import type { AgentThread } from "@/core/threads/types"; +import { + isThreadFavorite, + isThreadPinned, + usePathOfThread, + titleOfThread, +} from "@/core/threads/utils"; +import { formatTimeAgo } from "@/core/utils/datetime"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 20; + +export default function ChatsPage() { + const pathOfThread = usePathOfThread(); + const { t } = useI18n(); + const { data: threads } = useThreads(); + const { toggle: togglePin } = useToggleThreadPin(); + const { toggle: toggleFavorite } = useToggleThreadFavorite(); + const [search, setSearch] = useState(""); + const [onlyFavorites, setOnlyFavorites] = useState(false); + const [page, setPage] = useState(1); + const [importOpen, setImportOpen] = useState(false); + + useEffect(() => { + document.title = `${t.pages.chats} - ${t.pages.appName}`; + }, [t.pages.chats, t.pages.appName]); + + // Reset to the first page whenever the filtered result set changes. + useEffect(() => { + setPage(1); + }, [search, onlyFavorites]); + + const filteredThreads = useMemo(() => { + return (threads ?? []).filter((thread) => { + if ( + thread.metadata?.thread_type === "scheduler" || + thread.metadata?.thread_type === "roundtable" + ) { + return false; + } + if (onlyFavorites && !isThreadFavorite(thread)) { + return false; + } + return titleOfThread(thread) + .toLowerCase() + .includes(search.toLowerCase()); + }); + }, [threads, search, onlyFavorites]); + + // Pinned conversations are surfaced in their own section on the first page + // only, and excluded from the paginated list (so they don't repeat on every + // page as the user pages through history). + const pinnedThreads = useMemo( + () => filteredThreads.filter(isThreadPinned), + [filteredThreads], + ); + const restThreads = useMemo( + () => filteredThreads.filter((t) => !isThreadPinned(t)), + [filteredThreads], + ); + + const totalPages = Math.max(1, Math.ceil(restThreads.length / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const pagedThreads = restThreads.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ); + + const renderThread = (thread: AgentThread) => { + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); + return ( +
+ +
+ {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + +
+ {thread.updated_at && ( +
+ {formatTimeAgo(thread.updated_at)} +
+ )} + + {/* Row actions — kept out of the Link so clicks don't navigate. */} +
+ + +
+
+ ); + }; + + const isEmpty = filteredThreads.length === 0; + + return ( + + + +
+
+
+
+ + +
+ +
+ setSearch(e.target.value)} + /> +
+
+ +
+ {currentPage === 1 && pinnedThreads.length > 0 && ( + <> +
+ {t.chats.pinnedSection} +
+ {pinnedThreads.map(renderThread)} + + )} + {pagedThreads.map(renderThread)} + {isEmpty && ( +
+ {onlyFavorites ? t.chats.noFavorites : t.chats.noChats} +
+ )} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+
+
+
+
+ +
+ ); +} diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx new file mode 100644 index 00000000..59c2a3e0 --- /dev/null +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -0,0 +1,821 @@ +import { + BookOpen, + Copy, + ExternalLink, + Loader2, + Network, + Pencil, + Plus, + RefreshCw, + Save, + Search, + Sparkles, + Trash2, + X, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ModelNameSelect } from "@/components/workspace/model-name-select"; +import { + useKnowledgeActions, + useKnowledgeDuplicates, + useKnowledgeNote, + useKnowledgeNotes, + useNoteWikilinks, + useVaultPage, +} from "@/core/knowledge/hooks"; +import type { KnowledgeNote } from "@/core/knowledge/types"; +import { cn } from "@/lib/utils"; +import MarkdownRenderer, { + type WikiLinkTarget, +} from "@/strategy-components/MarkdownRenderer"; + +const SOURCE_TYPE_LABELS: Record = { + thread: "对话", + search: "检索", + tool: "工具", + file: "文件", + manual: "手动", + report: "报告", +}; + +const STATUS_LABELS: Record = { + draft: "草稿", + approved: "已批准", + archived: "已归档", +}; + +/** Human label for an audit actor: prefer the resolved name (email), else a short id. */ +function actorLabel(name?: string | null, id?: string | null): string { + if (name && name.trim()) return name.trim(); + if (id && id.trim()) return `${id.trim().slice(0, 8)}…`; + return "—"; +} + +const ENTITY_TYPE_META: Record = { + person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" }, + org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" }, + product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" }, + technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" }, + concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" }, + region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" }, + country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" }, +}; + +function entityMeta(type?: string | null) { + return ( + ENTITY_TYPE_META[(type || "").toLowerCase()] ?? { + label: "其他", + className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600", + } + ); +} + +function useDebounced(value: T, delay = 350): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = window.setTimeout(() => setDebounced(value), delay); + return () => window.clearTimeout(id); + }, [value, delay]); + return debounced; +} + +function NoteListItem({ + note, + active, + onClick, +}: { + note: KnowledgeNote; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function CreateNoteDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { create } = useKnowledgeActions(); + const navigate = useNavigate(); + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [content, setContent] = useState(""); + + const reset = () => { + setTitle(""); + setTags(""); + setContent(""); + }; + + const handleSubmit = async () => { + if (!title.trim()) { + toast.error("请输入标题"); + return; + } + try { + const note = await create.mutateAsync({ + title: title.trim(), + content_md: content || `# ${title.trim()}\n\n`, + tags: tags + .split(/[,,\s]+/) + .map((t) => t.trim()) + .filter(Boolean), + status: "approved", + }); + toast.success("知识已创建"); + onOpenChange(false); + reset(); + navigate(`/page/workspace/knowledge/${note.id}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "创建失败"); + } + }; + + return ( + + + + 新建知识 + 手动创建一条知识笔记,写入共享知识库 vault。 + +
+ setTitle(e.target.value)} + /> + setTags(e.target.value)} + /> +