This commit is contained in:
fengxiangdebaozi committed 2026-06-10 19:39:06 +08:00
commit d9e3a87911
129 files changed
+17888 -1703

No files matched your search

+4 -1
View File
@@ -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/
@@ -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://` 等浏览器内部页面。
- 如果目标网站阻止扩展脚本注入,抓取可能失败。
- 后台任务默认会打开新标签页;是否抓取完成后关闭标签页,可在插件设置里配置。
@@ -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();
@@ -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",
});
@@ -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();
})();
@@ -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": ["<all_urls>"],
"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
}
]
}
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zncm Web Clipper Settings</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="options">
<h1>zncm Web Clipper Settings</h1>
<label>
API 地址
<input id="apiBaseUrl" />
</label>
<label>
前端地址
<input id="webBaseUrl" />
</label>
<label class="checkbox">
<input id="autoDiscoverServiceUrl" type="checkbox" />
自动识别已打开 zncm 页面的服务地址
</label>
<div class="actions">
<button id="discoverService" type="button">重新识别服务地址</button>
</div>
<p id="serviceStatus" class="status"></p>
<label>
Access Token
<textarea id="accessToken" rows="5" placeholder="会自动从已登录 zncm 页面或 access_token Cookie 获取。也可以手动粘贴。"></textarea>
</label>
<label class="checkbox">
<input id="tokenSyncEnabled" type="checkbox" />
自动同步已登录 zncm 的 token
</label>
<div class="actions">
<button id="syncToken" type="button">Sync token now</button>
</div>
<p id="tokenStatus" class="status"></p>
<label class="checkbox">
<input id="pageAssistantEnabled" type="checkbox" />
启用页面问答框
</label>
<label>
默认首问
<textarea id="defaultPrompt" rows="4"></textarea>
</label>
<label class="checkbox">
<input id="taskPollingEnabled" type="checkbox" />
启用 zncm 聊天触发的浏览器抓取任务
</label>
<label>
Worker ID
<input id="workerId" placeholder="自动生成" />
</label>
<label>
任务轮询间隔秒数
<input id="taskPollIntervalSeconds" type="number" min="3" step="1" />
</label>
<label>
页面加载超时秒数
<input id="taskPageLoadTimeoutSeconds" type="number" min="5" step="1" />
</label>
<label class="checkbox">
<input id="closeTaskTabs" type="checkbox" />
抓取完成后关闭任务标签页
</label>
<button id="save" type="button">保存</button>
<p id="status" class="status"></p>
</main>
<script src="config.js"></script>
<script src="shared.js"></script>
<script src="options.js"></script>
</body>
</html>
@@ -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();
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zncm Web Clipper</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="popup">
<h1>zncm Web Clipper</h1>
<label>
首问
<textarea id="prompt" rows="4"></textarea>
</label>
<label class="checkbox">
<input id="pageAssistantEnabled" type="checkbox" />
启用页面问答框
</label>
<div class="actions">
<button id="send" type="button">发送当前页</button>
<button id="syncToken" type="button">同步 token</button>
<button id="pollNow" type="button">检查任务</button>
<button id="options" type="button" class="secondary">设置</button>
</div>
<p id="status" class="status"></p>
</main>
<script src="config.js"></script>
<script src="shared.js"></script>
<script src="popup.js"></script>
</body>
</html>
+151
View File
@@ -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();
@@ -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) };
}
}
@@ -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;
}
+3
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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",
+10 -1
View File
@@ -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() {
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only user leaderboard publish page — no authentication. */}
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route
path="/roundtable-share/:shareCode"
@@ -78,6 +83,10 @@ export function App() {
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
@@ -8,12 +8,14 @@ import {
LockIcon,
MessageSquareIcon,
PinIcon,
SearchIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
@@ -73,6 +75,9 @@ interface AgentCardProps {
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list";
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => 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<string[]>(
agent.skills ?? [],
);
const [skillSearch, setSkillSearch] = useState("");
const [detailSkillName, setDetailSkillName] = useState<string | null>(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 ? (
<button
type="button"
disabled={!canFilterByPublisher}
onClick={(e) => {
if (!canFilterByPublisher) return;
e.stopPropagation();
onPublisherClick!({ id: agent.user_id!, label: publisherLabel });
}}
className={cn(
"text-muted-foreground flex max-w-full items-center gap-1 text-sm font-medium",
canFilterByPublisher &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
canFilterByPublisher
? `点击筛选发布者:${publisherLabel}`
: `发布者:${publisherLabel}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{publisherLabel}</span>
</button>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>();
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)}
</Badge>
),
})),
@@ -585,15 +650,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
{isOwned && (
<Button
size="icon"
@@ -702,6 +767,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</CardDescription>
)}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
@@ -747,6 +813,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</p>
)}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
@@ -792,6 +859,17 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(支持中英文名称、描述)…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
@@ -799,9 +877,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<p className="text-muted-foreground text-sm">
当前没有可用技能
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
@@ -816,7 +896,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
title={desensitize(skill.description ?? "")}
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">{desensitize(skill.name)}</span>
<span className="truncate">
{skill.name_zh?.trim()
? skill.name_zh
: desensitize(skill.name)}
</span>
</Button>
<Button
type="button"
@@ -934,13 +1018,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -36,6 +36,11 @@ export function AgentGallery() {
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
@@ -48,7 +53,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]);
}, [search, tagKey, sort, publisher?.id]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -69,6 +74,7 @@ export function AgentGallery() {
agents: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
} = useAgentsPaginated({
scope: mainScope,
search,
@@ -77,12 +83,14 @@ export function AgentGallery() {
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
owner: publisher?.id,
});
const {
agents: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
error: privateError,
} = usePrivateAgentsPaginated(privatePassword, {
search,
@@ -91,6 +99,7 @@ export function AgentGallery() {
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
owner: publisher?.id,
});
// If the server reports the stored password no longer works (likely the
@@ -103,6 +112,12 @@ export function AgentGallery() {
}
}, [privateError, privatePassword]);
// The private tab is only "unlocked" once the feature is usable and a
// password has been verified. Until then we show the gate (or the lock
// prompt) and hide the keyword/tag search bar — there is no list to filter.
const privateUnlocked = privateFeatureUsable && !!privatePassword;
const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -127,17 +142,29 @@ export function AgentGallery() {
setPrivatePassword(null);
};
const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p);
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
<AgentCard
key={agent.id}
agent={agent}
layout="list"
onPublisherClick={handlePublisherClick}
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
<AgentCard
key={agent.id}
agent={agent}
onPublisherClick={handlePublisherClick}
/>
))}
</div>
);
@@ -296,9 +323,16 @@ export function AgentGallery() {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) =>
setActiveTab(v as "all" | "mine" | "private")
}
onValueChange={(v) => {
setActiveTab(v as "all" | "mine" | "private");
// Each tab starts from a clean slate: switching tabs clears the
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
setSearch("");
setTagIds([]);
setSort("time_desc");
setPublisher(null);
}}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
@@ -331,31 +365,53 @@ export function AgentGallery() {
)}
</div>
</div>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
{showSearchBar && (
<TagSearchBar
// Remount per tab so the internal keyword input / 展开 state also
// resets when the parent-owned filters are cleared on tab switch.
key={activeTab}
targetType="agent"
searching={
activeTab === "private" ? isPrivateFetching : mainFetching
}
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
onClick={() => setPublisher(null)}
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
title="点击清除发布者筛选"
>
{publisher.label}
<XIcon className="size-3.5" />
</button>
</div>
)}
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
@@ -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 (
<Tooltip content={enabled ? "知识库召回:已开启" : "知识库召回:已关闭"}>
<Button
variant="ghost"
onClick={toggle}
className={cn(
"text-muted-foreground hover:text-foreground",
enabled && "text-primary hover:text-primary",
)}
>
<Database />
知识库
</Button>
</Tooltip>
);
}
@@ -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<string | null>(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 (
<div className="flex items-center gap-1.5">
<ModelNameSelect
value={model}
onChange={setModel}
disabled={ingestThread.isPending}
triggerClassName="h-8 w-[8.5rem]"
/>
<Tooltip content="保存到知识库(用所选大模型后台异步整理)">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
<BookMarked />
{ingestThread.isPending ? "保存中…" : "保存"}
</Button>
</Tooltip>
</div>
);
}
@@ -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 (
<Select
value={value ?? DEFAULT_VALUE}
onValueChange={(v) => onChange(v === DEFAULT_VALUE ? null : v)}
disabled={disabled}
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={DEFAULT_VALUE}>{defaultLabel}</SelectItem>
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -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() {
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
@@ -210,10 +227,15 @@ export function RecentChatList() {
}
>
<Link
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{desensitize(titleOfThread(thread, t.pages.newChat))}
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -243,6 +265,36 @@ export function RecentChatList() {
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
togglePin(thread.thread_id, !pinned)
}
>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.common.unpin : t.common.pin}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleFavorite(thread.thread_id, !favorited)
}
>
<Star
className={
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground"
}
/>
<span>
{favorited
? t.common.unfavorite
: t.common.favorite}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
@@ -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() {
</div>
)}
</SettingsSection>
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
</>
)}
</div>
);
}
@@ -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<string, string> = {
topOfMind: "当前重点",
};
const CTX_ICONS: Record<string, React.ElementType> = {
workContext: Briefcase,
personalContext: UserRound,
topOfMind: Sparkles,
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const HISTORY_ICONS: Record<string, React.ElementType> = {
recentMonths: Clock,
earlierContext: History,
longTermBackground: Archive,
};
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: {
preference: "偏好",
@@ -572,12 +584,15 @@ function MemoryV1View({
<Icon className="w-3.5 h-3.5" />
{categoryLabel}
</span>
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
{Math.round(fact.confidence * 100)}%
<div
className="flex flex-col items-end gap-1"
title={`置信度:系统对这条记忆准确性的把握程度,${Math.round(fact.confidence * 100)}%`}
>
<span className="text-[11px] text-muted-foreground font-semibold tracking-wide">
置信度 {Math.round(fact.confidence * 100)}%
</span>
{/* Confidence Progress Bar */}
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
<div className="h-1 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
@@ -611,44 +626,77 @@ function MemoryV1View({
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
<TabsContent value="profile" className="mt-3 space-y-5">
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">用户画像</h3>
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<UserRound className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">用户画像</h3>
</div>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无画像信息,对话后系统会自动归纳。
</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ctxEntries.map(([key, value]) => {
const Icon = CTX_ICONS[key] ?? UserRound;
return (
<div
key={key}
className="group rounded-lg border border-border/50 bg-gradient-to-br from-card to-card/40 p-3.5 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:from-[#022248]/40 dark:to-[#022248]/10"
>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{CTX_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)}
</div>
</section>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">历史摘要</h3>
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<History className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">历史摘要</h3>
</div>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无历史摘要。
</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
<div className="space-y-3">
{historyEntries.map(([key, value]) => {
const Icon = HISTORY_ICONS[key] ?? History;
return (
<div
key={key}
className="relative rounded-lg border border-border/50 bg-card/60 p-3.5 pl-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:bg-[#022248]/30"
>
<span className="absolute inset-y-2 left-0 w-1 rounded-full bg-primary/30" />
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{HISTORY_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)}
</div>
</section>
</TabsContent>
</Tabs>
</div>
@@ -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<string[]>([]);
const [sort, setSort] = useState<SkillSort>("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<string | null>(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}
</Button>
{isAdmin && (
<>
<ModelNameSelect
value={fillModel}
onChange={setFillModel}
disabled={
fillNamesMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
triggerClassName="h-8 w-[9.5rem]"
/>
<Button
size="sm"
variant="outline"
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleFillNames()}
disabled={
fillNamesMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
title="用所选大模型为所有尚未配置中文名称的技能自动生成中文名(仅管理员)"
>
{fillNamesMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
@@ -362,6 +436,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</div>
}
/>
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
onClick={() => setPublisher(null)}
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
title="点击清除发布者筛选"
>
{publisher.label}
<XIcon className="size-3.5" />
</button>
</div>
)}
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
@@ -384,6 +472,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
currentUserId={currentUserId}
isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
))}
</div>
@@ -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 = (
<div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}>
{desensitize(skill.name)}
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
</span>
{skill.name_zh?.trim() && (
<span className="text-muted-foreground truncate text-xs font-normal">
{desensitize(skill.name)}
</span>
)}
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
@@ -631,6 +729,33 @@ export function SkillRow({
const metaNodes = (
<>
{!isBuiltin && skill.owner_username && (
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(e) => {
if (!skill.owner_user_id || !onPublisherClick) return;
e.stopPropagation();
onPublisherClick({
id: skill.owner_user_id,
label: desensitize(skill.owner_username!),
});
}}
className={cn(
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
skill.owner_user_id && onPublisherClick &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
skill.owner_user_id && onPublisherClick
? `点击筛选发布者:${desensitize(skill.owner_username)}`
: `发布者:${desensitize(skill.owner_username)}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
@@ -649,15 +774,15 @@ export function SkillRow({
const actionButtons = (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -842,13 +967,13 @@ export function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<SkillDetailDialog
skillName={skill.name}
open={detailOpen}
@@ -892,8 +1017,16 @@ function InstallSkillDialog({
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
// After a successful install the dialog switches to a "set Chinese name" step.
// `installedName` holds the just-installed skill name (null = still on the upload step).
const [installedName, setInstalledName] = useState<string | null>(null);
const [nameZhDraft, setNameZhDraft] = useState("");
// Optional model override for the "AI generate" button (null = configured default).
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const reset = useCallback(() => {
setFile(null);
@@ -901,14 +1034,30 @@ function InstallSkillDialog({
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
setInstalledName(null);
setNameZhDraft("");
setNameZhModel(null);
if (inputRef.current) inputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
if (validateMutation.isPending || installMutation.isPending) return;
if (
validateMutation.isPending ||
installMutation.isPending ||
setNameZhMutation.isPending ||
generateNameZhMutation.isPending
)
return;
reset();
onClose();
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
}, [
generateNameZhMutation.isPending,
installMutation.isPending,
onClose,
reset,
setNameZhMutation.isPending,
validateMutation.isPending,
]);
const acceptFile = useCallback(
async (selected: File) => {
@@ -964,15 +1113,47 @@ function InstallSkillDialog({
result.skill_name,
),
);
reset();
onClose();
// Stay open and advance to the optional "set Chinese name" step.
setInstalledName(result.skill_name);
setNameZhDraft("");
setNameZhModel(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, targetCategory],
);
const handleGenerateNameZh = useCallback(async () => {
if (!installedName) return;
try {
const result = await generateNameZhMutation.mutateAsync({
skillName: installedName,
modelName: nameZhModel,
});
setNameZhDraft(result.name_zh ?? "");
toast.success(dialog.nameZhGenerated);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [dialog.nameZhGenerated, generateNameZhMutation, installedName, nameZhModel]);
const handleSaveNameZh = useCallback(async () => {
if (!installedName) return;
const trimmed = nameZhDraft.trim();
try {
// Empty draft = keep whatever is stored (or none); just close.
if (trimmed) {
await setNameZhMutation.mutateAsync({ skillName: installedName, nameZh: trimmed });
toast.success(dialog.nameZhSaved);
}
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
@@ -984,8 +1165,70 @@ function InstallSkillDialog({
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{dialog.title}</DialogTitle>
<DialogTitle>{installedName ? dialog.nameZhStepTitle : dialog.title}</DialogTitle>
</DialogHeader>
{installedName && (
<>
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
{dialog.nameZhStepHint.replace("{name}", desensitize(installedName))}
</p>
<div className="flex items-center gap-2">
<Input
value={nameZhDraft}
onChange={(e) => setNameZhDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSaveNameZh();
}}
placeholder={dialog.nameZhPlaceholder}
className="h-9 flex-1"
autoFocus
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
/>
<ModelNameSelect
value={nameZhModel}
onChange={setNameZhModel}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
triggerClassName="h-9 w-[9rem] shrink-0"
/>
<Button
variant="outline"
className="h-9 shrink-0 gap-1 border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleGenerateNameZh()}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
title={dialog.nameZhGenerate}
>
{generateNameZhMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
{dialog.nameZhGenerate}
</Button>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
>
{dialog.nameZhSkip}
</Button>
<Button
onClick={() => void handleSaveNameZh()}
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
>
{setNameZhMutation.isPending && <Loader2 className="size-4 animate-spin" />}
{t.common.save}
</Button>
</DialogFooter>
</>
)}
{!installedName && (
<>
<div className="flex flex-col gap-4">
<div
onDragOver={(e) => {
@@ -1148,6 +1391,8 @@ function InstallSkillDialog({
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
@@ -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<string | null>(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({
<SparklesIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
{editingNameZh ? (
<div className="flex items-center gap-1.5">
<DialogTitle className="sr-only">
{desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
<Input
value={nameZhDraft}
onChange={(event) => 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}
/>
<ModelNameSelect
value={nameZhModel}
onChange={setNameZhModel}
disabled={generateNameZh.isPending || setNameZh.isPending}
triggerClassName="h-8 w-[8.5rem] shrink-0"
/>
<Button
size="sm"
variant="outline"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
className="h-8 shrink-0 gap-1 border-violet-300 bg-violet-50 px-2.5 text-xs font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleGenerateNameZh()}
disabled={generateNameZh.isPending || setNameZh.isPending}
title="用所选大模型自动生成中文名"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
{generateNameZh.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
AI 生成
</Button>
<Button
size="sm"
className="h-8 shrink-0 px-3 text-xs"
onClick={() => void handleSaveNameZh()}
disabled={setNameZh.isPending || generateNameZh.isPending}
>
{setNameZh.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
{t.common.save}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-8 shrink-0"
onClick={() => setEditingNameZh(false)}
disabled={setNameZh.isPending}
title={t.common.cancel}
>
<XIcon className="size-4" />
</Button>
</div>
) : (
<div className="group flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{skill?.name_zh?.trim()
? skill.name_zh
: desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.name_zh?.trim() && (
<span className="text-muted-foreground truncate font-mono text-xs">
{desensitize(skill?.name ?? skillName ?? "")}
</span>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-6 shrink-0 opacity-60 transition-opacity hover:opacity-100"
onClick={startEditingNameZh}
title={skill?.name_zh?.trim() ? "编辑中文名" : "设置中文名"}
>
<PencilIcon className="size-3" />
</Button>
)}
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
)}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description ? desensitize(skill.description) : "-"}
</DialogDescription>
@@ -227,7 +363,7 @@ export function SkillDetailDialog({
展示适配
</TabsTrigger>
)}
{isOwner && (
{canViewFiles && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle}
@@ -349,7 +485,7 @@ export function SkillDetailDialog({
</TabsContent>
)}
{isOwner && (
{canViewFiles && (
<TabsContent
value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -1,8 +1,9 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, Loader2, SearchIcon } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
@@ -18,27 +19,42 @@ const COLLAPSE_THRESHOLD = 16;
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke.
* request per keystroke. Pressing Enter or clicking the 搜索 button flushes the
* pending debounce immediately, so users never wait on the timer.
*
* Pass ``searching`` while a query is in flight to show an inline spinner — it
* makes server-side searches feel responsive instead of looking like they kept
* the previous results.
*
* Tags are rendered inline as a multi-select chip row (instead of a dropdown):
* "全部" is selected when no tag is picked, and any number of tags can be
* toggled. Filtering is OR — a resource matches if it carries any picked tag.
* When there are many tags the row collapses behind a 展开/收起 toggle.
* When there are many tags the row collapses behind a 展开/收起 toggle. Pass
* ``hideTags`` for resources without tags (e.g. the published-task plaza).
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagIds,
tagIds = [],
onTagIdsChange,
className,
actions,
searching = false,
hideTags = false,
searchPlaceholder,
}: {
targetType: TagType;
onSearchChange: (value: string) => 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<ReturnType<typeof setTimeout> | 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({
<Input
value={input}
onChange={(e) => 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 && (
<Loader2 className="text-muted-foreground absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin" />
)}
</div>
<Button
type="button"
variant="outline"
onClick={flushSearch}
className="shrink-0 gap-1.5"
title="搜索"
>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
搜索
</Button>
{actions && (
<div className="flex w-full shrink-0 justify-end sm:w-auto">
{actions}
@@ -96,11 +145,11 @@ export function TagSearchBar({
)}
</div>
{allTags.length > 0 && (
{!hideTags && allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => onTagIdsChange([])}
onClick={() => onTagIdsChange?.([])}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
tagIds.length === 0
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
@@ -164,6 +164,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/knowledge")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/knowledge">
<BookOpen />
<span>知识库</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
@@ -9,15 +9,12 @@ import {
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
UsersIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -148,36 +145,6 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/recommended-questions")
}
>
<MessageSquarePlusIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/private-square")
}
>
<ShieldCheckIcon />
私密广场配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -200,10 +167,12 @@ export function WorkspaceNavMenu() {
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<UsersIcon />
用户中心
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
@@ -232,12 +201,12 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
+26
View File
@@ -247,6 +247,32 @@ export async function getUserLeaderboard(
);
}
/**
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
* real-time records are included, then return the leaderboard for `range`.
*/
export async function refreshTodayLeaderboard(
range: {
since: string;
until: string;
days?: number;
},
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard/refresh-today?${params}`, {
method: "POST",
}),
"Failed to refresh today's leaderboard",
);
}
export async function getUserAnalytics(
userId: string,
range: { since: string; until: string; days?: number },
+31
View File
@@ -9,6 +9,7 @@ import {
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
refreshTodayLeaderboard,
listUserQa,
listUserThreadMessages,
listUserThreads,
@@ -41,6 +42,36 @@ export function useUserLeaderboard(
});
}
/**
* Force-rebuild today's snapshot, then prime + invalidate the matching
* leaderboard query so the page shows the freshly recomputed numbers.
*/
export function useRefreshTodayLeaderboard() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
range,
limit = 20,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
}) => refreshTodayLeaderboard(range, limit),
onSuccess: (data, variables) => {
qc.setQueryData(
[
"admin-activity",
"leaderboard",
variables.range.since,
variables.range.until,
variables.limit ?? 20,
],
data,
);
qc.invalidateQueries({ queryKey: ["admin-activity", "leaderboard"] });
},
});
}
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
+62 -56
View File
@@ -2,13 +2,13 @@ import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type {
Agent,
AgentSelectorResponse,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
Agent,
AgentSelectorResponse,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -28,21 +28,24 @@ export interface ListAgentsParams {
/** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
sort?: AgentSort;
}
page?: number;
pageSize?: number;
sort?: AgentSort;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
}
function buildAgentsQuery(params: ListAgentsParams): string {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.scope) qs.set("scope", params.scope);
if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
return qs.toString();
}
if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
if (params.owner) qs.set("owner", params.owner);
return qs.toString();
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const query = buildAgentsQuery(params);
@@ -129,13 +132,13 @@ export async function deleteAgent(id: string): Promise<void> {
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
}
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
return res.json() as Promise<AgentSelectorResponse>;
}
export async function favoriteAgent(id: string): Promise<void> {
}
export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST",
});
@@ -145,35 +148,35 @@ export async function favoriteAgent(id: string): Promise<void> {
}
}
export async function unfavoriteAgent(id: string): Promise<void> {
export async function unfavoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
}
}
export async function pinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
}
}
export async function unpinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
}
}
}
}
export async function pinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
}
}
export async function unpinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
}
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch(
@@ -211,14 +214,16 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
return data.agents;
}
export interface ListPrivateAgentsParams {
password: string;
search?: string;
tagIds?: string[];
page?: number;
pageSize?: number;
sort?: AgentSort;
}
export interface ListPrivateAgentsParams {
password: string;
search?: string;
tagIds?: string[];
page?: number;
pageSize?: number;
sort?: AgentSort;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
}
/** Paginated private-square listing (password-gated). */
export async function listPrivateAgentsPaginated(
@@ -232,11 +237,12 @@ export async function listPrivateAgentsPaginated(
body: JSON.stringify({
password: params.password,
search: params.search || undefined,
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page,
page_size: params.pageSize,
sort: params.sort,
}),
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page,
page_size: params.pageSize,
sort: params.sort,
owner: params.owner || undefined,
}),
},
);
if (!res.ok) {
+119 -98
View File
@@ -3,34 +3,34 @@ import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
} from "@tanstack/react-query";
import {
adminSyncAgentSkills,
createAgent,
deleteAgent,
favoriteAgent,
getAgent,
getAgentSelector,
listAgents,
listAgentsPaginated,
listAllAgentsAdmin,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
listAgents,
listAgentsPaginated,
listAllAgentsAdmin,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
@@ -79,31 +79,43 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square";
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({
scope: "mine" | "square";
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: [
"agents",
"paginated",
scope,
search,
[...tagIds].sort(),
sort,
page,
pageSize,
],
queryFn: () =>
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }),
scope,
search,
[...tagIds].sort(),
sort,
page,
pageSize,
owner,
],
queryFn: () =>
listAgentsPaginated({
scope,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
}),
enabled,
placeholderData: keepPreviousData,
});
@@ -111,6 +123,9 @@ export function useAgentsPaginated(params: {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
// keepPreviousData keeps isLoading false on subsequent searches; expose
// isFetching so callers can show a "searching" indicator on every query.
isFetching,
error,
};
}
@@ -180,7 +195,7 @@ export function useDeleteAgent() {
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() {
export function useToggleAgentFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
@@ -196,30 +211,30 @@ export function useToggleAgentFavorite() {
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useToggleAgentPin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
},
});
}
export function useAgentSelector() {
});
}
export function useToggleAgentPin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
},
});
}
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
@@ -230,9 +245,9 @@ export function useAgentSelector() {
isLoading,
error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
@@ -277,39 +292,44 @@ export function usePrivateAgents(password: string | null) {
* password-gated private square plus the total count. */
export function usePrivateAgentsPaginated(
password: string | null,
params: {
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
},
) {
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error, refetch } = useQuery({
params: {
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
},
) {
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [
"agents",
"private-square",
password ?? "",
"paginated",
search,
[...tagIds].sort(),
sort,
page,
pageSize,
"paginated",
search,
[...tagIds].sort(),
sort,
page,
pageSize,
owner,
],
queryFn: () =>
listPrivateAgentsPaginated({
password: password!,
search,
tagIds,
sort,
page,
pageSize,
password: password!,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
}),
enabled: !!password && enabled,
staleTime: 30_000,
@@ -320,6 +340,7 @@ export function usePrivateAgentsPaginated(
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
refetch,
};
+12 -10
View File
@@ -1,10 +1,12 @@
import type { Tag } from "@/core/tags";
export interface Agent {
export interface Agent {
id: string;
name: string;
description: string;
user_id: string | null;
/** Publisher username (email local part); null for built-in agents. */
owner_username?: string | null;
published: boolean;
is_private: boolean;
builtin: boolean;
@@ -17,15 +19,15 @@ export interface Agent {
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
is_pinned?: boolean;
}
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse {
agents: Agent[];
}
is_favorite?: boolean;
is_pinned?: boolean;
}
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse {
agents: Agent[];
}
/** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents {
@@ -25,6 +25,10 @@ export const enUS: Translations = {
edit: "Edit",
rename: "Rename",
share: "Share",
pin: "Pin",
unpin: "Unpin",
favorite: "Favorite",
unfavorite: "Unfavorite",
openInNewWindow: "Open in new window",
close: "Close",
more: "More",
@@ -255,6 +259,14 @@ export const enUS: Translations = {
// Chats
chats: {
searchChats: "Search chats",
pinnedSection: "Pinned",
allChats: "All",
favorites: "Favorites",
onlyFavorites: "Favorites only",
prevPage: "Previous",
nextPage: "Next",
noChats: "No conversations yet",
noFavorites: "No favorited conversations yet",
},
// Conversation sharing
@@ -586,6 +598,14 @@ export const enUS: Translations = {
targetCategoryLabel: "Install to",
categoryCustom: "Custom skill",
categoryPublic: "Built-in (admin)",
nameZhStepTitle: "Set Chinese name",
nameZhStepHint:
"Skill '{name}' is installed. You can give it a Chinese display name, or let the model generate a default one (leave blank to use the English name).",
nameZhPlaceholder: "Enter a Chinese name (optional)",
nameZhGenerate: "AI generate",
nameZhSkip: "Skip",
nameZhGenerated: "Chinese name generated",
nameZhSaved: "Chinese name saved",
},
},
skillEvolution: {
@@ -26,6 +26,10 @@ export interface Translations {
edit: string;
rename: string;
share: string;
pin: string;
unpin: string;
favorite: string;
unfavorite: string;
openInNewWindow: string;
close: string;
more: string;
@@ -198,6 +202,14 @@ export interface Translations {
// Chats
chats: {
searchChats: string;
pinnedSection: string;
allChats: string;
favorites: string;
onlyFavorites: string;
prevPage: string;
nextPage: string;
noChats: string;
noFavorites: string;
};
// Conversation sharing
@@ -511,6 +523,13 @@ export interface Translations {
targetCategoryLabel: string;
categoryCustom: string;
categoryPublic: string;
nameZhStepTitle: string;
nameZhStepHint: string;
nameZhPlaceholder: string;
nameZhGenerate: string;
nameZhSkip: string;
nameZhGenerated: string;
nameZhSaved: string;
};
};
skillEvolution: {
@@ -25,6 +25,10 @@ export const zhCN: Translations = {
edit: "编辑",
rename: "重命名",
share: "分享",
pin: "置顶",
unpin: "取消置顶",
favorite: "收藏",
unfavorite: "取消收藏",
openInNewWindow: "在新窗口打开",
close: "关闭",
more: "更多",
@@ -242,6 +246,14 @@ export const zhCN: Translations = {
// Chats
chats: {
searchChats: "搜索对话",
pinnedSection: "置顶",
allChats: "全部",
favorites: "收藏",
onlyFavorites: "只看收藏",
prevPage: "上一页",
nextPage: "下一页",
noChats: "暂无对话",
noFavorites: "暂无收藏的对话",
},
// Conversation sharing
@@ -559,6 +571,14 @@ export const zhCN: Translations = {
targetCategoryLabel: `安装到`,
categoryCustom: `自定义技能`,
categoryPublic: `内置技能(管理员)`,
nameZhStepTitle: "设置中文名",
nameZhStepHint:
"技能 “{name}” 已安装。你可以为它填写一个中文名,或让大模型自动生成一个默认中文名(留空则使用英文名)。",
nameZhPlaceholder: "请输入中文名(可留空)",
nameZhGenerate: "AI 生成",
nameZhSkip: "跳过",
nameZhGenerated: "已生成中文名称",
nameZhSaved: "已保存中文名",
},
},
skillEvolution: {
+226
View File
@@ -0,0 +1,226 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
BatchIngestPayload,
BatchIngestResult,
GraphExportResult,
IngestThreadPayload,
KnowledgeListParams,
KnowledgeListResponse,
KnowledgeNote,
KnowledgeSearchItem,
VaultPage,
WikilinkResolutionMap,
} from "./types";
const baseUrl = () => `${getBackendBaseURL()}/api/knowledge`;
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Request failed" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("请求失败");
}
return response.json() as Promise<T>;
}
export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
const qs = new URLSearchParams();
if (params.keyword) qs.set("keyword", params.keyword);
if (params.tag) qs.set("tag", params.tag);
if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status);
qs.set("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>(
await apiFetch(`${baseUrl()}/notes?${qs.toString()}`),
);
}
export async function getKnowledgeNote(noteId: string) {
return parseResponse<KnowledgeNote>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`),
);
}
export async function createKnowledgeNote(data: {
title: string;
content_md: string;
summary?: string | null;
tags?: string[];
status?: string;
}) {
const res = await parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.note;
}
export async function updateKnowledgeNote(
noteId: string,
data: Partial<{
title: string;
content_md: string;
summary: string | null;
tags: string[];
status: string;
}>,
) {
return parseResponse<KnowledgeNote>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function deleteKnowledgeNote(noteId: string, hard = false) {
return parseResponse<{ ok: boolean; note_id: string }>(
await apiFetch(
`${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
{ method: "DELETE" },
),
);
}
export async function searchKnowledge(query: string, limit = 8) {
const res = await parseResponse<{ items: KnowledgeSearchItem[] }>(
await apiFetch(`${baseUrl()}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, mode: "keyword", limit }),
}),
);
return res.items;
}
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
return parseResponse<{
note?: KnowledgeNote;
created?: boolean;
queued?: boolean;
}>(
await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
return parseResponse<BatchIngestResult>(
await apiFetch(`${baseUrl()}/ingest/threads/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
export async function resolveWikilinks(targets: string[]) {
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
await apiFetch(`${baseUrl()}/resolve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targets }),
}),
);
return res.resolved;
}
/** Resolve every `[[wikilink]]` inside a note's body in one call. */
export async function resolveNoteWikilinks(noteId: string) {
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
await apiFetch(
`${baseUrl()}/notes/${encodeURIComponent(noteId)}/wikilinks`,
),
);
return res.resolved;
}
/** Fetch a raw vault meta/hub Markdown page (no DB note) by its path. */
export async function fetchVaultPage(path: string) {
return parseResponse<VaultPage>(
await apiFetch(`${baseUrl()}/vault-page?path=${encodeURIComponent(path)}`),
);
}
export async function exportKnowledgeGraph() {
return parseResponse<GraphExportResult>(
await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }),
);
}
/** Absolute URL of a served wiki-export file (for iframe / download). */
export function knowledgeExportFileUrl(filename: string) {
return `${baseUrl()}/export/files/${encodeURIComponent(filename)}`;
}
// --- phase 2/3/4 ---
export interface KnowledgeVersion {
id: string;
note_id: string;
title: string;
summary?: string | null;
content_md: string;
status: string;
tags: string[];
edited_by?: string | null;
created_at?: string | null;
}
export async function listNoteVersions(noteId: string) {
const res = await parseResponse<{ items: KnowledgeVersion[] }>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}/versions`),
);
return res.items;
}
export interface DuplicateGroup {
primary: { id: string; title: string | null };
duplicates: Array<{ id: string; title: string | null; score: number }>;
}
export async function listDuplicates() {
const res = await parseResponse<{ groups: DuplicateGroup[] }>(
await apiFetch(`${baseUrl()}/duplicates`),
);
return res.groups;
}
export async function mergeKnowledgeNotes(primaryId: string, duplicateIds: string[]) {
return parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: duplicateIds }),
}),
);
}
export async function reindexKnowledge() {
return parseResponse<{ reindexed: number; embedding_enabled: number }>(
await apiFetch(`${baseUrl()}/reindex`, { method: "POST" }),
);
}
export interface KnowledgeGraph {
nodes: Array<{ id: string; type: string; label: string; category?: string | null }>;
edges: Array<{ id: string; source: string; target: string; label: string; weight: number }>;
}
export async function getKnowledgeGraph() {
return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`));
}
+125
View File
@@ -0,0 +1,125 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createKnowledgeNote,
deleteKnowledgeNote,
exportKnowledgeGraph,
fetchVaultPage,
getKnowledgeNote,
ingestThread,
ingestThreadsBatch,
listDuplicates,
listKnowledgeNotes,
mergeKnowledgeNotes,
resolveNoteWikilinks,
searchKnowledge,
updateKnowledgeNote,
} from "./api";
import type { KnowledgeListParams } from "./types";
const ROOT_KEY = ["knowledge"] as const;
export function useKnowledgeNotes(params: KnowledgeListParams = {}) {
return useQuery({
queryKey: [...ROOT_KEY, "list", params],
queryFn: () => listKnowledgeNotes(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeNote(noteId: string | undefined) {
return useQuery({
queryKey: [...ROOT_KEY, "note", noteId],
queryFn: () => getKnowledgeNote(noteId as string),
enabled: !!noteId,
refetchOnWindowFocus: false,
});
}
export function useNoteWikilinks(noteId: string | undefined) {
return useQuery({
queryKey: [...ROOT_KEY, "wikilinks", noteId],
queryFn: () => resolveNoteWikilinks(noteId as string),
enabled: !!noteId,
refetchOnWindowFocus: false,
});
}
export function useVaultPage(path: string | undefined) {
return useQuery({
queryKey: [...ROOT_KEY, "vault-page", path],
queryFn: () => fetchVaultPage(path as string),
enabled: !!path,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeSearch(query: string, enabled: boolean) {
return useQuery({
queryKey: [...ROOT_KEY, "search", query],
queryFn: () => searchKnowledge(query),
enabled: enabled && query.trim().length > 0,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ROOT_KEY });
return {
create: useMutation({ mutationFn: createKnowledgeNote, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({
noteId,
data,
}: {
noteId: string;
data: Parameters<typeof updateKnowledgeNote>[1];
}) => updateKnowledgeNote(noteId, data),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: ({ noteId, hard }: { noteId: string; hard?: boolean }) =>
deleteKnowledgeNote(noteId, hard),
onSuccess: invalidate,
}),
ingestThread: useMutation({
mutationFn: ({
threadId,
payload,
}: {
threadId: string;
payload?: Parameters<typeof ingestThread>[1];
}) => ingestThread(threadId, payload),
onSuccess: invalidate,
}),
batchIngest: useMutation({
mutationFn: ingestThreadsBatch,
onSuccess: invalidate,
}),
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
merge: useMutation({
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
mergeKnowledgeNotes(primaryId, duplicateIds),
onSuccess: invalidate,
}),
};
}
export function useKnowledgeDuplicates(enabled: boolean) {
return useQuery({
queryKey: [...ROOT_KEY, "duplicates"],
queryFn: listDuplicates,
enabled,
refetchOnWindowFocus: false,
});
}
+144
View File
@@ -0,0 +1,144 @@
export type KnowledgeStatus = "draft" | "approved" | "archived";
export type KnowledgeSourceType =
| "thread"
| "search"
| "tool"
| "file"
| "manual"
| "report";
export interface KnowledgeSource {
id: string;
note_id: string;
source_type: string;
thread_id?: string | null;
message_id?: string | null;
tool_name?: string | null;
title?: string | null;
url?: string | null;
snippet?: string | null;
created_at?: string | null;
}
export interface KnowledgeEntity {
name: string;
entity_type?: string | null;
}
export interface KnowledgeNote {
id: string;
title: string;
summary?: string | null;
content_md: string;
vault_path?: string | null;
source_type: string;
source_id?: string | null;
status: KnowledgeStatus;
confidence?: number;
created_by?: string | null;
updated_by?: string | null;
created_by_name?: string | null;
updated_by_name?: string | null;
created_at?: string | null;
updated_at?: string | null;
tags: string[];
entities?: KnowledgeEntity[];
sources?: KnowledgeSource[];
}
export interface KnowledgeListResponse {
items: KnowledgeNote[];
total: number;
}
export interface KnowledgeListParams {
keyword?: string;
tag?: string;
source_type?: string;
status?: string;
limit?: number;
offset?: number;
}
export type WikilinkKind = "note" | "vault" | "missing";
export interface WikilinkResolution {
target: string;
normalized?: string;
kind: WikilinkKind;
exists: boolean;
note_id?: string;
vault_path?: string;
title?: string;
}
/** Map of original `[[target]]` string → its resolution. */
export type WikilinkResolutionMap = Record<string, WikilinkResolution>;
export interface VaultPage {
path: string;
title: string;
content_md: string;
}
export interface KnowledgeSearchItem {
note_id: string;
chunk_id?: string | null;
title?: string | null;
summary?: string | null;
snippet?: string | null;
score: number;
tags: string[];
source_type?: string | null;
updated_at?: string | null;
}
export interface IngestThreadPayload {
mode?: "summary" | "full";
title?: string | null;
status?: KnowledgeStatus;
tags?: string[];
include_sources?: boolean;
/** When true the backend sediments asynchronously and returns immediately. */
background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null;
}
export interface BatchIngestPayload {
limit?: number;
offset?: number;
status?: KnowledgeStatus;
include_sources?: boolean;
skip_existing?: boolean;
mode?: "summary" | "full";
/** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null;
}
export interface BatchIngestResult {
ok: boolean;
processed: number;
created: number;
skipped: number;
failed: number;
/** Notes that fell back to rule-based extraction because the model failed. */
llm_fallback?: number;
items: Array<{
thread_id: string;
note_id: string | null;
status: string;
llm_fallback?: boolean;
}>;
}
export interface GraphExportResult {
ok: boolean;
files: {
graph_json: string;
graph_html: string;
graph_graphml: string;
cypher: string;
};
stats: { nodes: number; edges: number };
}
@@ -9,6 +9,7 @@
*/
import type { ElementType } from "react";
import {
BookOpen,
Bot,
Brain,
CalendarClock,
@@ -28,14 +29,13 @@ import {
ListChecks,
ListTodo,
MessageSquareCode,
MessageSquarePlus,
Network,
NotebookPen,
Plus,
Settings,
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -233,27 +233,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
id: "mgmt-private-square",
label: "私密广场配置",
icon: ShieldCheck,
path: "/page/strategy/settings/private-square",
adminOnly: true,
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
@@ -268,6 +247,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
@@ -295,6 +281,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-native",
label: "知识库",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
@@ -318,6 +310,7 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
+72
View File
@@ -430,6 +430,78 @@ export async function saveSkillFile(
return response.json() as Promise<SkillFileSaveResponse>;
}
export interface SkillNameZhResponse {
name: string;
name_zh: string | null;
}
export interface GenerateMissingSkillNameZhResponse {
generated: SkillNameZhResponse[];
failed: string[];
skipped: number;
/** Representative model error when some skills failed due to the LLM. */
model_error?: string | null;
}
/**
* Set a skill's Chinese display name. An empty string clears it (falls back to
* the English name).
*/
export async function setSkillNameZh(
skillName: string,
nameZh: string,
): Promise<SkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name_zh: nameZh }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to set skill Chinese name");
}
return response.json() as Promise<SkillNameZhResponse>;
}
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
export async function generateSkillNameZh(
skillName: string,
modelName?: string | null,
): Promise<SkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName ?? null }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
}
return response.json() as Promise<SkillNameZhResponse>;
}
/** One-click (admin): generate Chinese names for every skill that lacks one. */
export async function generateMissingSkillNameZh(
modelName?: string | null,
): Promise<GenerateMissingSkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName ?? null }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
}
return response.json() as Promise<GenerateMissingSkillNameZhResponse>;
}
export interface SetSkillDetailResponse {
name: string;
detail: string | null;
+37
View File
@@ -9,6 +9,8 @@ import {
deleteCustomSkill,
duplicateSkill,
enableSkill,
generateMissingSkillNameZh,
generateSkillNameZh,
getSkill,
getSkillContent,
getSkillFileContent,
@@ -17,6 +19,7 @@ import {
reconcileCustomSkills,
saveSkillFile,
setSkillDetail,
setSkillNameZh,
setSkillDisplay,
setSkillPinned,
setSkillFeatured,
@@ -90,6 +93,40 @@ export function useSetSkillDetail() {
});
}
export function useSetSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, nameZh }: { skillName: string; nameZh: string }) =>
setSkillNameZh(skillName, nameZh),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useGenerateSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, modelName }: { skillName: string; modelName?: string | null }) =>
generateSkillNameZh(skillName, modelName),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useGenerateMissingSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (modelName?: string | null) => generateMissingSkillNameZh(modelName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillDisplay() {
const queryClient = useQueryClient();
return useMutation({
+5
View File
@@ -42,6 +42,8 @@ export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "k
export interface Skill {
name: string;
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
name_zh?: string | null;
description: string;
category: string;
license: string;
@@ -49,6 +51,9 @@ export interface Skill {
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
// Publisher username (email local part) resolved from owner_user_id by the
// backend. Null for built-in / public skills with no human owner.
owner_username?: string | null;
published?: boolean;
builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
+116 -36
View File
@@ -621,15 +621,15 @@ export function useThreadStream({
lockThreadRagMode(threadId);
}
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
@@ -647,33 +647,33 @@ export function useThreadStream({
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
reasoning_effort:
mergedContext.reasoning_effort ??
(mergedContext.mode === "ultra"
? "high"
: mergedContext.mode === "pro"
? "medium"
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
reasoning_effort:
mergedContext.reasoning_effort ??
(mergedContext.mode === "ultra"
? "high"
: mergedContext.mode === "pro"
? "medium"
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -853,6 +853,86 @@ export function useDeleteMessage() {
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
* single user, so these live directly on the thread metadata (no join table).
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
* the toggle immediately, mirroring `useRenameThread`.
*/
export function useUpdateThreadMetadata() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
threadId,
metadata,
}: {
threadId: string;
metadata: Record<string, unknown>;
}) => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "更新对话失败。" }));
throw new Error(error.detail ?? "更新对话失败。");
}
},
onSuccess(_, { threadId, metadata }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.map((t) =>
t.thread_id === threadId
? {
...t,
metadata: {
...(t.metadata as Record<string, unknown> | null),
...metadata,
},
}
: t,
);
},
);
},
});
}
/** Toggle a conversation's pinned (置顶) flag. */
export function useToggleThreadPin() {
const { mutate, ...rest } = useUpdateThreadMetadata();
return {
...rest,
toggle: (threadId: string, pinned: boolean) =>
mutate({ threadId, metadata: { is_pinned: pinned } }),
};
}
/** Toggle a conversation's favorite (收藏) flag. */
export function useToggleThreadFavorite() {
const { mutate, ...rest } = useUpdateThreadMetadata();
return {
...rest,
toggle: (threadId: string, favorite: boolean) =>
mutate({ threadId, metadata: { is_favorite: favorite } }),
};
}
export function useRenameThread() {
const queryClient = useQueryClient();
const apiClient = getAPIClient();
+5 -3
View File
@@ -18,9 +18,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
agent_id?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
+22
View File
@@ -81,6 +81,28 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
return fallback;
}
export function isThreadPinned(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
}
export function isThreadFavorite(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
}
/**
* Stable sort that lifts pinned (置顶) threads to the top while preserving the
* incoming order (typically `updated_at` desc) within both the pinned and the
* unpinned group.
*/
export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
const pinned: AgentThread[] = [];
const rest: AgentThread[] = [];
for (const thread of threads) {
(isThreadPinned(thread) ? pinned : rest).push(thread);
}
return [...pinned, ...rest];
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface UserQaItem {
run_id: string;
thread_id: string;
thread_title: string;
assistant_id: string;
status: string;
model_name: string;
question: string;
answer: string;
total_tokens: number;
created_at: string;
updated_at: string;
}
export interface UserQaPageResponse {
user_id: string;
page: number;
page_size: number;
total: number;
items: UserQaItem[];
}
async function parse<T>(res: Response, what: string): Promise<T> {
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
}
return res.json() as Promise<T>;
}
export async function listMyQa(
params: { page?: number; pageSize?: number; q?: string } = {},
): Promise<UserQaPageResponse> {
const search = new URLSearchParams({
page: String(params.page ?? 1),
page_size: String(params.pageSize ?? 20),
});
const query = (params.q ?? "").trim();
if (query) search.set("q", query);
return parse(
await apiFetch(`${getBackendBaseURL()}/api/users/me/qa?${search}`),
"Failed to load my Q&A",
);
}
@@ -9,6 +9,7 @@ import {
Bot,
CalendarDays,
Crown,
ExternalLink,
FileDown,
Gauge,
Layers3,
@@ -17,6 +18,7 @@ import {
Medal,
MessageSquareText,
MousePointerClick,
RefreshCw,
Search,
Settings2,
Sparkles,
@@ -54,7 +56,7 @@ import type {
AdminLeaderboardUser,
} from "@/core/admin/api";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useRefreshTodayLeaderboard, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useModels } from "@/core/models/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
@@ -68,10 +70,10 @@ const LEADERBOARD_PAGE_SIZE = 20;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]",
bar: "bg-[#C1121F]",
border: "border-[#F1D6D6] dark:border-[#462123]",
soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
text: "text-[#C1121F] dark:text-[#7FACF2]",
bar: "bg-[#C1121F] dark:bg-[#2563EB]",
border: "border-[#F1D6D6] dark:border-[#213D5E]",
},
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -284,7 +286,7 @@ function StatCard({
}) {
const color = accentClasses[accent];
return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", color.border)}>
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
@@ -337,13 +339,13 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
return (
<section className="grid gap-3 md:grid-cols-5">
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div>
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{item.unit}</span>
<span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
</div>
))}
</section>
@@ -365,14 +367,14 @@ function GroupComparison({
const groups = buildGroupStats(users, rules);
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Users className="size-4 text-[#C1121F]" />
<Users className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
分组统计
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
</p>
</div>
@@ -380,12 +382,12 @@ function GroupComparison({
value={rulesText}
onChange={(event) => onRulesChange(event.target.value)}
placeholder="zero,two"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#19090A]"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#011226]"
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
{groups.map((group) => (
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#462123] dark:bg-[#19090A]">
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="font-semibold text-[#231815] dark:text-white">{group.name}</div>
<Badge variant="outline">{group.userCount} 人</Badge>
@@ -402,9 +404,9 @@ function GroupComparison({
key={user.user_id}
type="button"
onClick={() => onSelectUser(user)}
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#230D0F]"
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#02244C]"
>
<span className="font-semibold text-[#C1121F]">{index + 1}</span>
<span className="font-semibold text-[#C1121F] dark:text-[#7FACF2]">{index + 1}</span>
<span className="truncate">{user.email || user.user_id}</span>
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
</button>
@@ -438,8 +440,8 @@ function PodiumCard({
if (!user) {
return (
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#462123] dark:bg-[#230D0F]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#02244C]/70">
<div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
);
@@ -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]",
)}
>
<div className="flex items-start justify-between gap-3">
@@ -462,11 +464,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white shadow-sm">
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] text-base font-semibold text-white shadow-sm">
{initials(user)}
</div>
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#F2B8BB]">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{scoreLabel(user, tab)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>运行 {fmtNumber(user.run_count)}</span>
<span>问答 {fmtNumber(user.qa_count)}</span>
@@ -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 <div className="flex h-56 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
}
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]",
)}
>
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F]">全部趋势</span>
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
</span>
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#D8B8B8]">同时显示运行、问答、活跃与 Token</span>}
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
</button>
)}
{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
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : compact ? "text-base" : "text-lg")}>
{fmtNumber(totalFor(item.key))}
</span>
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>}
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
</button>
))}
</div>
@@ -583,15 +585,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
</DialogHeader>
<div className="shrink-0">{metricCards({ includeAll: true, large: true })}</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">当前查看</div>
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -679,7 +681,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return (
<div className="min-w-0 space-y-3">
{metricCards({ includeAll: false })}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
{analysisDialog(
"个人使用趋势",
@@ -693,7 +695,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return (
<div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -704,13 +706,13 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
)}
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -735,11 +737,11 @@ function UserTable({
}) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
if (users.length === 0) {
return <div className="py-14 text-center text-sm text-[#5F6368]">暂无用户统计</div>;
return <div className="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
}
return (
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]">
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
{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",
)}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
{rankOffset + index + 1}
</div>
<div className="min-w-0">
@@ -765,9 +767,9 @@ function UserTable({
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full bg-[#C1121F]"
className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
@@ -801,7 +803,7 @@ function SkillRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -810,7 +812,7 @@ function SkillRanking({
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 10).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -821,7 +823,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full"
style={{
@@ -859,7 +861,7 @@ function DimensionRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -868,7 +870,7 @@ function DimensionRanking({
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 8).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -879,7 +881,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full"
style={{
@@ -913,8 +915,8 @@ function QuestionPanel({
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
@@ -922,8 +924,8 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
@@ -942,8 +944,8 @@ function QuestionPanel({
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]">
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
<div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
</div>
);
@@ -971,7 +973,7 @@ function UserDetailPanel({
if (isLoading || !data) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368]">
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-4 animate-spin" />
加载用户详情
</div>
@@ -981,13 +983,13 @@ function UserDetailPanel({
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-[#231815] dark:text-white">
{data.user.email || data.user.user_id}
</div>
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
最近活跃 {fmtTime(data.user.last_active_at)}
</div>
</div>
@@ -996,7 +998,7 @@ function UserDetailPanel({
<ArrowRight className="ml-1 size-3.5" />
</Button>
</div>
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#462123] dark:bg-[#19090A]">
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#213D5E] dark:bg-[#011226]">
<Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} />
@@ -1016,7 +1018,7 @@ function UserDetailPanel({
</TabsContent>
<TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" />
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
个人使用趋势
</div>
<TrendBars points={data.trends} compact />
@@ -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 (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#F2B8BB]">
<Award className="size-4" />
管理员分析
{/* 整页统一滚动:标题与操作工具条不再固定置顶,随内容一起滚动,避免可视区域被压缩 */}
<ScrollArea className="min-h-0 flex-1">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-col gap-4">
{/* 标题维度:仅承载页面标题与说明 */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<Award className="size-4" />
管理员分析
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/chats/new")}
className="h-9"
className="h-9 shrink-0"
>
<ArrowLeft className="mr-1 size-4" />
返回系统
</Button>
</div>
{/* 操作维度:与标题区分开的控制工具条(操作按钮 + 时间筛选) */}
<div className="flex flex-wrap items-end justify-between gap-3 rounded-xl border border-[#F1D6D6] bg-white/70 px-3 py-3 dark:border-[#213D5E] dark:bg-[#02244C]/70">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -1261,6 +1271,30 @@ export default function AdminUserLeaderboardPage() {
>
<Settings2 className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={refreshToday.isPending}
onClick={() =>
refreshToday.mutate(
{ range: apiRange, limit: 100 },
{
onSuccess: () => toast.success("已刷新当天最新统计数据"),
onError: (err) =>
toast.error(err instanceof Error ? err.message : "刷新当天数据失败"),
},
)
}
className="h-9"
title="强制重算今天的统计,把最新的实时记录纳入榜单"
>
{refreshToday.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<RefreshCw className="mr-1 size-4" />
)}
{refreshToday.isPending ? "刷新中" : "刷新当天"}
</Button>
<Button
variant="outline"
size="sm"
@@ -1271,6 +1305,16 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open("#/public/leaderboard", "_blank", "noopener")}
className="h-9"
title="打开免登录的公开发布页"
>
<ExternalLink className="mr-1 size-4" />
公开页
</Button>
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={!data} className="h-9">
@@ -1279,16 +1323,16 @@ export default function AdminUserLeaderboardPage() {
</Button>
</DialogTrigger>
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#462123]">
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#213D5E]">
<DialogTitle>AI 榜单分析报告</DialogTitle>
<DialogDescription>
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]">
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#011226]">
<SelectValue placeholder="选择报告模型" />
</SelectTrigger>
<SelectContent>
@@ -1302,7 +1346,7 @@ export default function AdminUserLeaderboardPage() {
</Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">生成于 {fmtTime(reportGeneratedAt)}</span>}
</div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
@@ -1311,24 +1355,24 @@ export default function AdminUserLeaderboardPage() {
</div>
{data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F]" />
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
最近关注
</div>
<div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="font-medium text-[#C1121F]">{fmtTime(item.asked_at)}</span>
<span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
<span className="ml-1">{clampText(item.text, 58)}</span>
</div>
))}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" />
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
高频问题
</div>
<div className="space-y-2">
@@ -1337,34 +1381,34 @@ export default function AdminUserLeaderboardPage() {
{index + 1}. {clampText(item.text, 72)}
</div>
))}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F]" />
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
使用频率
</div>
<div className="space-y-2">
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
<div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="truncate">{item.name}</span>
<span className="shrink-0 font-semibold text-[#C1121F]">{fmtNumber(item.call_count)} 次</span>
<span className="shrink-0 font-semibold text-[#C1121F] dark:text-[#7FACF2]">{fmtNumber(item.call_count)} 次</span>
</div>
))}
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
</div>
)}
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#462123] dark:bg-[#19090A]">
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#213D5E] dark:bg-[#011226]">
{reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
</div>
)}
{reportGenerating && !reportText && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告,内容会流式显示
</div>
@@ -1375,13 +1419,13 @@ export default function AdminUserLeaderboardPage() {
</div>
)}
{reportText && (
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#230D0F] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#19090A] dark:[&_code]:bg-[#19090A] dark:[&_strong]:text-[#F2B8BB] dark:[&_th]:bg-[#19090A]">
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#02244C] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] dark:border-[#2563EB] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] dark:text-[#7FACF2] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#011226] dark:[&_code]:bg-[#011226] dark:[&_strong]:text-[#7FACF2] dark:[&_th]:bg-[#011226]">
<MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
</div>
)}
</ScrollArea>
{reportText && (
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#462123]">
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#213D5E]">
<Button
variant="outline"
onClick={exportReportAsWord}
@@ -1394,6 +1438,8 @@ export default function AdminUserLeaderboardPage() {
)}
</DialogContent>
</Dialog>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input
@@ -1402,7 +1448,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => 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]"
/>
</div>
<div className="flex flex-col gap-1">
@@ -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]"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1425,19 +1471,19 @@ export default function AdminUserLeaderboardPage() {
<Button variant="outline" size="sm" onClick={() => setQuickRange(90)}>
近 90 天
</Button>
</div>
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
{isLoading && (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span>正在统计排行榜数据</span>
</div>
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
</div>
</div>
@@ -1450,12 +1496,12 @@ export default function AdminUserLeaderboardPage() {
)}
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white">
<Loader2 className="size-4 animate-spin text-[#C1121F]" />
<Loader2 className="size-4 animate-spin text-[#C1121F] dark:text-[#7FACF2]" />
<span>排行榜正在后台统计</span>
</div>
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
</div>
</div>
@@ -1495,7 +1541,7 @@ export default function AdminUserLeaderboardPage() {
/>
)}
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -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]"
/>
</div>
</div>
@@ -1558,8 +1604,8 @@ export default function AdminUserLeaderboardPage() {
</div>
</section>
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div>
@@ -1575,7 +1621,7 @@ export default function AdminUserLeaderboardPage() {
</section>
</div>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -61,17 +61,17 @@ export default function AdminUserLeaderboardSettingsPage() {
};
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A]">
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<SlidersHorizontal className="size-4" />
管理员设置
</div>
<h1 className="text-2xl font-semibold">排行榜设置</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
</p>
</div>
@@ -86,14 +86,14 @@ export default function AdminUserLeaderboardSettingsPage() {
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-6xl space-y-5 p-6">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<ShieldOff className="size-4 text-[#C1121F]" />
<ShieldOff className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
统计排除名单
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
</p>
</div>
@@ -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 && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#462123] dark:bg-[#230D0F]">
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#213D5E] dark:bg-[#02244C]">
<ScrollArea className="max-h-72">
{candidates.map((user) => (
<button
key={user.id}
type="button"
onClick={() => addUser(user.id)}
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#462123] dark:hover:bg-[#361315]"
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#213D5E] dark:hover:bg-[#361315]"
>
<span className="min-w-0">
<span className="block truncate font-medium">{user.email}</span>
<span className="block truncate text-xs text-[#8B6B6B]">{user.id}</span>
<span className="block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{user.id}</span>
</span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button>
@@ -134,15 +134,15 @@ export default function AdminUserLeaderboardSettingsPage() {
</div>
)}
</div>
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]">
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#011226]">
{selectedUsers.length === 0 ? (
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span>
<span className="text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无排除账号</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#462123] dark:bg-[#230D0F] dark:text-[#F2B8BB]"
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]"
title={userLabel(user)}
>
<span className="truncate">{userLabel(user)}</span>
@@ -157,14 +157,14 @@ export default function AdminUserLeaderboardSettingsPage() {
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<Save className="size-4 text-[#C1121F]" />
<Save className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
统计规则
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
</p>
</div>
@@ -180,11 +180,11 @@ export default function AdminUserLeaderboardSettingsPage() {
].map(([key, title, desc]) => (
<label
key={key}
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]"
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#213D5E] dark:bg-[#011226]"
>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#D8B8B8]">{desc}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#7FACF2]">{desc}</span>
</span>
<Switch
checked={Boolean(settings?.[key as keyof typeof settings])}
+113 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -15,6 +15,8 @@ import {
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { KnowledgeRagToggle } from "@/components/workspace/knowledge-rag-toggle";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@@ -41,9 +43,47 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
const BROWSER_CONTEXT_PARAM_KEYS = [
"browser_context",
"bc_filename",
"bc_size",
"bc_path",
"bc_prompt",
] as const;
function clearBrowserContextParamsFromHash() {
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash.startsWith("#")) return;
const hashBody = hash.slice(1);
const queryStart = hashBody.indexOf("?");
if (queryStart < 0) return;
const path = hashBody.slice(0, queryStart);
const search = hashBody.slice(queryStart + 1);
const params = new URLSearchParams(search);
let changed = false;
for (const key of BROWSER_CONTEXT_PARAM_KEYS) {
if (params.has(key)) {
params.delete(key);
changed = true;
}
}
if (!changed) return;
const nextSearch = params.toString();
const nextHash = nextSearch ? `#${path}?${nextSearch}` : `#${path}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
}
export default function ChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -62,6 +102,7 @@ export default function ChatPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentHandoffPending, setAgentHandoffPending] = useState(false);
const agentHandoffPendingRef = useRef(false);
const browserContextAutoSubmitRef = useRef<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
const { selectedArtifact } = useArtifacts();
@@ -242,6 +283,75 @@ export default function ChatPage() {
[sendMessage, threadId],
);
useEffect(() => {
const token = searchParams.get("browser_context")?.trim();
if (!token || browserContextAutoSubmitRef.current === token) {
return;
}
if (!threadId || isNewThread || thread.isLoading || agentHandoffPending) {
return;
}
const consumedKey = `deerflow.browser-context.consumed.${token}`;
if (window.sessionStorage.getItem(consumedKey)) {
browserContextAutoSubmitRef.current = token;
clearBrowserContextParamsFromHash();
return;
}
const filename = searchParams.get("bc_filename")?.trim();
const path = searchParams.get("bc_path")?.trim();
const size = Number(searchParams.get("bc_size") ?? "0");
const prompt =
searchParams.get("bc_prompt")?.trim() ||
"请基于刚刚发送的网页内容,先用中文总结要点,并等待我继续提问。";
if (!filename || !path || !Number.isFinite(size) || size < 0) {
browserContextAutoSubmitRef.current = token;
clearBrowserContextParamsFromHash();
toast.error("网页上下文参数不完整,无法自动发送到 Deer");
return;
}
browserContextAutoSubmitRef.current = token;
window.sessionStorage.setItem(consumedKey, "1");
clearBrowserContextParamsFromHash();
setHasStartedConversation(true);
void sendMessage(
threadId,
{ text: prompt, files: [] },
undefined,
{
additionalKwargs: {
browser_context: {
source: "chrome-extension",
token,
},
files: [
{
filename,
size,
path,
status: "uploaded",
},
],
},
},
).catch((err) => {
toast.error(
err instanceof Error ? err.message : "网页上下文发送失败,请重试",
);
});
}, [
searchParams,
threadId,
isNewThread,
thread.isLoading,
agentHandoffPending,
sendMessage,
]);
// Persist the picked agent the moment it changes so the next new chat
// defaults to it — even if the user never sends a message this session.
// Passing null (the ✕ clear) wipes the stored preference.
@@ -300,7 +410,9 @@ export default function ChatPage() {
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<KnowledgeRagToggle threadId={threadId} />
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
+256 -95
View File
@@ -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 (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
))}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}
"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 (
<div
key={thread.thread_id}
className="group/chat-row relative flex flex-col gap-2 border-b"
>
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
<div className="flex items-center gap-1.5">
{pinned && (
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</div>
{thread.updated_at && (
<div className="text-muted-foreground mt-2 text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</Link>
{/* Row actions — kept out of the Link so clicks don't navigate. */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
className="size-8"
title={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={cn(
"size-4",
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
title={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
<PinOff className="size-4 text-muted-foreground" />
) : (
<Pin className="size-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
);
};
const isEmpty = filteredThreads.length === 0;
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
variant={onlyFavorites ? "outline" : "secondary"}
size="sm"
onClick={() => setOnlyFavorites(false)}
>
{t.chats.allChats}
</Button>
<Button
variant={onlyFavorites ? "secondary" : "outline"}
size="sm"
onClick={() => setOnlyFavorites(true)}
>
<Star className="size-4" />
{t.chats.favorites}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{currentPage === 1 && pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
</div>
{pinnedThreads.map(renderThread)}
</>
)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 py-6">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() =>
setPage((p) => Math.min(totalPages, p + 1))
}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}
@@ -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<string, string> = {
thread: "对话",
search: "检索",
tool: "工具",
file: "文件",
manual: "手动",
report: "报告",
};
const STATUS_LABELS: Record<string, string> = {
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<string, { label: string; className: string }> = {
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<T>(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 (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border/70 hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
</div>
{note.summary ? (
<span className="text-muted-foreground line-clamp-2 text-xs">
{note.summary}
</span>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1">
{note.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
>
#{tag}
</span>
))}
<span className="text-muted-foreground ml-auto text-[10px]">
{note.updated_at?.slice(0, 10)}
</span>
</div>
</button>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>新建知识</DialogTitle>
<DialogDescription>手动创建一条知识笔记,写入共享知识库 vault。</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder="标题"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,使用逗号分隔"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<Textarea
placeholder="Markdown 正文"
rows={12}
value={content}
onChange={(e) => setContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleSubmit()} disabled={create.isPending}>
{create.isPending ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BatchImportDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { batchIngest } = useKnowledgeActions();
const [limit, setLimit] = useState(50);
const [skipExisting, setSkipExisting] = useState(true);
// Optional model override for extraction (null = configured default).
const [model, setModel] = useState<string | null>(null);
const handleRun = async () => {
try {
const result = await batchIngest.mutateAsync({
limit,
skip_existing: skipExisting,
status: "approved",
include_sources: true,
model_name: model,
});
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
if (result.llm_fallback && result.llm_fallback > 0) {
// The model failed on some threads — they were saved via rule-based
// extraction instead. Say so explicitly per the requirement.
toast.warning(
`${base};其中 ${result.llm_fallback} 条因大模型异常改用了规则抽取(草稿)`,
);
} else {
toast.success(base);
}
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量导入失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>批量导入历史线程</DialogTitle>
<DialogDescription>
扫描你最近的对话线程并沉淀为知识库内容。已沉淀的线程可自动跳过。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<label className="flex items-center justify-between gap-2 text-sm">
处理数量上限
<Input
type="number"
min={1}
max={500}
value={limit}
onChange={(e) => setLimit(Number(e.target.value) || 1)}
className="w-28"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
跳过已沉淀线程
<input
type="checkbox"
checked={skipExisting}
onChange={(e) => setSkipExisting(e.target.checked)}
className="size-4"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
提炼大模型
<ModelNameSelect
value={model}
onChange={setModel}
disabled={batchIngest.isPending}
triggerClassName="h-9 w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleRun()} disabled={batchIngest.isPending}>
{batchIngest.isPending ? "导入中..." : "开始导入"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DedupDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: groups = [], isLoading } = useKnowledgeDuplicates(open);
const { merge } = useKnowledgeActions();
const handleMerge = async (primaryId: string, duplicateIds: string[]) => {
try {
await merge.mutateAsync({ primaryId, duplicateIds });
toast.success("已合并重复知识");
} catch (error) {
toast.error(error instanceof Error ? error.message : "合并失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>查重与合并</DialogTitle>
<DialogDescription>
基于标题/摘要相似度与内容哈希检测重复知识;合并会把重复项归档并把来源并入主条目。
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto">
{isLoading ? (
<div className="text-muted-foreground p-4 text-sm">检测中...</div>
) : groups.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
未发现明显重复的知识。
</div>
) : (
<ul className="flex flex-col gap-3">
{groups.map((g) => (
<li key={g.primary.id} className="rounded-md border p-3">
<div className="mb-2 text-sm font-medium">
保留:{g.primary.title || g.primary.id}
</div>
<ul className="mb-2 flex flex-col gap-1">
{g.duplicates.map((d) => (
<li key={d.id} className="text-muted-foreground text-xs">
合并:{d.title || d.id}(相似度 {Math.round(d.score * 100)}%)
</li>
))}
</ul>
<Button
size="sm"
variant="outline"
onClick={() =>
void handleMerge(
g.primary.id,
g.duplicates.map((d) => d.id),
)
}
disabled={merge.isPending}
>
合并到主条目
</Button>
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function VaultPageDialog({
path,
onOpenChange,
}: {
path: string | null;
onOpenChange: (open: boolean) => void;
}) {
const { data: page, isLoading } = useVaultPage(path ?? undefined);
return (
<Dialog open={!!path} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{page?.title || "知识库页面"}</DialogTitle>
<DialogDescription>{path}</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="p-1">
{isLoading ? (
<div className="text-muted-foreground p-4 text-sm">加载中...</div>
) : page ? (
<MarkdownRenderer content={page.content_md} />
) : (
<div className="text-muted-foreground p-4 text-sm">页面不存在</div>
)}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NoteDetail({ noteId }: { noteId: string }) {
const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
const { update, remove } = useKnowledgeActions();
const [vaultPath, setVaultPath] = useState<string | null>(null);
const handleWikiLink = (link: WikiLinkTarget) => {
if (link.kind === "note" && link.note_id) {
navigate(`/page/workspace/knowledge/${link.note_id}`);
} else if (link.kind === "vault" && link.vault_path) {
setVaultPath(link.vault_path);
}
};
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftTags, setDraftTags] = useState("");
const [draftContent, setDraftContent] = useState("");
const [draftStatus, setDraftStatus] = useState("approved");
useEffect(() => {
setEditing(false);
setVaultPath(null);
}, [noteId]);
useEffect(() => {
if (note) {
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
}
}, [note]);
if (isLoading || !note) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
{isLoading ? "加载中..." : "知识不存在"}
</div>
);
}
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2">
<Badge variant="outline">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
<X className="size-4" /> 取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
<Save className="size-4" /> 保存
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="size-4" /> 编辑
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => void handleArchive()}
disabled={remove.isPending}
>
<Trash2 className="size-4" /> 归档
</Button>
</>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={20}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
{note.tags.length > 0 ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{note.tags.map((tag) => (
<span
key={tag}
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
>
#{tag}
</span>
))}
</div>
) : null}
{note.entities && note.entities.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownRenderer
content={note.content_md}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-6 border-t pt-4">
<h2 className="mb-2 text-sm font-semibold">来源</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="text-primary inline-flex items-center gap-1 hover:underline"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span>{src.title || src.tool_name || "来源"}</span>
)}
{src.snippet ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{note.updated_at?.slice(0, 19).replace("T", " ")}
</div>
</>
)}
</div>
</ScrollArea>
<VaultPageDialog
path={vaultPath}
onOpenChange={(open) => {
if (!open) setVaultPath(null);
}}
/>
</div>
);
}
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 50,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex flex-wrap items-center gap-2 border-b p-3">
<BookOpen className="size-5" />
<h1 className="mr-2 text-base font-semibold">知识库</h1>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 摘要 / 正文 / 标签"
className="w-64 pl-8"
/>
</div>
<Select value={sourceType} onValueChange={setSourceType}>
<SelectTrigger className="w-28">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
<SelectItem key={v} value={v}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-28">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
<Copy className="size-4" /> 查重
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/knowledge/graph")}
>
<Network className="size-4" /> 图谱
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> 新建
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r">
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-2 p-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : notes.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无知识。点击「新建」或在对话页「保存到知识库」。
</div>
) : (
notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
active={note.id === noteId}
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
/>
))
)}
</div>
</ScrollArea>
{data ? (
<div className="text-muted-foreground border-t p-2 text-center text-xs">
共 {data.total} 条知识
</div>
) : null}
</div>
<div className="min-h-0">
{noteId ? (
<NoteDetail noteId={noteId} />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
从左侧选择一条知识查看详情
</div>
)}
</div>
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
);
}
@@ -0,0 +1,79 @@
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { exportGraph } = useKnowledgeActions();
const [src, setSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const regenerate = useCallback(async () => {
try {
const result = await exportGraph.mutateAsync();
setStats(result.stats);
// Cache-bust the iframe so it reflects the freshly generated graph.
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成图谱失败");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void regenerate();
}, [regenerate]);
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b p-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/page/workspace/knowledge")}
>
<ArrowLeft className="size-4" /> 返回
</Button>
<h1 className="text-base font-semibold">知识图谱</h1>
{stats ? (
<span className="text-muted-foreground text-xs">
{stats.nodes} 个节点 · {stats.edges} 条关系
</span>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => void regenerate()}
disabled={exportGraph.isPending}
>
{exportGraph.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
重新生成
</Button>
</header>
<div className="min-h-0 flex-1">
{src ? (
<iframe
src={src}
title="知识图谱"
className="size-full border-0"
sandbox="allow-scripts allow-same-origin"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
</div>
)}
</div>
</div>
);
}
+4 -1
View File
@@ -105,6 +105,7 @@ export default function NewAgentPage() {
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
@@ -590,7 +591,9 @@ export default function NewAgentPage() {
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
<span className="truncate">
{skill.name_zh?.trim() ? skill.name_zh : skill.name}
</span>
</Button>
<Button
type="button"
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PrivateSquareSettingsPage() {
export default function PrivateSquareSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePrivateSquareStatus();
const update = useUpdatePrivateSquareSettings();
const { theme } = useTheme();
@@ -95,9 +99,15 @@ export default function PrivateSquareSettingsPage() {
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className="text-xl font-semibold">私密广场配置</h1>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>私密广场配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PromptPrefixSettingsPage() {
export default function PromptPrefixSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme();
@@ -83,9 +87,15 @@ export default function PromptPrefixSettingsPage() {
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className="text-xl font-semibold">问答前缀配置</h1>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
@@ -0,0 +1,947 @@
"use client";
import {
Activity,
Award,
BarChart3,
Bot,
CalendarDays,
Crown,
Gauge,
Layers3,
Loader2,
Maximize2,
Medal,
MessageSquareText,
MousePointerClick,
Search,
Sparkles,
TrendingUp,
Users,
type LucideIcon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
const LEADERBOARD_PAGE_SIZE = 20;
const RANGE_DAYS = 30;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
text: "text-[#C1121F] dark:text-[#7FACF2]",
bar: "bg-[#C1121F] dark:bg-[#2563EB]",
border: "border-[#F1D6D6] dark:border-[#213D5E]",
},
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
text: "text-[#E11D48] dark:text-rose-200",
bar: "bg-[#E11D48]",
border: "border-[#FECDD3] dark:border-rose-300/25",
},
amber: {
soft: "bg-[#FFFBEB] dark:bg-amber-300/15",
text: "text-[#B45309] dark:text-amber-100",
bar: "bg-[#F59E0B]",
border: "border-[#FDE68A] dark:border-amber-200/25",
},
teal: {
soft: "bg-[#ECFDF5] dark:bg-teal-300/15",
text: "text-[#0F766E] dark:text-teal-100",
bar: "bg-[#0F766E]",
border: "border-[#A7F3D0] dark:border-teal-200/25",
},
};
function fmtNumber(value?: number | null): string {
return Number(value ?? 0).toLocaleString();
}
function fmtTime(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function fmtDate(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false });
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
if (tab === "skills") return user.skill_call_count;
if (tab === "efficiency") {
return user.run_count > 0 ? Math.round((user.qa_count / user.run_count) * 100) : 0;
}
return user.run_count + user.tool_call_count;
}
function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string {
if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`;
if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`;
if (tab === "skills") return `${fmtNumber(user.skill_call_count)} 次技能`;
if (tab === "efficiency") {
return `${(user.run_count > 0 ? user.qa_count / user.run_count : 0).toFixed(1)} 问答/运行`;
}
return `${fmtNumber(userScore(user, tab))} 次使用`;
}
function rankTabDescription(tab: UserRankTab): string {
if (tab === "questions") return "问答排行按用户提出有效问题的运行次数排序。";
if (tab === "tokens") return "消耗排行按模型输入与输出 Token 总量排序。";
if (tab === "skills") return "技能排行按带有技能名称的工具调用次数排序。";
if (tab === "efficiency") return "效率排行 = 问答次数 / 运行次数,用来衡量每次运行承载的问答密度。";
return "使用排行按运行次数与工具调用次数合计排序。";
}
function initials(user: AdminLeaderboardUser): string {
const source = user.email || user.user_id || "?";
return source.slice(0, 1).toUpperCase();
}
async function fetchPublicLeaderboard(days: number): Promise<AdminLeaderboardResponse> {
const res = await fetch(
`${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=100`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `加载排行榜失败 (HTTP ${res.status})`);
}
return (await res.json()) as AdminLeaderboardResponse;
}
export default function PublicLeaderboardPage() {
const [data, setData] = useState<AdminLeaderboardResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [userPage, setUserPage] = useState(1);
const days = RANGE_DAYS;
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchPublicLeaderboard(days)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
const pending = data?.status === "running" || data?.status === "refreshing";
// The snapshot may still be computing on first load — poll briefly until ready.
useEffect(() => {
if (!pending) return;
const timer = setInterval(() => {
fetchPublicLeaderboard(days)
.then((res) => setData(res))
.catch(() => undefined);
}, 3000);
return () => clearInterval(timer);
}, [pending, days]);
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
const source =
tab === "questions"
? data?.users_by_questions
: tab === "tokens"
? data?.users_by_tokens
: tab === "skills"
? [...base].sort((a, b) => b.skill_call_count - a.skill_call_count)
: tab === "efficiency"
? [...base].sort((a, b) => userScore(b, "efficiency") - userScore(a, "efficiency"))
: data?.users_by_activity;
const needle = query.trim().toLowerCase();
return (source ?? []).filter((user) =>
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
);
}, [data, query, tab]);
const overview = data?.overview;
const hasRows = rankedUsers.length > 0 || (overview?.total_runs ?? 0) > 0 || (overview?.total_users ?? 0) > 0;
const topUsers = rankedUsers.slice(0, 3);
const userPageCount = Math.max(1, Math.ceil(rankedUsers.length / LEADERBOARD_PAGE_SIZE));
const visibleUsers = useMemo(() => {
const start = (userPage - 1) * LEADERBOARD_PAGE_SIZE;
return rankedUsers.slice(start, start + LEADERBOARD_PAGE_SIZE);
}, [rankedUsers, userPage]);
useEffect(() => {
setUserPage(1);
}, [query, tab]);
useEffect(() => {
setUserPage((page) => Math.min(page, userPageCount));
}, [userPageCount]);
return (
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{/* 标题区:仅展示,不含任何操作按钮 */}
<header className="mb-6">
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<Award className="size-4" />
公开榜单
</div>
<h1 className="text-2xl font-semibold tracking-normal sm:text-3xl">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗、技能与工具热度、模型与 Agent 使用及近期问题。
{data?.since && data?.until && (
<span className="ml-1">
({fmtDate(data.since).slice(0, 10)} ~ {fmtDate(data.until).slice(0, 10)})
</span>
)}
</p>
</header>
{loading && !data && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-5 animate-spin" />
正在加载排行榜数据
</div>
)}
{error && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-5 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
加载失败:{error}
</div>
)}
{data && overview && (
<div className="space-y-6">
{/* 仅在首次统计、尚无数据时提示;已有数据时后台静默刷新,不再常驻横幅 */}
{pending && !hasRows && (
<div className="rounded-lg border border-[#F1D6D6] bg-white px-4 py-3 text-sm text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]">
<Loader2 className="mr-1 inline size-4 animate-spin" />
排行榜正在后台统计,数据会自动刷新。
</div>
)}
{/* 概览指标 */}
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
<StatCard icon={MessageSquareText} accent="amber" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</section>
{/* 衍生洞察 */}
<InsightStrip overview={overview} />
{/* 榜单前三 */}
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">{rankTabDescription(tab)}</p>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList>
<TabsTrigger value="activity">使用排行</TabsTrigger>
<TabsTrigger value="questions">问答排行</TabsTrigger>
<TabsTrigger value="tokens">消耗排行</TabsTrigger>
<TabsTrigger value="skills">技能排行</TabsTrigger>
<TabsTrigger value="efficiency">效率排行</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="grid gap-3 lg:grid-cols-3">
<PodiumCard user={topUsers[0]} rank={1} tab={tab} />
<PodiumCard user={topUsers[1]} rank={2} tab={tab} />
<PodiumCard user={topUsers[2]} rank={3} tab={tab} />
</div>
</section>
{/* 完整排行榜 */}
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" />
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
<p className="mt-1 text-xs text-[#6B7280] dark:text-slate-400">{rankTabDescription(tab)}</p>
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户"
className="h-9 w-56 bg-white pl-8 dark:bg-[#011226]"
/>
</div>
</div>
<UserTable users={visibleUsers} tab={tab} rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE} />
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
<span>
共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页
</span>
<Pagination
page={userPage}
pageSize={LEADERBOARD_PAGE_SIZE}
total={rankedUsers.length}
onPageChange={setUserPage}
/>
</div>
</section>
{/* 时间趋势 */}
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">时间趋势</h2>
</div>
{data.since && data.until && (
<div className="flex items-center gap-2 text-xs text-[#5F6368] dark:text-slate-400">
<CalendarDays className="size-3.5" />
{fmtDate(data.since).slice(0, 10)} 至 {fmtDate(data.until).slice(0, 10)}
</div>
)}
</div>
<TrendBars points={data.trends} />
</section>
{/* 技能与工具热度 */}
<div className="grid gap-5 xl:grid-cols-2">
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
</div>
{/* 模型 / Agent / 运行状态 */}
<div className="grid gap-5 xl:grid-cols-3">
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
{/* 问题 */}
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</div>
)}
<footer className="mt-10 border-t border-[#F1D6D6] pt-4 text-center text-xs text-[#8B6B6B] dark:border-[#213D5E] dark:text-[#7FACF2]">
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
</footer>
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
note,
accent,
}: {
icon: LucideIcon;
label: string;
value: string;
note: string;
accent: LeaderboardAccent;
}) {
const color = accentClasses[accent];
return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
<Icon className="size-4" />
</span>
</div>
<div className="mt-3 text-2xl font-semibold tracking-normal text-[#202124] tabular-nums dark:text-white">
{value}
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">{note}</div>
</div>
);
}
function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
const activeUsers = Math.max(1, overview.active_users);
const totalRuns = Math.max(1, overview.total_runs);
const items = [
{
label: "人均使用",
value: overview.total_runs / activeUsers,
unit: "次/活跃用户",
description: "当前时间段内,平均每位活跃用户发起了多少次运行。",
},
{
label: "问答密度",
value: overview.total_questions / totalRuns,
unit: "问答/运行",
description: "问答次数 ÷ 运行次数,用来判断每次使用承载了多少有效提问。",
},
{
label: "平均 Token",
value: overview.total_tokens / totalRuns,
unit: "Token/运行",
description: "总 Token ÷ 运行次数,反映单次使用的模型消耗强度。",
},
{
label: "技能覆盖",
value: overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0,
unit: "%",
description: "技能调用 ÷ 工具总调用,越高说明用户越多依赖已配置技能能力。",
},
{
label: "失败率",
value: (overview.error_runs / totalRuns) * 100,
unit: "%",
description: "失败运行 ÷ 总运行,用来快速发现稳定性或配置问题。",
},
];
return (
<section className="grid gap-3 md:grid-cols-5">
{items.map((item) => (
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
</div>
))}
</section>
);
}
function PodiumCard({
user,
rank,
tab,
}: {
user?: AdminLeaderboardUser;
rank: 1 | 2 | 3;
tab: UserRankTab;
}) {
const styles = {
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
2: "border-[#E5E7EB] bg-[#F8FAFC] text-[#4B5563] dark:border-[#6B7280]/40 dark:bg-white/5 dark:text-[#E5E7EB]",
3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
}[rank];
if (!user) {
return (
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#02244C]/70">
<div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
);
}
return (
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-start justify-between gap-3">
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium", styles)}>
{rank === 1 ? <Crown className="size-3.5" /> : <Medal className="size-3.5" />}
第 {rank} 名
</span>
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] text-base font-semibold text-white shadow-sm">
{initials(user)}
</div>
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{scoreLabel(user, tab)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>运行 {fmtNumber(user.run_count)}</span>
<span>问答 {fmtNumber(user.qa_count)}</span>
<span>技能 {fmtNumber(user.skill_call_count)}</span>
</div>
</div>
</div>
);
}
function UserTable({
users,
tab,
rankOffset = 0,
}: {
users: AdminLeaderboardUser[];
tab: UserRankTab;
rankOffset?: number;
}) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
if (users.length === 0) {
return <div className="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
}
return (
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
{users.map((user, index) => {
const score = userScore(user, tab);
return (
<div
key={user.user_id}
className="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 dark:border-[#213D5E]"
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
{rankOffset + index + 1}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-[#202124] dark:text-white">{user.email || user.user_id}</span>
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
最近活跃 {fmtTime(user.last_active_at)}
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
会话 {fmtNumber(user.thread_count)} · LLM {fmtNumber(user.llm_call_count)}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-[#202124] tabular-nums dark:text-white">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
{fmtNumber(user.total_tokens)} tokens
</div>
</div>
</div>
);
})}
</div>
);
}
function SkillRanking({
title,
icon: Icon,
items,
empty,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 10).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
<span className="text-xs text-[#5F6368] dark:text-slate-400">
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full"
style={{
width: `${Math.max(4, (item.call_count / max) * 100)}%`,
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
}}
/>
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold tabular-nums text-[#202124] dark:text-white">
{fmtNumber(item.call_count)}
</div>
<div className="text-xs text-[#5F6368] dark:text-slate-400">
失败 {fmtNumber(item.error_count)}
</div>
</div>
</div>
))}
</div>
</section>
);
}
function DimensionRanking({
title,
icon: Icon,
items,
empty,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardDimension[];
empty: string;
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 8).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
<span className="text-xs text-[#5F6368] dark:text-slate-400">
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-2 rounded-full"
style={{
width: `${Math.max(4, (item.count / max) * 100)}%`,
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
}}
/>
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
成功 {fmtNumber(item.success_count)} · 失败 {fmtNumber(item.error_count)} · {fmtNumber(item.total_tokens)} tokens
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(item.count)}</div>
<div className="text-xs text-[#5F6368] dark:text-slate-400">次</div>
</div>
</div>
))}
</div>
</section>
);
}
function QuestionPanel({
title,
items,
}: {
title: string;
items: AdminLeaderboardQuestion[];
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>{item.email || item.user_id || "未知用户"}</span>
<span>{fmtTime(item.asked_at)}</span>
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, "");
if (points.length === 0) {
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
}
const visible = points.slice(-32);
const series = [
{ 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];
const metricMax = Math.max(1, ...visible.map((point) => Number(point[activeMetric] ?? 0)));
const latest = visible[visible.length - 1];
const previous = visible[visible.length - 2];
const latestValue = Number(latest?.[activeMetric] ?? 0);
const previousValue = Number(previous?.[activeMetric] ?? 0);
const delta = latestValue - previousValue;
const width = 920;
const height = 280;
const padding = { top: 22, right: 28, bottom: 36, left: 48 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const xFor = (index: number) =>
padding.left + (visible.length <= 1 ? chartWidth / 2 : (index / (visible.length - 1)) * chartWidth);
const yFor = (value: number, max: number) => padding.top + chartHeight - (value / max) * chartHeight;
const gridLines = [0, 0.25, 0.5, 0.75, 1];
const selectedPath = visible
.map((point, index) => {
const value = Number(point[activeMetric] ?? 0);
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, metricMax).toFixed(2)}`;
})
.join(" ");
const selectedArea = `${selectedPath} L ${xFor(visible.length - 1).toFixed(2)} ${padding.top + chartHeight} L ${xFor(0).toFixed(2)} ${padding.top + chartHeight} Z`;
const peak = visible.reduce(
(best, point) => (Number(point[activeMetric] ?? 0) > Number(best[activeMetric] ?? 0) ? point : best),
visible[0],
);
const displayedSeries = metric === "all" ? series : [activeSeries];
const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
const metricCards = ({ large = false }: { large?: boolean } = {}) => (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<button
type="button"
onClick={() => setMetric("all")}
className={cn(
"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] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
)}
>
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
</span>
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
</button>
{series.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setMetric(item.key)}
className={cn(
"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] 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}
>
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : "text-lg")}>
{fmtNumber(totalFor(item.key))}
</span>
{large && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
</button>
))}
</div>
);
const analysisDialog = (title: string, description: ReactNode) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Maximize2 className="mr-1 size-4" />
放大分析
</Button>
</DialogTrigger>
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-4 overflow-hidden p-5 sm:max-w-none">
<DialogHeader className="shrink-0 pr-10">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="shrink-0">{metricCards({ large: true })}</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
{chart(true)}
</div>
</DialogContent>
</Dialog>
);
const chart = (large = false) => (
<div className={cn("min-w-0 overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#3F3F46] dark:bg-[#18181B]", large && "flex min-h-0 p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-full min-h-[420px]" : "h-72")} role="img" aria-label="使用趋势折线图">
<defs>
<linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
<stop offset="100%" stopColor={activeSeries.color} stopOpacity="0.02" />
</linearGradient>
</defs>
{gridLines.map((ratio) => {
const y = padding.top + chartHeight * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1"
/>
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
{fmtNumber(Math.round(metricMax * (1 - ratio)))}
</text>
</g>
);
})}
{metric !== "all" && <path d={selectedArea} fill={`url(#trend-area-${chartId}-${activeMetric})`} />}
{displayedSeries.map((item) => {
const max = Math.max(1, ...visible.map((point) => Number(point[item.key] ?? 0)));
const path = visible
.map((point, index) => {
const value = Number(point[item.key] ?? 0);
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, max).toFixed(2)}`;
})
.join(" ");
return (
<g key={item.key}>
<path d={path} fill="none" stroke={item.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" opacity={metric === "all" ? 0.9 : 1} />
{metric !== "all" &&
visible.map((point, index) => {
const value = Number(point[item.key] ?? 0);
const isPeak = point.date === peak.date;
const isLast = index === visible.length - 1;
return (
<circle
key={`${item.key}-${point.date}`}
cx={xFor(index)}
cy={yFor(value, max)}
r={isPeak || isLast ? 5 : 3}
fill="#fff"
stroke={item.color}
strokeWidth={isPeak || isLast ? "3" : "2"}
>
<title>{`${point.date} ${item.label}: ${fmtNumber(value)}`}</title>
</circle>
);
})}
</g>
);
})}
{visible.map((point, index) => {
if (index !== 0 && index !== visible.length - 1 && index % Math.ceil(visible.length / 6) !== 0) return null;
return (
<text key={point.date} x={xFor(index)} y={height - 12} textAnchor="middle" className="fill-[#6B7280] text-[11px]">
{point.date.slice(5)}
</text>
);
})}
</svg>
</div>
);
return (
<div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1">{metricCards()}</div>
{analysisDialog(
"时间趋势分析",
<>当前查看 {metric === "all" ? "全部指标" : activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}。</>,
)}
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
{chart(false)}
</div>
</div>
);
}
@@ -6,16 +6,18 @@ import {
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
FileText,
List,
Loader2,
Maximize2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -127,6 +129,10 @@ async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse>
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
}
async function fetchPublicRun(runId: string): Promise<PublicRun> {
return fetchJson<PublicRun>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`);
}
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
return fetchJson<PublicFileContent>(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
@@ -667,12 +673,61 @@ type ContentTab = {
fileName?: string;
};
type PublicFullscreenPreview =
| { mode: "html"; title: string; html: string }
| { mode: "markdown"; title: string; content: string }
| { mode: "text"; title: string; content: string }
| { mode: "file"; title: string; file: PublicFileContent };
function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreenPreview }) {
if (preview.mode === "html") {
return <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
}
if (preview.mode === "markdown") {
return (
<MarkdownContent
className="max-w-none"
content={preview.content || "无正文内容"}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
);
}
if (preview.mode === "file") {
const content = preview.file.content || "无文件内容";
if (isMarkdownFile(preview.file)) {
return (
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
);
}
return <pre className="whitespace-pre-wrap break-words text-sm">{content}</pre>;
}
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
}
function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string {
const params = new URLSearchParams();
params.set("tab", tab.kind);
if (tab.kind === "file" && tab.fileIndex != null) {
params.set("file", String(tab.fileIndex));
}
return `#/public/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/fullscreen?${params.toString()}`;
}
/**
* Right-pane controller for a single run: builds the tab list (body + MDs),
* fetches each tab's source text, and hosts the view/edit toggle.
*/
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
const queryClient = useQueryClient();
const [fullscreenPreview, setFullscreenPreview] = useState<PublicFullscreenPreview | null>(null);
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
const isHtmlRun =
@@ -837,9 +892,67 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
const fileIndex = activeTabMeta?.fileIndex;
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
const showEmptyHint = !activeSource && !fileIsLoading;
const fullscreenHref = activeTabMeta ? publicFullscreenHref(taskId, run.id, activeTabMeta) : "";
const fullscreenDisabled =
!activeTabMeta ||
(activeTabMeta.kind === "html" ? !htmlContent : activeTabMeta.kind === "file" ? fileIsLoading : false);
const handleOpenFullscreen = useCallback(() => {
if (!activeTabMeta) return;
if (activeTabMeta.kind === "html") {
if (!htmlContent) return;
setFullscreenPreview({
mode: "html",
title: run.result?.title || "HTML 预览",
html: htmlContent,
});
return;
}
if (activeTabMeta.kind === "body") {
setFullscreenPreview({
mode: run.result?.format === "markdown" || run.result?.format === "mixed" ? "markdown" : "text",
title: run.result?.title || "正文预览",
content: activeSource || "无正文内容",
});
return;
}
const sourceFile = markdownFiles.find((file) => file.index === activeTabMeta.fileIndex);
setFullscreenPreview({
mode: "file",
title: activeTabMeta.fileName || sourceFile?.name || "文件预览",
file: {
index: activeTabMeta.fileIndex ?? 0,
name: activeTabMeta.fileName || sourceFile?.name || "result.md",
mime_type: sourceFile?.mime_type ?? "text/markdown",
kind: sourceFile?.kind ?? "markdown",
size: sourceFile?.size ?? activeSource.length,
previewable: true,
content: activeSource,
},
});
}, [activeTabMeta, activeSource, htmlContent, markdownFiles, run.result?.format, run.result?.title]);
return (
<>
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
<DialogTitle className="truncate text-base">
{fullscreenPreview?.title ?? "预览"}
</DialogTitle>
</DialogHeader>
{fullscreenPreview && (
<div
className={cn(
"min-h-0 flex-1",
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
)}
>
<PublicFullscreenPreviewContent preview={fullscreenPreview} />
</div>
)}
</DialogContent>
</Dialog>
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
<div className="min-w-0 flex-1">
<Tooltip>
@@ -859,6 +972,30 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isEditing && activeTabMeta && (
<>
<Button
size="sm"
variant="outline"
onClick={handleOpenFullscreen}
disabled={fullscreenDisabled}
title="全屏查看"
>
<Maximize2 className="size-4" />
全屏
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open(fullscreenHref, "_blank", "noopener,noreferrer")}
disabled={fullscreenDisabled}
title="新页面打开全屏预览"
>
<ExternalLink className="size-4" />
新页面
</Button>
</>
)}
{isFileTab && fileIndex != null && (
<a
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
@@ -999,3 +1136,153 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</>
);
}
export function PublicScheduledTaskFullscreenPage() {
const { taskId, runId } = useParams<{ taskId: string; runId: string }>();
const [searchParams] = useSearchParams();
const requestedTab = searchParams.get("tab");
const tabKind: ContentTab["kind"] =
requestedTab === "body" || requestedTab === "file" || requestedTab === "html"
? requestedTab
: "html";
const fileIndexValue = Number(searchParams.get("file"));
const fileIndexValid = Number.isInteger(fileIndexValue) && fileIndexValue >= 0;
const { data: run, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", "run", runId],
queryFn: () => fetchPublicRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
retry: false,
});
const isHtmlRun =
run?.result?.format === "html" ||
(run?.result?.files ?? []).some(
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
);
const { data: htmlData, isLoading: htmlLoading } = useQuery({
queryKey: ["public-scheduled-task", "html", runId],
queryFn: () => fetchPublicRunHtml(runId!),
enabled: !!runId && tabKind === "html" && isHtmlRun && !run?.result?.html,
refetchOnWindowFocus: false,
retry: false,
});
const {
data: fileData,
isLoading: fileLoading,
isError: fileIsError,
error: fileError,
} = useQuery({
queryKey: ["public-scheduled-task", "file", runId, fileIndexValue],
queryFn: () => fetchPublicRunFile(runId!, fileIndexValue),
enabled: !!runId && tabKind === "file" && fileIndexValid,
refetchOnWindowFocus: false,
retry: false,
});
const htmlContent = run?.result?.html ?? htmlData?.html ?? null;
const backHref = taskId ? `#/public/scheduled-tasks/${encodeURIComponent(taskId)}` : "#/public/scheduled-tasks";
const pageTitle =
tabKind === "file"
? fileData?.name || "文件预览"
: tabKind === "body"
? run?.result?.title || "正文预览"
: htmlData?.title || run?.result?.title || "HTML 预览";
return (
<TooltipProvider delayDuration={150}>
<div className="bg-background flex h-screen min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Button asChild variant="ghost" size="sm" className="h-8 px-2 text-xs">
<a href={backHref}>
<ChevronLeft className="size-3" />
返回列表
</a>
</Button>
<div className="min-w-0 flex-1 truncate text-sm font-medium" title={pageTitle}>
{pageTitle}
</div>
{tabKind === "file" && runId && fileIndexValid && (
<Button asChild variant="outline" size="sm" className="h-8 px-2 text-xs">
<a
href={publicRunFileDownloadUrl(runId, fileIndexValue)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-3" />
下载
</a>
</Button>
)}
</div>
<div className="min-h-0 flex-1">
{!runId || !taskId ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
缺少任务或执行记录 ID。
</div>
) : isLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载预览...
</div>
) : isError ? (
<div className="text-destructive flex h-full items-center justify-center text-sm">
加载失败:{error instanceof Error ? error.message : "未知错误"}
</div>
) : tabKind === "html" ? (
htmlContent ? (
<HtmlPreviewFrame html={htmlContent} className="h-full w-full border-0 bg-white" />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
{htmlLoading && <Loader2 className="size-4 animate-spin" />}
HTML 页面尚未生成或正在生成中。
</div>
)
) : tabKind === "file" ? (
fileLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件...
</div>
) : !fileIndexValid || fileIsError || !fileData ? (
<div className="text-destructive flex h-full items-center justify-center text-sm">
文件加载失败:{fileError instanceof Error ? fileError.message : "文件不存在或不可预览"}
</div>
) : (
<ScrollArea className="h-full">
<div className="mx-auto max-w-5xl p-6">
<PublicFullscreenPreviewContent
preview={{
mode: "file",
title: fileData.name,
file: fileData,
}}
/>
</div>
</ScrollArea>
)
) : (
<ScrollArea className="h-full">
<div className="mx-auto max-w-5xl p-6">
<PublicFullscreenPreviewContent
preview={{
mode:
run?.result?.format === "markdown" || run?.result?.format === "mixed"
? "markdown"
: "text",
title: run?.result?.title || "正文预览",
content: run?.result?.content || "无正文内容",
}}
/>
</div>
</ScrollArea>
)}
</div>
</div>
</TooltipProvider>
);
}
@@ -59,7 +59,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function RecommendedQuestionsPage() {
export default function RecommendedQuestionsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion();
@@ -176,10 +180,16 @@ export default function RecommendedQuestionsPage() {
: { success: undefined, error: undefined };
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">推荐问题配置</h1>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>推荐问题配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
</p>
+106 -13
View File
@@ -21,6 +21,7 @@ import {
RefreshCw,
TagsIcon,
Trash2,
X,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -148,8 +149,16 @@ export default function ScheduledTasksPage() {
const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
// Plaza (广场) name search is resolved client-side over the fetched published
// list, kept separate from the server-side 我的任务 search above.
const [plazaSearch, setPlazaSearch] = useState("");
// Publisher (创建者) filter for the 我的任务 tab — click a task's 创建者 to keep
// only that creator's tasks. {id} is creator_user_id; {label} the display name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
const { data: tasks = [], isLoading, isFetching } = useScheduledTasks({ search, tagIds });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -199,6 +208,34 @@ export default function ScheduledTasksPage() {
}),
[tasks],
);
// The rendered 我的任务 list, optionally narrowed to one creator. Selection
// logic still runs against the full sortedTasks so the detail panel keeps
// working even when the picked task is from a filtered-out creator.
const visibleTasks = useMemo(
() =>
publisher
? sortedTasks.filter((task) => task.creator_user_id === publisher.id)
: sortedTasks,
[sortedTasks, publisher],
);
// Plaza search: filter the fetched published list client-side. Match against
// both the raw and desensitized forms so typing what's shown on screen (the
// desensitized name) and the underlying text both hit.
const visiblePublished = useMemo(() => {
const q = plazaSearch.trim().toLowerCase();
if (!q) return published;
return published.filter((item) =>
[
item.name,
desensitize(item.name),
item.prompt_summary,
desensitize(item.prompt_summary ?? ""),
item.schedule_text,
displayUsername(item.creator_email),
].some((field) => (field ?? "").toLowerCase().includes(q)),
);
}, [published, plazaSearch]);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
@@ -449,8 +486,32 @@ export default function ScheduledTasksPage() {
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
searching={isFetching}
/>
)}
{activeTab === "plaza" && (
<TagSearchBar
targetType="scheduled_task"
hideTags
onSearchChange={setPlazaSearch}
searching={publishedLoading}
searchPlaceholder="搜索广场任务(名称 / 摘要 / 创建者)"
/>
)}
{activeTab === "mine" && publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选创建者:</span>
<button
type="button"
onClick={() => setPublisher(null)}
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
title="点击清除创建者筛选"
>
{publisher.label}
<X className="size-3.5" />
</button>
</div>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -459,12 +520,14 @@ export default function ScheduledTasksPage() {
{isLoading && (
<div className="text-muted-foreground text-sm">正在加载任务...</div>
)}
{!isLoading && sortedTasks.length === 0 && (
{!isLoading && visibleTasks.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。
{publisher
? `没有「${publisher.label}」创建的任务。`
: "还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。"}
</div>
)}
{sortedTasks.map((task) => {
{visibleTasks.map((task) => {
const isOwner = task.role !== "subscriber";
const isSubscriber = task.role === "subscriber";
const notifyOn = !!task._subscription?.notify_element;
@@ -539,11 +602,36 @@ export default function ScheduledTasksPage() {
)}
</div>
{(task.creator_email || task.creator_user_id) && (
<div className="truncate">
// The card itself is a <button>, so this clickable
// creator must be a <span> (no nested buttons).
<span
role={task.creator_user_id ? "button" : undefined}
tabIndex={task.creator_user_id ? 0 : undefined}
onClick={(event) => {
if (!task.creator_user_id) return;
event.stopPropagation();
setPublisher({
id: task.creator_user_id,
label:
displayUsername(task.creator_email) ||
task.creator_user_id,
});
}}
className={cn(
"block max-w-full truncate text-sm font-medium",
task.creator_user_id &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
task.creator_user_id
? `点击筛选创建者:${displayUsername(task.creator_email) || task.creator_user_id}`
: undefined
}
>
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id}
</div>
</span>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
@@ -694,14 +782,19 @@ export default function ScheduledTasksPage() {
) : (
<div className="flex flex-col gap-3 p-4 pt-0">
{publishedLoading && (
<div className="text-muted-foreground text-sm">正在加载广场任务...</div>
)}
{!publishedLoading && published.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
广场上还没有发布的任务。
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载广场任务...
</div>
)}
{published.map((item) => {
{!publishedLoading && visiblePublished.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
{plazaSearch.trim()
? `没有匹配「${plazaSearch.trim()}」的任务。`
: "广场上还没有发布的任务。"}
</div>
)}
{visiblePublished.map((item) => {
const subscribed = tasks.some((t) => t.task_id === item.task_id);
return (
<div key={item.task_id} className="bg-card rounded-md border p-4">
@@ -781,7 +874,7 @@ export default function ScheduledTasksPage() {
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs">
<p className="text-muted-foreground text-sm font-medium">
创建者:
{displayUsername(selectedTask.creator_email) ||
selectedTask.creator_user_id}
@@ -24,6 +24,8 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import KnowledgeBasePage from './KnowledgeBasePage';
import KnowledgeGraphPage from './KnowledgeGraphPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
@@ -344,6 +346,30 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/graph"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeGraphPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId"
element={
<WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -16,12 +16,33 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
export interface WikiLinkTarget {
target: string;
kind: 'note' | 'vault' | 'missing';
exists: boolean;
note_id?: string;
vault_path?: string;
title?: string;
}
export type WikiLinkMap = Record<string, WikiLinkTarget>;
interface MarkdownRendererProps {
content: string;
className?: string;
onCitationClick?: (index: number) => void;
/** Resolved `[[wikilink]]` targets; when provided, non-numeric `[[...]]` render as links. */
wikiLinks?: WikiLinkMap;
onWikiLinkClick?: (link: WikiLinkTarget) => void;
}
const escapeHtmlAttr = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('css', css);
SyntaxHighlighter.registerLanguage('html', markup);
@@ -126,8 +147,48 @@ const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = (
);
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
const contentWithCitationBadge = content.replace(
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = '',
onCitationClick,
wikiLinks,
onWikiLinkClick,
}) => {
// 1) Non-numeric [[wikilinks]] → clickable (or greyed-out) links, driven by the
// resolver map. Numeric [[123]] are left untouched here for the citation pass.
let processed = content;
if (wikiLinks) {
processed = processed.replace(
/\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]/g,
(match, rawTarget: string, alias?: string) => {
const target = String(rawTarget).trim();
if (/^\d+$/.test(target)) return match; // citation badge, handled below
const resolved = wikiLinks[target];
const label = (alias?.trim() || resolved?.title || target);
const safeLabel = escapeHtmlAttr(label);
const safeTarget = escapeHtmlAttr(target);
if (resolved && resolved.exists) {
return `<button type="button" data-wikilink-target="${safeTarget}" class="inline items-center font-medium text-blue-600 dark:text-blue-400 hover:underline cursor-pointer align-baseline">${safeLabel}</button>`;
}
return `<span data-wikilink-missing="${safeTarget}" title="页面不存在" class="text-muted-foreground underline decoration-dashed decoration-from-font cursor-not-allowed">${safeLabel}</span>`;
},
);
}
// 2) wiki-capture provenance markers ^[inferred] / ^[ambiguous] → small badges
// (otherwise they render as raw text). They mark model-inferred / uncertain
// statements per the wiki-capture spec.
processed = processed
.replace(
/\s*\^\[inferred\]/g,
' <sup title="模型推断,非原文直接得出" class="ml-0.5 rounded bg-amber-50 px-1 text-[10px] font-medium text-amber-600 align-super dark:bg-amber-500/15 dark:text-amber-300">推测</sup>',
)
.replace(
/\s*\^\[ambiguous\]/g,
' <sup title="不确定 / 存在冲突" class="ml-0.5 rounded bg-rose-50 px-1 text-[10px] font-medium text-rose-600 align-super dark:bg-rose-500/15 dark:text-rose-300">存疑</sup>',
);
// 3) Numeric [[123]] citation badges.
const contentWithCitationBadge = processed.replace(
/\[\[(\d+)\]\]/g,
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
);
@@ -136,8 +197,17 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className
<div
className={`markdown-content ${className}`}
onClick={(event) => {
if (!onCitationClick) return;
const target = event.target as HTMLElement;
const wikiButton = target.closest('[data-wikilink-target]') as HTMLElement | null;
if (wikiButton && onWikiLinkClick && wikiLinks) {
const key = wikiButton.dataset.wikilinkTarget || '';
const link = wikiLinks[key];
if (link) {
onWikiLinkClick(link);
return;
}
}
if (!onCitationClick) return;
const button = target.closest('[data-citation-index]') as HTMLElement | null;
if (!button) return;
const index = Number(button.dataset.citationIndex || '0');
@@ -1,12 +1,18 @@
import { TaskListItem } from './TaskManagement';
/**
* 任务看板假数据 —— 用于在没有后端 `/task/cop-tasks` / `/three/cop-three-tasks`
* 接口时,给「任务分析」「三情分析」两个页面提供演示数据。
* 任务看板假数据 —— 给「任务分析」「三情分析」两个页面提供演示数据,
* 用于没有后端 `/task/cop-tasks` / `/three/cop-three-tasks` 接口时本地预览。
*
* 用法:把组件里的 `await service.post(url, payload)` 换成
* `await mockCopTasks('task' | 'sentiment', taskType, payload)`
* 返回结构与真实接口保持一致:`{ data: { records, total } }`。
* 是否启用由环境变量 `VITE_USE_REAL_DATA` 控制(见 `utils/request.ts`):
* - `VITE_USE_REAL_DATA=true` → 走真实接口(不挂本 mock)
* - 其它(含未设置) → 这两个接口走本地假数据
* 提交的 .env.development / .env.production / .env.network / .env.sit 默认 true(真实),
* 仅本机 .env 默认 false(假数据)。
*
* 实现方式:作为一个临时 axios adapter 挂在请求拦截器上(命中两个 url 时直接返回 mock body,
* 不发真实请求),业务组件 `SentimentAnalyzes.tsx` / `TaskAnalyzes.tsx` 一行不用改。
* 返回结构与真实接口一致:`{ code, message, data: { records, total } }`。
*/
export type MockColumnType = 'unanalyzed' | 'running' | 'completed';
@@ -402,7 +408,7 @@ export function matchTaskBoardMock(url?: string): MockDataset | null {
/**
* 临时假数据 axios adapter:命中任务看板接口时直接返回 mock body,不发真实请求。
* 仅用于演示,撤掉假数据时删除 request.ts 中对它的引用即可。
* 仅在 `VITE_USE_REAL_DATA !== 'true'` 时由 request.ts 挂上(见那里的请求拦截器)。
*/
export function taskBoardMockAdapter(config: {
url?: string;
+9 -3
View File
@@ -1,6 +1,12 @@
import axios from 'axios'
// === 临时假数据(任务分析 / 三情分析看板):撤掉时删除本 import 及下方 request 拦截器中的对应块即可 ===
import { matchTaskBoardMock, taskBoardMockAdapter } from '@/strategy-components/components/task-components/task-mock-data'
// 任务看板(任务分析 / 三情分析)数据源开关:
// VITE_USE_REAL_DATA=true → 走真实接口;其它(含未设置)→ 这两个接口走本地假数据。
// 提交的 .env.development/.env.production/.env.network/.env.sit 默认 true(真实数据),
// 仅本机 .env 默认 false(假数据)。改 env 后需重启 dev server 生效。
const USE_REAL_DATA = import.meta.env.VITE_USE_REAL_DATA === 'true'
// axios.interceptors
const service = axios.create({
// baseURL: '', // url = base url + request url
@@ -33,8 +39,8 @@ service.interceptors.response.use(
)
service.interceptors.request.use(
config => {
// === 临时假数据:任务看板两个接口直接走本地 mock,不发真实请求 ===
if (matchTaskBoardMock(config.url)) {
// 假数据模式:任务看板两个接口直接走本地 mock,不发真实请求(VITE_USE_REAL_DATA=true 时跳过)。
if (!USE_REAL_DATA && matchTaskBoardMock(config.url)) {
config.adapter = taskBoardMockAdapter as any;
}
// let token: any = localStorage.getItem('token');
+42 -1
View File
@@ -250,9 +250,18 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 300s); a past day, once `ready`, is treated as fresh forever.
Two on-demand/automatic refresh paths layer on top:
- **Manual "刷新当天"** — `POST /leaderboard/refresh-today` force-rebuilds *today's* snapshot **synchronously** (bounded by the same semaphore/timeout the scheduler uses), then returns the merged range. Lets an admin pull the latest real-time records into the stats without waiting for the TTL. Frontend: a "刷新当天" button on `AdminUserLeaderboardPage` → `useRefreshTodayLeaderboard` (`core/admin/hooks.ts`), which primes + invalidates the `["admin-activity","leaderboard"]` query.
- **Daily finalize of the previous day** — once per day, on/after `ADMIN_LEADERBOARD_FINALIZE_HOUR` (local Beijing, default `1` = 凌晨 1 点), the scheduler tick force-recomputes *yesterday* a single time so runs/tool-calls that only landed near midnight are fully captured. The decision is the pure `deerflow.persistence.admin_stats.finalize.should_finalize_previous_day` (status/generated_at/now/hour) — the rebuild stamps `generated_at` to today, which self-debounces it for the rest of the day (no extra state file). Tests: `tests/test_leaderboard_finalize.py`.
### Scheduled Task Runtime
`ScheduledTaskService` (`deerflow/runtime/scheduler/service.py`) polls due tasks every `poll_interval_seconds` and executes each in its own throwaway thread, serialized by a shared `_agent_run_lock`.
@@ -273,11 +282,32 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
**HTML skills (tools the render step uses).** Two offline skills under `skills/public/` shape the generated HTML and are toggled in the skills settings page (enabled by default): `html-page-builder` (a self-contained inline-CSS design system — colors/cards/tables/KPI/responsive) and `html-charts` (inline-SVG charts that render in the no-`allow-scripts` preview iframe; optional local ECharts documented in its `assets/README.md`). A skill opts into HTML injection via `metadata.kind: html_page` in its SKILL.md frontmatter; `_collect_html_skill_instructions()` reads the bodies of **enabled** such skills and `build_generation_prompt(skill_instructions=…)` injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected `model_name` (no silent fallback to the default model — and there is **no hardcoded GLM-5**; an unset selection falls back to config `models[0]`). To stop auxiliary LLM calls from leaking onto the default model, **`TitleMiddleware` is skipped when `is_scheduled_run` is set** (`_build_middlewares` in `lead_agent/agent.py`) — scheduled/HTML runs use throwaway threads whose titles are never shown, and `config.title.model_name` is usually unset (→ would call `models[0]`, not the chosen model). The completeness gate (`validate_html`) also requires a non-empty `<title>` and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks.
### Knowledge Base (`packages/harness/deerflow/knowledge/`)
Self-contained product knowledge base. **All knowledge capability lives in this one module** (harness layer, `deerflow.*` only — no `app.*` imports). Wired as `app.state.knowledge_service` in `deps.py` via `make_knowledge_service(sf, config.knowledge)` (returns `None` on the memory backend or when `knowledge.enabled=false` → router 503). Surfaced by `app/gateway/routers/knowledge.py` (`/api/knowledge`). Config: `config.yaml` → `knowledge` (`deerflow/config/knowledge_config.py`).
**Two stores, kept in sync on every write:**
- **DB mirror** — tables `knowledge_notes` / `knowledge_sources` / `knowledge_tags` / `knowledge_note_tags` (`knowledge/models.py`, registered in `persistence/models/__init__.py`, auto-created by `Base.metadata.create_all`; portable types `PortableLongText`/`BeijingDateTime`). Powers listing/filter/search/audit. Global/shared — no `user_id` column; `created_by`/`updated_by` are audit-only (resolved via `get_effective_user_id()`).
- **Markdown vault** — obsidian-wiki compatible, default `{$DEER_FLOW_HOME}/data/knowledge/obsidian-vault`. `ObsidianWikiAdapter` (`obsidian_adapter.py`) writes wiki-capture frontmatter+body, and after **every** write syncs `index.md` / `log.md` (CAPTURE/INGEST/UPDATE/ARCHIVE/EXPORT) / `hot.md` / `.manifest.json` (source-hash dedup for `skip_existing`). `markdown_vault.py` handles frontmatter build/parse, slugging, wikilink extraction and **path-traversal-proof** resolution.
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
**Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`):
- **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed.
- **Real related notes only (no structural meta-links)** — `extractor.related_links()` now returns `[]`: notes no longer auto-link `index` / `_meta/taxonomy` / a project hub (they were noise in every `## Related`). On capture, `service._find_related_notes` cross-links **only by shared *distinctive* tags** (project/`knowledge-base` defaults excluded; free-text title/summary matching was removed because CJK tokenization linked unrelated notes via generic words like 助手/知识图谱) and `_inject_related_notes` writes real `[[Title]]` links into `## Related` (plus note→note `related` graph edges); no distinctive tags → no related notes. The renderers omit the `## Related` section entirely when there are no related notes and no sources. The vault's own `index.md`/`_meta/taxonomy.md` still exist (obsidian scaffold) and remain resolvable for manually-written links. The router enriches `GET /notes/{id}` with `created_by_name`/`updated_by_name` (auth-provider email lookup) so the UI shows a person, not a UUID. The frontend `MarkdownRenderer` renders wiki-capture provenance markers `^[inferred]`/`^[ambiguous]` as small "推测"/"存疑" badges.
- **LLM-extraction outcomes** — capture distinguishes three LLM outcomes: (1) **success** → declarative note(s); (2) **deliberate rejection** — the model returns `should_ingest:false` with no notes (greeting / meta chatter) → `extract_thread_llm_multi` raises `LLMRejectedIngest` and `capture_thread` raises `EmptyThreadError` (skip — nothing persisted, so worthless chats never enter the KB); (3) **genuine failure** — API error / non-JSON → returns `[]`, capture falls back to the rule-based verbatim extractor and lands the note as a **`draft`** (not `approved`), logs a WARNING, and returns `llm_fallback:true` (surfaced by the router). `llm_extractor` logs an actionable WARNING/INFO for each non-success path so extraction issues are diagnosable.
- **Multi-note split** — `llm_extractor.extract_thread_llm_multi` lets one conversation distill into 1–4 cross-linked notes (`notes:[…]` JSON array; legacy single-object output still accepted). `capture_thread` persists each, sibling-cross-links them by title, and returns `{note, created, notes:[…]}`. Gated by `multi_note_enabled` (false → first note only). Tests: `tests/test_knowledge_wikilinks.py`.
**Phases 2–4 (all config-gated, defaults safe):**
- **Phase 2 — vector RAG**: `knowledge.embedding.{enabled,base_url,api_key,model,...}` enables an OpenAI-compatible embedding endpoint; notes are chunked + embedded on write (`knowledge_embeddings`), `POST /search` supports `mode=keyword|vector|hybrid` (vector degrades to keyword when unconfigured). `KnowledgeRagMiddleware` (lead-agent chain) injects a transient `<knowledge-context>` recall before the model call, gated **per-conversation** by the `knowledge_rag_enabled` runtime flag (frontend chat toggle) falling back to global `knowledge.rag_enabled`. `POST /reindex` rebuilds the index.
- **Phase 3 — governance**: `sensitive.py` redacts secrets/PII before persistence (`sensitive_redaction_enabled`); every update snapshots `knowledge_note_versions` (`GET /notes/{id}/versions`); `GET /duplicates` + `POST /merge` for dedup; background `AutoIngestQueue` (wired in `deps.py`, started/stopped in the gateway lifespan) auto-sediments finished conversations **off the chat path** when `auto_ingest_enabled` — enqueued from `KnowledgeRagMiddleware.after_agent`, value-judged by min-chars + `skip_existing`, landing as `draft`.
- **Phase 4 — graph**: LLM extraction also yields entities/relations → `knowledge_entities`/`knowledge_relations`; `exporter.build_graph` adds entity nodes + note→entity/entity→entity edges (reflected in `graph.html`/`graph.json`); `GET /graph` returns product graph data.
### Custom Agent Persistence
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter. `POST /api/agents/private-square/list` takes the same pagination params. Repository entry point: `AgentStore.list_paginated(...)`.
**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's 发布者 — built-in agents never match), powering the gallery's click-发布者-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
@@ -289,6 +319,16 @@ Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` /
- **`PortableLongText`** — a large text column: maps to MySQL `LONGTEXT` (4 GB) and plain `TEXT` on SQLite/Postgres (already unbounded). Use for big JSON/markdown blobs that would overflow MySQL's 64 KB `TEXT` cap — e.g. `ai_writing_sessions.transcript`.
- **`BeijingDateTime`** — stores timestamps as Beijing (UTC+8) wall-clock time. The app records time with `datetime.now(UTC)`, but MySQL `DATETIME` / SQLite have no timezone and persist whatever wall clock the driver serializes — so a raw `DateTime` column would store UTC and read 8 hours behind Beijing. `BeijingDateTime` converts UTC→Beijing on bind and re-attaches `+08:00` on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQL `timestamptz` stores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so no `tzdata` dependency.
### Schema Migrations / Manual Column-Add SQL (`scripts/sql/`)
Alembic auto-migration is **disabled** — `_run_alembic_upgrade(url)` is commented out in `persistence/engine.py`. On startup only `Base.metadata.create_all` runs, which creates **new tables** but never patches **existing** ones (no new columns, no new indexes). So a column added to an ORM model appears automatically on a brand-new deployment, but is **missing on any existing MySQL database** — and code reading/writing that column will error there until the column is added by hand.
**Convention (required):** whenever code adds a field to an existing table (a new `mapped_column` on an ORM model), you must, in the same change:
1. Keep the matching alembic version file under `persistence/migrations/versions/` (guarded with `_has_table`/`_has_column`), and
2. Append the idempotent `ADD COLUMN` statement to **`scripts/sql/column_additions_mysql.sql`**, with a definition matching the ORM column exactly (type, length, nullability).
Operators upgrading an existing DB run the whole file (`USE <db>;` then execute) — it is idempotent (existing columns are skipped via `information_schema.columns`), so it is safe to re-run. Index-only changes go in the sibling `scripts/sql/leaderboard_indexes_mysql.sql` (same idempotent pattern).
### Sandbox System (`packages/harness/deerflow/sandbox/`)
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
@@ -358,6 +398,7 @@ Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` /
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) — never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill → owner or admin; built-in/public → admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (一键补充: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` — an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) — distinct from the model returning an unusable name (→ `None`). The single endpoint maps that to `502 大模型报错,无法补全技能中文名:…`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N 个因大模型报错失败". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI 生成" plus an **admin-only** header "一键补充中文名" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
@@ -6,6 +6,8 @@ import sys
import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime
from time import perf_counter
from dotenv import load_dotenv
from fastapi import FastAPI
@@ -30,12 +32,14 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
browser_context,
business_mapping,
channels,
checkpoint_migration,
feedback,
html_page_favorites,
intent,
knowledge,
llm_metrics,
mcp,
memory,
@@ -45,6 +49,7 @@ from app.gateway.routers import (
parallel_agent_tasks,
parallel_agents,
public_embed,
public_leaderboard,
public_scheduled_tasks,
recommend,
recommended_questions,
@@ -469,6 +474,91 @@ _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0,
)
# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
# *previous* day's snapshot once, so records that landed near midnight are fully
# captured. Default 1 → "每天凌晨 1 点重新统计前一天".
_LEADERBOARD_FINALIZE_HOUR = min(
23,
_env_int("ADMIN_LEADERBOARD_FINALIZE_HOUR", 1, minimum=0),
)
def _leaderboard_scheduler_sql_for_log(stmt) -> str:
try:
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
except Exception:
return str(stmt)
async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context):
sql = _leaderboard_scheduler_sql_for_log(stmt)
logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step,
context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "",
context.get("settings_hash") or "",
datetime.now().isoformat(),
sql,
)
start = perf_counter()
try:
result = await session.execute(stmt)
except Exception:
logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step,
context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "",
context.get("settings_hash") or "",
(perf_counter() - start) * 1000,
)
raise
logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step,
context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "",
context.get("settings_hash") or "",
datetime.now().isoformat(),
(perf_counter() - start) * 1000,
)
return result
async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context):
logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step,
context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "",
context.get("settings_hash") or "",
datetime.now().isoformat(),
sql,
)
start = perf_counter()
try:
result = await operation()
except Exception:
logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step,
context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "",
context.get("settings_hash") or "",
(perf_counter() - start) * 1000,
)
raise
logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step,
context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "",
context.get("settings_hash") or "",
datetime.now().isoformat(),
(perf_counter() - start) * 1000,
)
return result
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
@@ -496,21 +586,27 @@ async def _leaderboard_snapshot_due_dates(
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session:
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
stmt = (
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
)
rows = (
await _execute_leaderboard_scheduler_query(
session,
"scheduler.due_dates",
stmt,
settings_hash=settings_hash,
)
).all()
return [str(row[0]) for row in rows]
@@ -530,9 +626,15 @@ async def _claim_leaderboard_snapshot(
from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
row = await _time_leaderboard_scheduler_db_operation(
"scheduler.claim.get",
lambda: session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
),
sql="SELECT admin_leaderboard_daily_stats by primary key",
stat_date=stat_date,
settings_hash=settings_hash,
)
if row is None or _snapshot_is_fresh(row, stat_date):
return False
@@ -548,10 +650,68 @@ async def _claim_leaderboard_snapshot(
row.message = "排行榜后台统计任务已认领。"
row.error = None
row.updated_at = datetime.now(UTC)
await session.commit()
await _time_leaderboard_scheduler_db_operation(
"scheduler.claim.commit",
lambda: session.commit(),
sql="COMMIT",
stat_date=stat_date,
settings_hash=settings_hash,
)
return True
async def _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash: str) -> bool:
"""Once per day after ``_LEADERBOARD_FINALIZE_HOUR``, force-recompute yesterday.
A ``ready`` past-day snapshot is otherwise treated as fresh forever and never
rebuilt. But the previous day's snapshot is usually generated before midnight,
so it can miss runs/tool-calls that only land in the DB around the day
boundary. This re-queues yesterday (``force=True``) a single time; the rebuild
stamps ``generated_at`` to today, which stops it from re-firing for the rest of
the day.
"""
from datetime import timedelta
from app.gateway.routers.admin_users import (
_as_beijing,
_queue_daily_leaderboard_snapshots,
)
from deerflow.persistence.admin_stats.finalize import should_finalize_previous_day
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
now = datetime.now(BEIJING_TZ)
yesterday = (now.date() - timedelta(days=1)).isoformat()
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": yesterday, "settings_hash": settings_hash},
)
generated_at = _as_beijing(row.generated_at) if (row is not None and row.generated_at is not None) else None
if not should_finalize_previous_day(
status=(row.status if row is not None else None),
generated_at=generated_at,
now=now,
finalize_hour=_LEADERBOARD_FINALIZE_HOUR,
):
return False
queued = await _queue_daily_leaderboard_snapshots(
session_factory,
[yesterday],
settings_hash,
force=True,
message="每日凌晨重新统计前一天完整数据,确保全部记录纳入统计。",
)
if queued:
logger.info(
"Leaderboard snapshot finalize: queued previous day %s for full recompute (hash=%s)",
yesterday,
settings_hash[:10],
)
return True
return False
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import (
_daily_job_key,
@@ -576,6 +736,12 @@ async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
message="排行榜后台统计任务定期预热/补算队列。",
)
# 每天凌晨(默认 1 点)后强制重算"前一天"的完整快照一次,覆盖跨午夜才落库的记录。
try:
await _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash)
except Exception:
logger.exception("Leaderboard snapshot finalize check failed (non-fatal)")
due_dates = await _leaderboard_snapshot_due_dates(
session_factory,
settings_hash=settings_hash,
@@ -1115,6 +1281,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Uploads API is mounted at /api/threads/{thread_id}/uploads
app.include_router(uploads.router)
# Optional Chrome-extension page capture API is mounted at /api/browser-context
app.include_router(browser_context.router)
# Thread cleanup API is mounted at /api/threads/{thread_id}
app.include_router(threads.router)
@@ -1162,10 +1331,16 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# surface results without a DeerFlow login.
app.include_router(public_scheduled_tasks.router)
# Public read-only user leaderboard publish page (/api/public/leaderboard)
app.include_router(public_leaderboard.router)
# Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router)
app.include_router(html_page_favorites.router)
# Product knowledge base API is mounted at /api/knowledge
app.include_router(knowledge.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
@@ -1174,6 +1349,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Admin window into other users' threads / messages / memory
app.include_router(admin_users.router)
app.include_router(admin_users.self_router)
# Admin tool / skill call metrics audit + export
app.include_router(tool_metrics.router)
@@ -20,6 +20,7 @@ from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, i
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from app.gateway.proxy_path import app_relative_path
from deerflow.runtime.user_context import reset_current_user, set_current_user
# Paths that never require authentication.
@@ -84,7 +85,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
if _is_public(request.url.path):
# Match the application-relative path so a reverse-proxy prefix
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
if _is_public(app_relative_path(request)):
return await call_next(request)
if is_auth_disabled():
@@ -8,6 +8,7 @@ import secrets
from collections.abc import Callable
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.proxy_path import app_relative_path
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
@@ -37,7 +38,9 @@ def should_check_csrf(request: Request) -> bool:
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return False
path = request.url.path
# Match the application-relative path so a reverse-proxy prefix
# (e.g. /xxx/api/v1/auth/me) does not defeat these whitelists.
path = app_relative_path(request)
stripped = path.rstrip("/")
# Exempt /api/v1/auth/me endpoint
if stripped == "/api/v1/auth/me":
@@ -56,6 +59,10 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
{
"/api/v1/auth/login/local",
"/api/v1/auth/login/username",
# Token-exchange login is sessionless on first call (no CSRF cookie yet),
# exactly like the other login endpoints — mirror auth_middleware's
# _PUBLIC_EXACT_PATHS, which already whitelists it.
"/api/v1/auth/login/token",
"/api/v1/auth/logout",
"/api/v1/auth/register",
"/api/v1/auth/initialize",
@@ -69,8 +76,10 @@ def is_auth_endpoint(request: Request) -> bool:
"""Check if the request is to an auth endpoint.
Auth endpoints don't need CSRF validation on first call (no token).
Matches the application-relative path so a reverse-proxy prefix does not
defeat the whitelist.
"""
return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
return app_relative_path(request).rstrip("/") in _AUTH_EXEMPT_PATHS
def _has_bearer_auth(request: Request) -> bool:
@@ -82,6 +91,22 @@ def _has_bearer_auth(request: Request) -> bool:
return scheme.lower() == "bearer" and bool(token.strip())
def _has_cookie_session(request: Request) -> bool:
"""Return True when the request carries a cookie-based session.
Double-Submit-Cookie CSRF only protects requests the browser authenticates
**automatically via cookies** — that is the exact surface a cross-site
forgery can ride. A request with no ``access_token`` session cookie has no
session for an attacker to exploit, so enforcing CSRF on it is both
pointless and harmful: a freshly-loaded browser (e.g. one that just cleared
its cache/cookies) hitting the login endpoint would otherwise be rejected
with "CSRF token missing" before it can even sign in. Such an
unauthenticated request, if forged, is rejected by the auth layer (401)
anyway.
"""
return bool(request.cookies.get("access_token"))
class CSRFMiddleware(BaseHTTPMiddleware):
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
@@ -95,8 +120,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
return await call_next(request)
# Double-submit-cookie CSRF applies to cookie-based browser sessions.
# API clients using explicit Bearer tokens are not CSRF-prone.
if should_check_csrf(request) and not _is_auth and not _has_bearer_auth(request):
# API clients using explicit Bearer tokens are not CSRF-prone, and a
# request with no session cookie at all (e.g. a fresh browser signing
# in) has no session to forge — only enforce when an ``access_token``
# cookie is actually present.
if should_check_csrf(request) and not _is_auth and not _has_bearer_auth(request) and _has_cookie_session(request):
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
header_token = request.headers.get(CSRF_HEADER_NAME)
@@ -143,6 +143,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.ai_writing_sessions import (
make_ai_writing_session_store,
)
from deerflow.persistence.ai_writing_sessions import (
register_default_store as _register_ai_writing_default_store,
)
@@ -159,6 +161,25 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.memory_config_store = make_user_memory_config_store(sf)
from deerflow.knowledge import make_knowledge_service
app.state.knowledge_service = make_knowledge_service(sf, config.knowledge)
# Background auto-sedimentation queue (phase 3) — drains finished
# conversations off the chat path. Worker only does work when
# ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so
# the middleware can enqueue without a hard dependency.
from deerflow.knowledge.auto_ingest import AutoIngestQueue, set_auto_ingest_queue
if app.state.knowledge_service is not None:
_auto_ingest_queue = AutoIngestQueue(
lambda: getattr(app.state, "knowledge_service", None),
min_chars=config.knowledge.auto_ingest_min_chars,
)
_auto_ingest_queue.start()
set_auto_ingest_queue(_auto_ingest_queue)
app.state.knowledge_auto_ingest_queue = _auto_ingest_queue
from deerflow.persistence.user_prompts import make_user_prompt_store
app.state.user_prompt_store = make_user_prompt_store(sf)
@@ -189,6 +210,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
from deerflow.persistence.roundtable_jobs import (
register_default_store as _register_roundtable_job_default_store,
)
@@ -246,6 +269,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
if queue is not None:
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
@@ -380,6 +409,14 @@ def get_memory_config_store(request: Request):
return val
def get_knowledge_service(request: Request):
"""Return the knowledge base service (503 when disabled / memory backend)."""
val = getattr(request.app.state, "knowledge_service", None)
if val is None:
raise HTTPException(status_code=503, detail="Knowledge base not available")
return val
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -40,6 +40,13 @@ def _check_csrf(request) -> None:
# CSRF protection is for cookie-auth browser flows.
return
# No cookie-based session → nothing for a cross-site forgery to ride, so
# CSRF is moot. Enforcing it here would reject a freshly-loaded browser
# (just cleared its cookies) before it can authenticate; such a request is
# rejected as unauthenticated (401) below anyway.
if not request.cookies.get("access_token"):
return
cookie_token = request.cookies.get("csrf_token")
header_token = request.headers.get("x-csrf-token")
@@ -0,0 +1,39 @@
"""Resolve the application-relative request path behind a reverse proxy.
A reverse proxy may forward requests under a path prefix, e.g.
``/xxx/api/v1/auth/login/token``. FastAPI strips that prefix for **routing**
when it is told about it via the ASGI ``root_path`` (uvicorn ``--root-path`` /
``--proxy-headers``, or ``X-Forwarded-Prefix`` from the proxy) — which is why
the route still matches and the request reaches a handler.
Starlette middleware, however, runs *before* routing and sees the full,
prefixed ``request.url.path``. Middlewares that match that raw path against
fixed whitelists — the CSRF-exempt auth paths and the AuthMiddleware public
paths — then mis-match, so login and ``/api/public/*`` endpoints get wrongly
rejected (``CSRF token missing`` / ``401``) on a prefixed deployment.
``app_relative_path`` returns the path with the proxy prefix removed so that
whitelist matching is prefix-agnostic. It is a no-op when no prefix is present
(direct deployment), and tolerates the prefix already being stripped from the
path (it only strips when the path actually starts with the prefix).
"""
from fastapi import Request
def app_relative_path(request: Request) -> str:
"""Return ``request.url.path`` with any reverse-proxy prefix removed.
Prefix source order: ASGI ``root_path`` (set by the server when the proxy
forwards it), then the ``X-Forwarded-Prefix`` header (proxies that add a
prefix without informing the ASGI server). Returns ``"/"`` for an empty
result so downstream ``rstrip("/")`` / ``startswith`` checks stay sane.
"""
path = request.url.path or "/"
prefix = (request.scope.get("root_path") or request.headers.get("x-forwarded-prefix") or "").rstrip("/")
if prefix and path.startswith(prefix):
stripped = path[len(prefix):]
if not stripped:
return "/"
return stripped if stripped.startswith("/") else "/" + stripped
return path
@@ -19,8 +19,11 @@ import logging
import os
import re
from collections.abc import AsyncIterator
from contextvars import ContextVar
from difflib import SequenceMatcher
from hashlib import sha256
from time import perf_counter
from typing import Any
from datetime import UTC, datetime, timedelta, time as datetime_time
@@ -56,6 +59,7 @@ from deerflow.persistence.user.model import UserRow
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
self_router = APIRouter(prefix="/api/users/me", tags=["user-qa"])
_LEADERBOARD_MAX_CONCURRENT = max(
1,
@@ -99,6 +103,119 @@ _LEADERBOARD_QUEUE_ON_REQUEST_ENABLED = os.getenv(
"off",
}
_leaderboard_semaphore = asyncio.Semaphore(_LEADERBOARD_MAX_CONCURRENT)
_LEADERBOARD_TRACE_CONTEXT: ContextVar[dict[str, object] | None] = ContextVar(
"leaderboard_trace_context",
default=None,
)
def _leaderboard_log_context(**context: object) -> dict[str, object] | None:
current = _LEADERBOARD_TRACE_CONTEXT.get()
merged: dict[str, object] = dict(current or {})
for key, value in context.items():
if value is None or value == "":
continue
merged[key] = value
return merged or None
def _leaderboard_sql_for_log(stmt) -> str:
try:
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
except Exception:
return str(stmt)
async def _execute_leaderboard_query(session, step: str, stmt, **context: object):
log_context = _leaderboard_log_context(**context)
if not log_context:
return await session.execute(stmt)
started_at = datetime.now(BEIJING_TZ).isoformat()
sql = _leaderboard_sql_for_log(stmt)
logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
started_at,
sql,
)
start = perf_counter()
try:
result = await session.execute(stmt)
except Exception:
elapsed_ms = (perf_counter() - start) * 1000
logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
elapsed_ms,
)
raise
elapsed_ms = (perf_counter() - start) * 1000
logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
datetime.now(BEIJING_TZ).isoformat(),
elapsed_ms,
)
return result
async def _time_leaderboard_db_operation(
step: str,
operation,
*,
sql: str = "",
**context: object,
):
log_context = _leaderboard_log_context(**context)
if not log_context:
return await operation()
logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
datetime.now(BEIJING_TZ).isoformat(),
sql,
)
start = perf_counter()
try:
result = await operation()
except Exception:
elapsed_ms = (perf_counter() - start) * 1000
logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
elapsed_ms,
)
raise
elapsed_ms = (perf_counter() - start) * 1000
logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step,
log_context.get("stat_date") or log_context.get("stat_dates") or "",
log_context.get("job_key") or "",
log_context.get("settings_hash") or "",
datetime.now(BEIJING_TZ).isoformat(),
elapsed_ms,
)
return result
async def _require_admin(request: Request) -> None:
@@ -285,6 +402,13 @@ def _parse_datetime(value: str | None, fallback: datetime) -> datetime:
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
def _parse_optional_datetime(value: str | None) -> datetime | None:
if not value:
return None
parsed = _parse_datetime(value, _now())
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=BEIJING_TZ)
def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]:
end = _parse_datetime(until, _now())
start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365))))
@@ -325,7 +449,7 @@ def _bounds_for_stat_date(stat_date: str) -> tuple[datetime, datetime]:
async def _try_set_session_value(session, sql: str) -> None:
try:
await session.execute(text(sql))
await _execute_leaderboard_query(session, "session_setting", text(sql))
except Exception:
logger.debug("Failed to apply analytics session setting: %s", sql, exc_info=True)
try:
@@ -334,13 +458,36 @@ async def _try_set_session_value(session, sql: str) -> None:
logger.debug("Failed to rollback after analytics session setting failure", exc_info=True)
def _is_mariadb(dialect) -> bool:
"""SQLAlchemy reports ``dialect.name == 'mysql'`` for MariaDB too."""
if getattr(dialect, "_is_mariadb", False) or getattr(dialect, "is_mariadb", False):
return True
version_info = getattr(dialect, "server_version_info", None) or ()
return any("mariadb" in str(part).lower() for part in version_info)
async def _set_analytics_read_timeout(session) -> None:
"""Best-effort per-statement read timeout for the analytics queries.
The right knob differs by server, and some servers have none at all:
- MySQL >= 5.7.4: ``max_execution_time`` (milliseconds)
- MariaDB: ``max_statement_time`` (seconds)
- MySQL <= 5.6: no per-statement timeout variable — skip it entirely
(5.6 raises "Unknown system variable" otherwise)
- PostgreSQL: ``statement_timeout`` (milliseconds)
"""
conn = await session.connection()
dialect = conn.dialect.name
if dialect == "mysql":
await _try_set_session_value(session, f"SET SESSION max_execution_time = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
dialect = conn.dialect
name = dialect.name
if name == "mysql":
if _is_mariadb(dialect):
# MariaDB: max_statement_time is in SECONDS (fractional ok).
await _try_set_session_value(session, f"SET SESSION max_statement_time = {_LEADERBOARD_QUERY_TIMEOUT_MS / 1000}")
elif (getattr(dialect, "server_version_info", None) or ()) >= (5, 7, 4):
await _try_set_session_value(session, f"SET SESSION max_execution_time = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
# else: MySQL <= 5.6 has no statement-timeout variable — skip silently.
await _try_set_session_value(session, f"SET SESSION innodb_lock_wait_timeout = {_LEADERBOARD_LOCK_WAIT_SECONDS}")
elif dialect == "postgresql":
elif name == "postgresql":
await _try_set_session_value(session, f"SET LOCAL statement_timeout = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
@@ -661,11 +808,15 @@ async def _query_user_leaderboard(
if user_id:
stmt = stmt.where(UserRow.id == user_id)
stmt = _apply_user_scope(stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
# 排行榜只统计真正产生过问答的用户;从未问答的账号(仅注册、零活跃)不计入榜单与人数。
# 个人分析(指定 user_id)不受此限制,仍可查看任意单个用户。
if user_id is None:
stmt = stmt.where(func.coalesce(run_agg.c.qa_count, 0) > 0)
stmt = stmt.order_by(
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)),
)
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, "users", stmt)).all()
return [
AdminLeaderboardUser(
user_id=r[0],
@@ -725,7 +876,7 @@ async def _query_call_rankings(
include_admins=include_admins,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, f"{field}_rankings", stmt)).all()
return [
AdminLeaderboardSkill(
name=r[0] or "",
@@ -791,7 +942,7 @@ async def _query_run_dimensions(
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, f"{field}_dimensions", stmt)).all()
return [
AdminLeaderboardDimension(
name=r[0] or "",
@@ -848,7 +999,7 @@ async def _query_model_rankings(
stmt = stmt.where(LlmCallMetricRow.status != "error")
if not include_scheduled:
stmt = stmt.where(or_(LlmCallMetricRow.run_id.is_(None), LlmCallMetricRow.run_id.not_in(_scheduled_run_ids_stmt())))
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, "model_rankings", stmt)).all()
return [
AdminLeaderboardDimension(
name=r[0] or "",
@@ -895,7 +1046,7 @@ async def _query_recent_questions(
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, "recent_questions", stmt)).all()
questions: list[AdminLeaderboardQuestion] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
@@ -947,7 +1098,7 @@ async def _query_frequent_questions(
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, "frequent_questions", stmt)).all()
groups: list[dict] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
@@ -1022,7 +1173,7 @@ async def _query_trends(
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
rows = (await _execute_leaderboard_query(session, "trends", stmt)).all()
return [
AdminLeaderboardTrendPoint(
date=str(r[0]),
@@ -1071,8 +1222,8 @@ async def _query_overview(
total_users_stmt = select(func.count(UserRow.id))
total_users_stmt = _apply_user_scope(total_users_stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
total_users = 1 if user_id else (await session.execute(total_users_stmt)).scalar_one()
run_row = (await session.execute(
total_users = 1 if user_id else (await _execute_leaderboard_query(session, "overview.total_users", total_users_stmt)).scalar_one()
run_summary_stmt = (
select(
func.count(RunRow.run_id),
_question_count_expr(),
@@ -1080,16 +1231,19 @@ async def _query_overview(
func.count(func.distinct(RunRow.user_id)),
func.sum(case((RunRow.status == "error", 1), else_=0)),
).where(*run_where)
)).one()
thread_count = (await session.execute(select(func.count(ThreadMetaRow.thread_id)).where(*thread_where))).scalar_one()
tool_row = (await session.execute(
)
run_row = (await _execute_leaderboard_query(session, "overview.run_summary", run_summary_stmt)).one()
thread_count_stmt = select(func.count(ThreadMetaRow.thread_id)).where(*thread_where)
thread_count = (await _execute_leaderboard_query(session, "overview.thread_count", thread_count_stmt)).scalar_one()
tool_summary_stmt = (
select(
func.count(ToolCallMetricRow.id),
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)),
)
.outerjoin(RunRow, RunRow.run_id == ToolCallMetricRow.run_id)
.where(*tool_where)
)).one()
)
tool_row = (await _execute_leaderboard_query(session, "overview.tool_summary", tool_summary_stmt)).one()
return AdminLeaderboardOverview(
total_users=int(total_users or 0),
@@ -1579,13 +1733,12 @@ async def _load_daily_snapshot_rows(
if not stat_dates:
return {}
async with session_factory() as session:
stmt = select(AdminLeaderboardDailyStatRow).where(
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow).where(
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
)
await _execute_leaderboard_query(session, "snapshot_rows.load", stmt)
).scalars().all()
return {row.stat_date: row for row in rows}
@@ -1603,9 +1756,15 @@ async def _store_daily_snapshot(
) -> None:
async with session_factory() as session:
now = datetime.now(UTC)
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
row = await _time_leaderboard_db_operation(
"snapshot.store.get",
lambda: session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
),
sql="SELECT admin_leaderboard_daily_stats by primary key",
stat_date=stat_date,
settings_hash=settings_hash,
)
if row is None:
row = AdminLeaderboardDailyStatRow(
@@ -1630,7 +1789,13 @@ async def _store_daily_snapshot(
if generated_at is not None:
row.generated_at = generated_at
row.updated_at = now
await session.commit()
await _time_leaderboard_db_operation(
"snapshot.store.commit",
lambda: session.commit(),
sql="COMMIT",
stat_date=stat_date,
settings_hash=settings_hash,
)
async def _queue_daily_leaderboard_snapshots(
@@ -1646,12 +1811,17 @@ async def _queue_daily_leaderboard_snapshots(
return queued
async with session_factory() as session:
now = datetime.now(UTC)
stmt = select(AdminLeaderboardDailyStatRow).where(
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow).where(
AdminLeaderboardDailyStatRow.stat_date.in_(stat_dates),
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
)
await _execute_leaderboard_query(
session,
"snapshot_queue.load_existing",
stmt,
stat_dates=stat_dates,
settings_hash=settings_hash,
)
).scalars().all()
rows_by_date = {row.stat_date: row for row in rows}
@@ -1680,7 +1850,13 @@ async def _queue_daily_leaderboard_snapshots(
row.updated_at = now
queued.append(stat_date)
if queued:
await session.commit()
await _time_leaderboard_db_operation(
"snapshot_queue.commit",
lambda: session.commit(),
sql="COMMIT",
stat_dates=queued,
settings_hash=settings_hash,
)
return queued
@@ -1722,7 +1898,22 @@ async def _refresh_daily_leaderboard_snapshot(
include_failed: bool,
) -> None:
acquired = False
trace_token = _LEADERBOARD_TRACE_CONTEXT.set(
{
"job_key": job_key,
"stat_date": stat_date,
"settings_hash": settings_hash,
}
)
snapshot_start = perf_counter()
try:
logger.info(
"Leaderboard snapshot build start: job=%s stat_date=%s settings_hash=%s started_at=%s",
job_key,
stat_date,
settings_hash,
datetime.now(BEIJING_TZ).isoformat(),
)
if _LEADERBOARD_START_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_START_DELAY_SECONDS)
await _store_daily_snapshot(
@@ -1758,6 +1949,14 @@ async def _refresh_daily_leaderboard_snapshot(
error=None,
generated_at=generated_at,
)
logger.info(
"Leaderboard snapshot build done: job=%s stat_date=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
job_key,
stat_date,
settings_hash,
datetime.now(BEIJING_TZ).isoformat(),
(perf_counter() - snapshot_start) * 1000,
)
except asyncio.TimeoutError:
logger.warning("Daily leaderboard snapshot timed out: %s", job_key)
await _store_daily_snapshot(
@@ -1781,6 +1980,7 @@ async def _refresh_daily_leaderboard_snapshot(
finally:
if acquired:
_leaderboard_semaphore.release()
_LEADERBOARD_TRACE_CONTEXT.reset(trace_token)
async def _load_or_schedule_daily_leaderboard(
@@ -1978,6 +2178,72 @@ async def queue_leaderboard_snapshot_backfill(
)
@router.post("/leaderboard/refresh-today", response_model=AdminLeaderboardResponse)
async def refresh_today_leaderboard(
request: Request,
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
include_scheduled: bool | None = Query(default=None, description="Include runs created by scheduled tasks"),
include_admins: bool | None = Query(default=None, description="Include admin accounts in leaderboard aggregation"),
include_failed: bool | None = Query(default=None, description="Include failed runs and LLM calls"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Force a synchronous rebuild of *today's* snapshot, then return the range.
Today's snapshot otherwise refreshes only every ``_LEADERBOARD_TODAY_TTL_SECONDS``
(~5 min) via the background scheduler. This endpoint lets an admin pull the
latest real-time records into the stats on demand: it rebuilds today's daily
snapshot immediately (bounded by the same semaphore/timeout the scheduler uses)
and then returns the merged leaderboard for the requested range.
"""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
(
excluded,
resolved_include_scheduled,
resolved_include_admins,
resolved_include_failed,
settings_hash,
) = _resolve_leaderboard_snapshot_scope(
settings,
exclude_user_ids=exclude_user_ids,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
today = _today_stat_date()
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(today, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=today,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=resolved_include_scheduled,
include_admins=resolved_include_admins,
include_failed=resolved_include_failed,
)
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
return await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=limit,
exclude_user_ids=exclude_user_ids,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
async def _build_leaderboard_report_prompt(
session_factory,
settings,
@@ -2248,22 +2514,182 @@ class AdminUserQaPageResponse(BaseModel):
items: list[AdminUserQaItem] = Field(default_factory=list)
@router.get("/{user_id}/qa", response_model=AdminUserQaPageResponse)
async def list_user_qa(
user_id: str,
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminUserQaPageResponse:
"""Return one user's Q&A runs from lightweight run summary fields."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Q&A audit requires database persistence")
def _qa_sort_datetime(value: str | None) -> datetime:
if not value:
return datetime.min.replace(tzinfo=BEIJING_TZ)
text_value = str(value).strip()
if text_value.endswith("Z"):
text_value = text_value[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text_value)
except ValueError:
return datetime.min.replace(tzinfo=BEIJING_TZ)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=BEIJING_TZ)
return parsed.astimezone(BEIJING_TZ)
def _qa_dedupe_key(item: AdminUserQaItem) -> tuple[str, str, str]:
question_key = _question_similarity_key(item.question)[:160]
answer_key = _question_similarity_key(item.answer)[:160]
return (item.thread_id, question_key, answer_key)
def _message_value(message: Any, key: str, default: Any = None) -> Any:
if isinstance(message, dict):
return message.get(key, default)
return getattr(message, key, default)
def _qa_message_type(message: Any) -> str:
value = _message_value(message, "type") or _message_value(message, "role") or ""
if value == "user":
return "human"
if value == "assistant":
return "ai"
return str(value)
def _qa_message_id(message: Any, fallback: str) -> str:
return str(_message_value(message, "id") or fallback)
def _qa_message_text(message: Any) -> str:
return _message_content_to_text(_message_value(message, "content")).strip()
async def _read_thread_checkpoint_messages(checkpointer, thread_id: str) -> list[dict]:
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.warning("Failed to read checkpoint while listing Q&A: thread=%s", thread_id, exc_info=True)
return []
if checkpoint_tuple is None:
return []
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
values = serialize_channel_values(channel_values)
messages = values.get("messages", [])
return messages if isinstance(messages, list) else []
def _qa_items_from_checkpoint_messages(
*,
user_id: str,
thread_id: str,
thread_title: str,
assistant_id: str,
thread_time: str,
messages: list[dict],
) -> list[AdminUserQaItem]:
items: list[AdminUserQaItem] = []
current_question = ""
current_answer = ""
current_id = ""
current_index = 0
def flush() -> None:
nonlocal current_question, current_answer, current_id, current_index
question = _clean_question_text(current_question)
if not _is_displayable_question(question):
current_question = ""
current_answer = ""
current_id = ""
return
items.append(
AdminUserQaItem(
run_id=current_id or f"{thread_id}:checkpoint:{current_index}",
thread_id=thread_id,
thread_title=thread_title,
assistant_id=assistant_id,
status="checkpoint",
question=question[:2000],
answer=current_answer.strip()[:6000],
created_at=thread_time,
updated_at=thread_time,
)
)
current_question = ""
current_answer = ""
current_id = ""
for index, message in enumerate(messages):
msg_type = _qa_message_type(message)
if msg_type == "human":
if current_question:
flush()
current_question = _qa_message_text(message)
current_answer = ""
current_id = _qa_message_id(message, f"{thread_id}:checkpoint:{index}")
current_index = index
continue
if msg_type == "ai" and current_question:
text = _qa_message_text(message)
if text:
current_answer = text
if current_question:
flush()
return items
async def _list_thread_checkpoint_qa_items(
*,
request: Request,
user_id: str,
) -> list[AdminUserQaItem]:
repo = get_thread_store(request)
checkpointer = get_checkpointer(request)
items: list[AdminUserQaItem] = []
offset = 0
page_size = 200
while True:
rows = await repo.search(limit=page_size, offset=offset, user_id=user_id)
if not rows:
break
for row in rows:
thread_id = str(row.get("thread_id") or "")
if not thread_id:
continue
messages = await _read_thread_checkpoint_messages(checkpointer, thread_id)
if not messages:
continue
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
context = row.get("context") if isinstance(row.get("context"), dict) else {}
assistant_id = str(
row.get("assistant_id")
or (context or {}).get("agent_id")
or (metadata or {}).get("agent_id")
or (context or {}).get("agent_name")
or (metadata or {}).get("agent_name")
or ""
)
thread_time = str(row.get("updated_at") or row.get("created_at") or "")
items.extend(
_qa_items_from_checkpoint_messages(
user_id=user_id,
thread_id=thread_id,
thread_title=str(row.get("display_name") or ""),
assistant_id=assistant_id,
thread_time=thread_time,
messages=messages,
)
)
if len(rows) < page_size:
break
offset += len(rows)
return items
async def _list_run_summary_qa_items(
*,
session_factory,
user_id: str,
since: str | None,
until: str | None,
) -> list[AdminUserQaItem]:
filters = [
RunRow.user_id == user_id,
RunRow.first_human_message.is_not(None),
@@ -2272,26 +2698,9 @@ async def list_user_qa(
if since or until:
since_dt, until_dt, _range_days = _resolve_range(365, since, until)
filters.append(_run_time_filter(since_dt, until_dt))
needle = (q or "").strip()
if needle:
like = f"%{needle}%"
filters.append(
or_(
RunRow.first_human_message.like(like),
RunRow.last_ai_message.like(like),
RunRow.run_id.like(like),
RunRow.thread_id.like(like),
)
)
offset = (page - 1) * page_size
async with session_factory() as session:
await _set_analytics_read_timeout(session)
total = (
await session.execute(
select(func.count(RunRow.run_id)).where(*filters)
)
).scalar_one()
rows = (
await session.execute(
select(
@@ -2316,32 +2725,152 @@ async def list_user_qa(
)
.where(*filters)
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
.limit(page_size)
.offset(offset)
)
).all()
return [
AdminUserQaItem(
run_id=r[0] or "",
thread_id=r[1] or "",
thread_title=r[2] or "",
assistant_id=r[3] or "",
status=r[4] or "",
model_name=r[5] or "",
question=_clean_question_text(r[6])[:2000],
answer=str(r[7] or "").strip()[:6000],
total_tokens=int(r[8] or 0),
created_at=_iso(r[9]),
updated_at=_iso(r[10]),
)
for r in rows
if _is_displayable_question(r[6])
]
def _filter_qa_items(
items: list[AdminUserQaItem],
*,
q: str | None,
since: str | None,
until: str | None,
) -> list[AdminUserQaItem]:
needle = (q or "").strip().lower()
since_dt = _parse_optional_datetime(since) if since else None
until_dt = _parse_optional_datetime(until) if until else None
filtered: list[AdminUserQaItem] = []
for item in items:
item_dt = _qa_sort_datetime(item.created_at or item.updated_at)
if since_dt and item_dt < since_dt:
continue
if until_dt and item_dt > until_dt:
continue
if needle:
haystack = "\n".join(
[
item.question,
item.answer,
item.run_id,
item.thread_id,
item.thread_title,
item.assistant_id,
]
).lower()
if needle not in haystack:
continue
filtered.append(item)
return filtered
async def _list_user_qa_page(
*,
request: Request,
user_id: str,
page: int,
page_size: int,
q: str | None,
since: str | None,
until: str | None,
include_checkpoints: bool = True,
) -> AdminUserQaPageResponse:
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Q&A audit requires database persistence")
items = await _list_run_summary_qa_items(
session_factory=session_factory,
user_id=user_id,
since=since,
until=until,
)
if include_checkpoints:
items.extend(await _list_thread_checkpoint_qa_items(request=request, user_id=user_id))
by_key: dict[tuple[str, str, str], AdminUserQaItem] = {}
for item in items:
key = _qa_dedupe_key(item)
existing = by_key.get(key)
if existing is None or (existing.status == "checkpoint" and item.status != "checkpoint"):
by_key[key] = item
filtered = _filter_qa_items(list(by_key.values()), q=q, since=since, until=until)
filtered.sort(
key=lambda item: (_qa_sort_datetime(item.created_at or item.updated_at), item.thread_id, item.run_id),
reverse=True,
)
offset = (page - 1) * page_size
return AdminUserQaPageResponse(
user_id=user_id,
page=page,
page_size=page_size,
total=int(total or 0),
items=[
AdminUserQaItem(
run_id=r[0] or "",
thread_id=r[1] or "",
thread_title=r[2] or "",
assistant_id=r[3] or "",
status=r[4] or "",
model_name=r[5] or "",
question=_clean_question_text(r[6])[:2000],
answer=str(r[7] or "").strip()[:6000],
total_tokens=int(r[8] or 0),
created_at=_iso(r[9]),
updated_at=_iso(r[10]),
)
for r in rows
],
total=len(filtered),
items=filtered[offset : offset + page_size],
)
@router.get("/{user_id}/qa", response_model=AdminUserQaPageResponse)
async def list_user_qa(
user_id: str,
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminUserQaPageResponse:
"""Return one user's Q&A runs, including older checkpoint-only threads."""
await _require_admin(request)
return await _list_user_qa_page(
request=request,
user_id=user_id,
page=page,
page_size=page_size,
q=q,
since=since,
until=until,
)
@self_router.get("/qa", response_model=AdminUserQaPageResponse)
async def list_my_qa(
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
q: str | None = Query(default=None, description="Search question, answer, run id, or thread id"),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
) -> AdminUserQaPageResponse:
"""Return the current user's own Q&A history."""
user = getattr(request.state, "user", None)
if user is None:
user = await get_current_user_from_request(request)
return await _list_user_qa_page(
request=request,
user_id=str(user.id),
page=page,
page_size=page_size,
q=q,
since=since,
until=until,
)
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
@@ -39,6 +39,7 @@ class AgentResponse(BaseModel):
name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.")
description: str = Field(default="", description="Agent description")
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
published: bool = Field(default=False, description="Whether a user-created agent is public")
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
builtin: bool = Field(default=False, description="True when user_id is null")
@@ -62,15 +63,15 @@ class AgentResponse(BaseModel):
is_favorite: bool = Field(
default=False,
description="Whether the requesting user has favorited (收藏) this agent.",
)
is_pinned: bool = Field(
default=False,
description="Whether the requesting user has pinned this agent.",
)
class AgentsListResponse(BaseModel):
)
is_pinned: bool = Field(
default=False,
description="Whether the requesting user has pinned this agent.",
)
class AgentsListResponse(BaseModel):
"""Response model for listing agents.
``total`` / ``page`` / ``page_size`` describe server-side pagination. When
@@ -115,7 +116,7 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
class AgentSelectorResponse(BaseModel):
class AgentSelectorResponse(BaseModel):
"""Response for the homepage agent selector dropdown.
The list is fully curated by admins (``featured_order`` on each row).
@@ -125,10 +126,10 @@ class AgentSelectorResponse(BaseModel):
agents: list[AgentResponse] = Field(
default_factory=list,
description="Admin-curated agents, sorted by featured_order ascending.",
)
class AgentFeaturedRequest(BaseModel):
)
class AgentFeaturedRequest(BaseModel):
"""Request body for setting/clearing an agent's homepage featured rank."""
order: int | None = Field(
@@ -159,6 +160,41 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin"
def _email_local_part(email: str | None) -> str | None:
"""Return the username portion (before ``@``) of an email address."""
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
async def _resolve_usernames(user_ids: set[str]) -> dict[str, str]:
"""Batch-resolve owner user ids to friendly usernames (email local part).
One lookup per distinct id; tolerates missing accounts (deleted users) and
an uninitialised auth provider by simply omitting those ids from the map.
"""
user_ids = {uid for uid in user_ids if uid}
if not user_ids:
return {}
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception:
return {}
names: dict[str, str] = {}
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
return names
def _validate_display_name(name: str) -> str:
normalized = name.strip()
if not normalized:
@@ -348,7 +384,7 @@ def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
def _record_to_response(
def _record_to_response(
record: dict[str, Any],
*,
include_soul: bool = False,
@@ -390,21 +426,21 @@ def _record_to_response(
tool_groups=extras["tool_groups"],
skills=extras["skills"],
featured_order=record.get("featured_order"),
)
def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]:
reverse = sort not in {"time_asc", "updated_asc", "name_asc"}
if sort in {"name_asc", "name_desc"}:
ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
elif sort in {"updated_desc", "updated_asc"}:
ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse)
else:
ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse)
return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1)
async def _record_to_response_async(
)
def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]:
reverse = sort not in {"time_asc", "updated_asc", "name_asc"}
if sort in {"name_asc", "name_desc"}:
ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
elif sort in {"updated_desc", "updated_asc"}:
ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse)
else:
ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse)
return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1)
async def _record_to_response_async(
record: dict[str, Any],
*,
include_soul: bool = False,
@@ -478,20 +514,23 @@ async def _build_list_responses(
_record_to_response(a, include_soul=False, include_extras=False) for a in records
]
ids = [r.id for r in responses]
extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
for resp in responses:
extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
usernames = await _resolve_usernames({r.user_id for r in responses if r.user_id})
for resp in responses:
cached = extras.get(resp.id)
if cached is not None:
resp.skills = cached.get("skills")
resp.tool_groups = cached.get("tool_groups")
resp.model = cached.get("model")
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
resp.is_pinned = resp.id in pin_ids
return responses
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
resp.is_pinned = resp.id in pin_ids
if resp.user_id:
resp.owner_username = usernames.get(resp.user_id)
return responses
@router.get(
@@ -509,14 +548,18 @@ async def list_agents(
pattern="^(mine|square)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
),
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
),
admin: bool = Query(
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
),
owner: str | None = Query(
default=None,
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
),
admin: bool = Query(
default=False,
description=(
"Admin-only override. When ``true`` and the caller is a system admin, "
@@ -543,11 +586,12 @@ async def list_agents(
scope=scope,
search=search,
tag_ids=tag_ids,
page=eff_page,
page_size=page_size,
admin=admin,
sort=sort,
)
page=eff_page,
page_size=page_size,
admin=admin,
sort=sort,
owner=owner,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
@@ -559,11 +603,13 @@ async def list_agents(
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
if owner and owner.strip():
responses = [r for r in responses if r.user_id == owner.strip()]
responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
raise
except Exception as e:
@@ -576,14 +622,14 @@ async def list_agents(
summary="Check Agent Name",
description="Validate an agent display name. Duplicate names are allowed.",
)
async def check_agent_name(name: str) -> dict[str, Any]:
_require_agents_api_enabled()
normalized = _validate_display_name(name)
return {"available": True, "name": normalized}
@router.get(
"/agents/selector",
async def check_agent_name(name: str) -> dict[str, Any]:
_require_agents_api_enabled()
normalized = _validate_display_name(name)
return {"available": True, "name": normalized}
@router.get(
"/agents/selector",
response_model=AgentSelectorResponse,
summary="Agent Homepage Selector",
description=(
@@ -635,12 +681,12 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent
# detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
response.is_favorite = agent_id in favorite_ids
response.is_pinned = agent_id in pin_ids
return response
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
response.is_favorite = agent_id in favorite_ids
response.is_pinned = agent_id in pin_ids
return response
except HTTPException:
raise
except Exception as e:
@@ -874,16 +920,16 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
try:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass
try:
await store.delete_pins_for_agent(agent_id)
except Exception: # noqa: BLE001 - pin cleanup must not block deletion
pass
try:
await store.delete_extras_for_agent(agent_id)
try:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass
try:
await store.delete_pins_for_agent(agent_id)
except Exception: # noqa: BLE001 - pin cleanup must not block deletion
pass
try:
await store.delete_extras_for_agent(agent_id)
except Exception: # noqa: BLE001 — cache cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
@@ -925,7 +971,7 @@ async def favorite_agent(agent_id: str, request: Request) -> None:
summary="Unfavorite Agent",
description="Remove an agent from the current user's favorites. No-op when not favorited.",
)
async def unfavorite_agent(agent_id: str, request: Request) -> None:
async def unfavorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
@@ -936,60 +982,60 @@ async def unfavorite_agent(agent_id: str, request: Request) -> None:
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.post(
"/agents/{agent_id}/pin",
status_code=204,
summary="Pin Agent",
description="Pin a visible agent to the top of the current user's lists. Idempotent.",
)
async def pin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
await store.add_pin(user_id, agent_id)
logger.info("User '%s' pinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e
@router.delete(
"/agents/{agent_id}/pin",
status_code=204,
summary="Unpin Agent",
description="Remove the current user's pin from an agent. No-op when not pinned.",
)
async def unpin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
await store.remove_pin(user_id, agent_id)
logger.info("User '%s' unpinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.post(
"/agents/{agent_id}/pin",
status_code=204,
summary="Pin Agent",
description="Pin a visible agent to the top of the current user's lists. Idempotent.",
)
async def pin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
await store.add_pin(user_id, agent_id)
logger.info("User '%s' pinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e
@router.delete(
"/agents/{agent_id}/pin",
status_code=204,
summary="Unpin Agent",
description="Remove the current user's pin from an agent. No-op when not pinned.",
)
async def unpin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
await store.remove_pin(user_id, agent_id)
logger.info("User '%s' unpinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
response_model=AgentResponse,
summary="Set Agent Featured Rank (admin)",
description=(
@@ -1031,19 +1077,20 @@ async def set_agent_featured(
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class PrivateSquareListRequest(BaseModel):
class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).")
tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).")
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
sort: str = Field(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
)
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
sort: str = Field(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
)
owner: str | None = Field(default=None, description="Keep only agents published by this user id (the card's publisher).")
class AgentPrivateUpdateRequest(BaseModel):
@@ -1086,17 +1133,18 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
scope="private",
search=body.search,
tag_ids=body.tag_ids,
page=body.page,
page_size=body.page_size,
sort=body.sort,
)
page=body.page,
page_size=body.page_size,
sort=body.sort,
owner=body.owner,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
records = await store.list_private()
responses = await _build_list_responses(store, tag_store, user_id, records)
responses = _sort_agent_responses(responses, body.sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
responses = await _build_list_responses(store, tag_store, user_id, records)
responses = _sort_agent_responses(responses, body.sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
raise
except Exception as e:
@@ -0,0 +1,590 @@
"""Browser page context capture endpoints.
This router is intentionally isolated behind ``config.yaml``:
browser_context:
enabled: true
When disabled, mutating endpoints behave as if the integration is unavailable.
"""
from __future__ import annotations
import asyncio
import logging
import re
from datetime import UTC, datetime
from typing import Any, Literal
from urllib.parse import urlencode
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer, get_config, get_thread_store
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.routers.threads import ThreadCreateRequest, create_thread
from app.gateway.routers.uploads import _make_file_sandbox_writable
from app.gateway.services import start_run
from deerflow.browser_context.tasks import claim_next_browser_task, complete_browser_task, prune_browser_tasks
from deerflow.config.app_config import AppConfig
from deerflow.config.browser_context_config import BrowserContextConfig
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.uploads.manager import (
UnsafeUploadPathError,
ensure_uploads_dir,
normalize_filename,
open_upload_file_no_symlink,
upload_artifact_url,
upload_virtual_path,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser-context", tags=["browser-context"])
DEFAULT_BROWSER_CONTEXT_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"
class BrowserContextStatusResponse(BaseModel):
enabled: bool
max_content_chars: int
max_title_chars: int
max_url_chars: int
task_result_timeout_seconds: int
task_poll_interval_seconds: float
task_lease_seconds: int
tool_result_max_chars: int
class BrowserContextCaptureRequest(BaseModel):
thread_id: str | None = Field(default=None, description="Existing or caller-generated zncm thread id")
title: str | None = Field(default=None, description="Current browser page title")
url: str | None = Field(default=None, description="Current browser page URL")
content: str = Field(description="Captured page content, preferably Markdown")
content_type: Literal["markdown", "text"] = Field(default="markdown")
prompt: str | None = Field(default=None, description="Optional first question to ask in zncm after capture")
class BrowserContextFileInfo(BaseModel):
filename: str
size: int
path: str
virtual_path: str
artifact_url: str
class BrowserContextCaptureResponse(BaseModel):
success: bool
thread_id: str
file: BrowserContextFileInfo
chat_path: str
chat_url: str
browser_context_token: str
prompt: str
truncated: bool = False
message: str
class BrowserContextAskRequest(BaseModel):
thread_id: str | None = Field(default=None, description="Existing zncm thread id for follow-up questions")
title: str | None = Field(default=None, description="Current browser page title")
url: str | None = Field(default=None, description="Current browser page URL")
content: str = Field(description="Captured page content, preferably Markdown")
content_type: Literal["markdown", "text"] = Field(default="markdown")
question: str = Field(description="Question asked from the in-page assistant")
class BrowserContextAskResponse(BaseModel):
success: bool
thread_id: str
run_id: str
answer: str
truncated: bool = False
message: str
class BrowserContextTaskPayload(BaseModel):
id: str
kind: str = "fetch"
url: str
instruction: str = ""
session_id: str = ""
action: dict[str, Any] | None = None
created_at: str
updated_at: str
max_content_chars: int
class BrowserContextTaskNextResponse(BaseModel):
task: BrowserContextTaskPayload | None = None
class BrowserContextTaskResultRequest(BaseModel):
kind: str = Field(default="fetch", description="Task kind the extension processed: fetch or act")
title: str | None = Field(default=None, description="Captured page title")
url: str | None = Field(default=None, description="Final page URL after redirects")
content: str | None = Field(default=None, description="Captured page content")
content_type: Literal["markdown", "text"] = Field(default="markdown")
observation: dict[str, Any] | None = Field(default=None, description="Structured observation for an act step (elements, text, status)")
error: str | None = Field(default=None, description="Extension-side task error")
metadata: dict[str, Any] = Field(default_factory=dict)
class BrowserContextTaskResultResponse(BaseModel):
success: bool
task_id: str
status: str
truncated: bool = False
def _settings(config: AppConfig) -> BrowserContextConfig:
value = getattr(config, "browser_context", None)
if isinstance(value, BrowserContextConfig):
return value
if isinstance(value, dict):
return BrowserContextConfig.model_validate(value)
return BrowserContextConfig()
def _require_enabled(config: AppConfig) -> BrowserContextConfig:
settings = _settings(config)
if not settings.enabled:
raise HTTPException(status_code=404, detail="Browser context integration is disabled")
return settings
def _trim(value: str | None, limit: int) -> str:
if not value:
return ""
return value.strip()[:limit]
def _limit_utf8_bytes(value: str, max_bytes: int) -> str:
text = value
while len(text.encode("utf-8")) > max_bytes:
text = text[:-1]
return text
def _filename_part(value: str, fallback: str, *, max_bytes: int) -> str:
text = value.strip() or fallback
# Keep unicode word chars so Chinese titles remain readable, while removing
# path separators and shell-hostile punctuation.
text = re.sub(r"[^\w.-]+", "-", text, flags=re.UNICODE).strip("-._")
text = _limit_utf8_bytes(text, max_bytes)
return text or fallback
def _build_markdown(
*,
title: str,
url: str,
content: str,
content_type: str,
captured_at: str,
truncated: bool,
) -> str:
lines = [
f"# {title or 'Captured Web Page'}",
"",
f"- Source URL: {url or '(unknown)'}",
f"- Captured at: {captured_at}",
]
if truncated:
lines.append("- Note: Content was truncated by the browser_context.max_content_chars limit.")
lines.extend(["", "---", ""])
if content_type == "markdown":
lines.append(content.strip())
else:
lines.append("```text")
lines.append(content.strip())
lines.append("```")
lines.append("")
return "\n".join(lines)
def _build_ask_prompt(
*,
title: str,
url: str,
content: str,
content_type: str,
captured_at: str,
truncated: bool,
) -> str:
note = "页面内容已按配置截断,请只基于可见内容回答。" if truncated else "页面内容如下。"
body = content.strip()
if content_type == "text":
body = f"```text\n{body}\n```"
return "\n\n".join(
[
"你是 zncm 页面问答助手。请直接基于当前网页内容回答用户问题。",
"如果网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
f"页面标题:{title or '未知标题'}",
f"页面 URL:{url or '未知 URL'}",
f"读取时间:{captured_at}",
f"说明:{note}",
"当前网页内容:",
body,
]
)
def _message_type(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("type") or message.get("role") or "").lower()
return str(getattr(message, "type", "") or getattr(message, "role", "") or "").lower()
def _content_to_text(content: Any) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
if isinstance(item.get("text"), str):
parts.append(item["text"])
elif item.get("type") == "text" and isinstance(item.get("content"), str):
parts.append(item["content"])
return "\n".join(part.strip() for part in parts if part and part.strip()).strip()
return ""
def _latest_ai_answer(values: dict[str, Any]) -> str:
messages = values.get("messages")
if not isinstance(messages, list):
return ""
for message in reversed(messages):
if _message_type(message) not in {"ai", "assistant"}:
continue
answer = _content_to_text(message.get("content") if isinstance(message, dict) else getattr(message, "content", None))
if answer:
return answer
return ""
async def _sync_to_sandbox_if_needed(thread_id: str, virtual_path: str, file_path, data: bytes) -> None:
sandbox_provider = get_sandbox_provider()
if bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)):
return
sandbox_id = sandbox_provider.acquire(thread_id)
sandbox = sandbox_provider.get(sandbox_id)
if sandbox is None:
raise HTTPException(status_code=500, detail="Failed to acquire sandbox")
_make_file_sandbox_writable(file_path)
sandbox.update_file(virtual_path, data)
@router.get("/status", response_model=BrowserContextStatusResponse)
async def get_browser_context_status(
config: AppConfig = Depends(get_config),
) -> BrowserContextStatusResponse:
settings = _settings(config)
return BrowserContextStatusResponse(
enabled=settings.enabled,
max_content_chars=settings.max_content_chars,
max_title_chars=settings.max_title_chars,
max_url_chars=settings.max_url_chars,
task_result_timeout_seconds=settings.task_result_timeout_seconds,
task_poll_interval_seconds=settings.task_poll_interval_seconds,
task_lease_seconds=settings.task_lease_seconds,
tool_result_max_chars=settings.tool_result_max_chars,
)
@router.get("/tasks/next", response_model=BrowserContextTaskNextResponse)
@require_permission("runs", "create")
async def claim_browser_context_task(
request: Request,
worker_id: str | None = None,
config: AppConfig = Depends(get_config),
) -> BrowserContextTaskNextResponse:
settings = _require_enabled(config)
prune_browser_tasks()
task = claim_next_browser_task(
user_id=get_effective_user_id(),
worker_id=worker_id or "chrome-extension",
lease_seconds=settings.task_lease_seconds,
)
if task is None:
return BrowserContextTaskNextResponse(task=None)
return BrowserContextTaskNextResponse(task=BrowserContextTaskPayload.model_validate(task))
@router.post("/tasks/{task_id}/result", response_model=BrowserContextTaskResultResponse)
@require_permission("runs", "create")
async def submit_browser_context_task_result(
task_id: str,
body: BrowserContextTaskResultRequest,
request: Request,
config: AppConfig = Depends(get_config),
) -> BrowserContextTaskResultResponse:
settings = _require_enabled(config)
error = _trim(body.error, 4000)
content = body.content or ""
is_act = (body.kind or "fetch") == "act"
if not error and not is_act and not content.strip():
raise HTTPException(status_code=400, detail="Captured page content is empty")
if not error and is_act and not isinstance(body.observation, dict):
raise HTTPException(status_code=400, detail="Act task result requires an observation")
task = complete_browser_task(
task_id=task_id,
user_id=get_effective_user_id(),
title=_trim(body.title, settings.max_title_chars),
url=_trim(body.url, settings.max_url_chars),
content=content,
content_type=body.content_type,
error=error,
metadata=body.metadata,
observation=body.observation,
)
if task is None:
raise HTTPException(status_code=404, detail=f"Browser task {task_id} not found")
result = task.get("result") or {}
return BrowserContextTaskResultResponse(
success=True,
task_id=task_id,
status=str(task.get("status") or "unknown"),
truncated=bool(result.get("truncated")),
)
@router.post("/capture", response_model=BrowserContextCaptureResponse)
@require_permission("threads", "write")
async def capture_browser_context(
body: BrowserContextCaptureRequest,
request: Request,
config: AppConfig = Depends(get_config),
) -> BrowserContextCaptureResponse:
settings = _require_enabled(config)
raw_content = (body.content or "").strip()
if not raw_content:
raise HTTPException(status_code=400, detail="Captured page content is empty")
title = _trim(body.title, settings.max_title_chars)
url = _trim(body.url, settings.max_url_chars)
truncated = len(raw_content) > settings.max_content_chars
content = raw_content[: settings.max_content_chars] if truncated else raw_content
requested_thread_id = (body.thread_id or "").strip() or None
if requested_thread_id:
thread_store = get_thread_store(request)
allowed = await thread_store.check_access(
requested_thread_id,
get_effective_user_id(),
require_existing=False,
)
if not allowed:
raise HTTPException(status_code=404, detail=f"Thread {requested_thread_id} not found")
thread_response = await create_thread(
ThreadCreateRequest(
thread_id=requested_thread_id,
metadata={
"source": "browser_context",
"browser_context_url": url,
"browser_context_title": title,
},
),
request,
)
thread_id = thread_response.thread_id
captured_at = datetime.now(UTC).isoformat()
markdown = _build_markdown(
title=title,
url=url,
content=content,
content_type=body.content_type,
captured_at=captured_at,
truncated=truncated,
)
data = markdown.encode("utf-8")
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
prefix = _filename_part(settings.filename_prefix, "webpage", max_bytes=40)
title_part = _filename_part(title, "page", max_bytes=120)
filename = normalize_filename(f"{prefix}-{timestamp}-{title_part}.md")
try:
uploads_dir = ensure_uploads_dir(thread_id)
file_path, fh = open_upload_file_no_symlink(uploads_dir, filename)
with fh:
fh.write(data)
except UnsafeUploadPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Failed to save browser context capture for thread %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to save captured page") from exc
virtual_path = upload_virtual_path(filename)
await _sync_to_sandbox_if_needed(thread_id, virtual_path, file_path, data)
prompt = (body.prompt or "").strip() or DEFAULT_BROWSER_CONTEXT_PROMPT
token = uuid4().hex
route_base = settings.route_base.rstrip("/") or "/page/workspace"
chat_path = f"{route_base}/chats/{thread_id}"
query = urlencode(
{
"browser_context": token,
"bc_filename": filename,
"bc_size": str(len(data)),
"bc_path": virtual_path,
"bc_prompt": prompt,
}
)
chat_url = f"#{chat_path}?{query}"
return BrowserContextCaptureResponse(
success=True,
thread_id=thread_id,
file=BrowserContextFileInfo(
filename=filename,
size=len(data),
path=virtual_path,
virtual_path=virtual_path,
artifact_url=upload_artifact_url(thread_id, filename),
),
chat_path=chat_path,
chat_url=chat_url,
browser_context_token=token,
prompt=prompt,
truncated=truncated,
message="Captured page saved as a zncm upload",
)
@router.post("/ask", response_model=BrowserContextAskResponse)
@require_permission("runs", "create")
async def ask_browser_context(
body: BrowserContextAskRequest,
request: Request,
config: AppConfig = Depends(get_config),
) -> BrowserContextAskResponse:
settings = _require_enabled(config)
raw_content = (body.content or "").strip()
if not raw_content:
raise HTTPException(status_code=400, detail="Captured page content is empty")
question = (body.question or "").strip()
if not question:
raise HTTPException(status_code=400, detail="Question is empty")
title = _trim(body.title, settings.max_title_chars)
url = _trim(body.url, settings.max_url_chars)
content_limit = max(1_000, min(settings.max_content_chars, settings.tool_result_max_chars))
truncated = len(raw_content) > content_limit
content = raw_content[:content_limit] if truncated else raw_content
requested_thread_id = (body.thread_id or "").strip() or None
if requested_thread_id:
thread_store = get_thread_store(request)
allowed = await thread_store.check_access(
requested_thread_id,
get_effective_user_id(),
require_existing=False,
)
if not allowed:
raise HTTPException(status_code=404, detail=f"Thread {requested_thread_id} not found")
thread_response = await create_thread(
ThreadCreateRequest(
thread_id=requested_thread_id,
assistant_id="lead_agent",
metadata={
"source": "browser_context_page_assistant",
"browser_context_url": url,
"browser_context_title": title,
},
),
request,
)
thread_id = thread_response.thread_id
captured_at = datetime.now(UTC).isoformat()
is_follow_up = requested_thread_id is not None
if is_follow_up:
prompt_prefix = "\n\n".join(
[
"你是 zncm 页面问答助手。请继续基于本会话里已经读取的网页内容回答用户问题。",
"如果本会话里的网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
f"当前页面标题:{title or '未知标题'}",
f"当前页面 URL:{url or '未知 URL'}",
f"本轮提问时间:{captured_at}",
]
)
else:
prompt_prefix = _build_ask_prompt(
title=title,
url=url,
content=content,
content_type=body.content_type,
captured_at=captured_at,
truncated=truncated,
)
run_body = RunCreateRequest(
assistant_id="lead_agent",
input={
"messages": [
{
"type": "human",
"content": question,
"additional_kwargs": {
"prompt_prefix": prompt_prefix,
},
}
]
},
metadata={
"source": "browser_context_page_assistant",
"browser_context_url": url,
"browser_context_title": title,
"browser_context_truncated": truncated,
},
stream_mode=["values"],
on_disconnect="continue",
multitask_strategy="reject",
)
record = await start_run(run_body, thread_id, request)
if record.task is not None:
try:
await record.task
except asyncio.CancelledError:
pass
checkpointer = get_checkpointer(request)
values: dict[str, Any] = {}
try:
checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}})
if checkpoint_tuple is not None:
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
except Exception:
logger.exception("Failed to fetch browser context answer for run %s", record.run_id)
answer = _latest_ai_answer(values)
if not answer:
answer = record.error or "zncm did not return an answer."
return BrowserContextAskResponse(
success=bool(answer),
thread_id=thread_id,
run_id=record.run_id,
answer=answer,
truncated=truncated,
message="zncm page assistant answer completed",
)
@@ -0,0 +1,521 @@
"""Knowledge base API (``/api/knowledge``).
Sediments threads / searches into an obsidian-wiki compatible vault, mirrors
metadata into the database, and serves listing, detail, edit, delete, search
and graph export. The knowledge base is **global / shared** — no per-user
isolation; writes record ``created_by`` / ``updated_by`` for audit.
All obsidian-wiki specifics live in :mod:`deerflow.knowledge`; this router only
adapts HTTP ↔ the :class:`~deerflow.knowledge.service.KnowledgeService`.
"""
from __future__ import annotations
import logging
import mimetypes
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.gateway.authz import require_auth, require_permission
from app.gateway.deps import get_checkpointer, get_knowledge_service, get_local_provider, get_thread_store
from app.gateway.utils import sanitize_log_param
from deerflow.knowledge.service import EmptyThreadError
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import build_reference_batches
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
# --------------------------------------------------------------------------- #
# Request / response models
# --------------------------------------------------------------------------- #
class IngestThreadRequest(BaseModel):
mode: str = Field(default="summary", description="summary | full")
title: str | None = None
status: str = Field(default="approved", description="draft | approved")
tags: list[str] = Field(default_factory=list)
include_sources: bool = True
background: bool = Field(
default=False,
description="When true, sediment the thread asynchronously in a background task and return immediately.",
)
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置")
class BatchIngestRequest(BaseModel):
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
status: str = "approved"
include_sources: bool = True
skip_existing: bool = True
mode: str = "summary"
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置")
class SearchResultItem(BaseModel):
title: str | None = None
url: str | None = None
snippet: str | None = None
tool_name: str | None = None
score: float | None = None
class IngestSearchRequest(BaseModel):
query: str
results: list[SearchResultItem] = Field(default_factory=list)
thread_id: str | None = None
message_id: str | None = None
title: str | None = None
tags: list[str] = Field(default_factory=list)
status: str = "approved"
class CreateNoteRequest(BaseModel):
title: str
content_md: str
summary: str | None = None
tags: list[str] = Field(default_factory=list)
status: str = "approved"
class UpdateNoteRequest(BaseModel):
title: str | None = None
content_md: str | None = None
summary: str | None = None
tags: list[str] | None = None
status: str | None = None
class SearchRequest(BaseModel):
query: str
mode: str = Field(default="keyword", description="keyword | vector | hybrid (vector/hybrid fall back to keyword in v1)")
limit: int = Field(default=8, ge=1, le=50)
class ResolveWikilinksRequest(BaseModel):
targets: list[str] = Field(default_factory=list, description="`[[wikilink]]` targets to resolve to note ids / vault pages.")
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
async def _read_thread_values(checkpointer, thread_id: str) -> dict[str, Any]:
"""Return serialized channel values for a thread, or raise 404/500."""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to read thread %s for knowledge ingest", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to read thread state")
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
return serialize_channel_values(checkpoint.get("channel_values", {}) or {})
async def _enrich_actor_names(note: dict[str, Any]) -> None:
"""Resolve created_by/updated_by user ids to a human label (email).
Adds ``created_by_name`` / ``updated_by_name`` to the note dict so the UI can
show a person instead of a raw UUID. Best-effort: unresolved ids fall back to
``None`` and the frontend then shows a shortened id.
"""
provider = get_local_provider()
cache: dict[str, str | None] = {}
async def name_of(uid: str | None) -> str | None:
if not uid:
return None
if uid not in cache:
try:
user = await provider.get_user(uid)
cache[uid] = user.email if user else None
except Exception:
cache[uid] = None
return cache[uid]
note["created_by_name"] = await name_of(note.get("created_by"))
note["updated_by_name"] = await name_of(note.get("updated_by"))
def _messages_and_meta(values: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]:
messages = values.get("messages")
if not isinstance(messages, list):
messages = []
title = values.get("title") if isinstance(values.get("title"), str) else None
batches = build_reference_batches(messages) if messages else []
return messages, title, batches
async def _ingest_thread_background(service, checkpointer, thread_id: str, body: IngestThreadRequest, created_by: str | None) -> None:
"""Sediment a thread into a note without an HTTP context.
Runs after the response is sent (FastAPI ``BackgroundTasks``); ``created_by``
must be captured by the request handler since the user contextvar is gone
by the time this executes. All failures are swallowed (logged) — the user
has already been told the save was queued.
"""
try:
values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values)
if not messages:
logger.warning("Background knowledge ingest skipped: thread %s has no messages", sanitize_log_param(thread_id))
return
await service.capture_thread(
thread_id=thread_id,
messages=messages,
thread_title=title,
mode=body.mode,
title=body.title,
status=body.status,
tags=body.tags,
include_sources=body.include_sources,
reference_batches=batches,
created_by=created_by,
model_name=body.model_name,
)
except EmptyThreadError:
logger.warning("Background knowledge ingest skipped: thread %s has no sediment-able content", sanitize_log_param(thread_id))
except Exception:
logger.exception("Background knowledge ingest failed for thread %s", sanitize_log_param(thread_id))
# --------------------------------------------------------------------------- #
# Ingest
# --------------------------------------------------------------------------- #
@router.post("/ingest/thread/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check=True)
async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Request, background_tasks: BackgroundTasks) -> dict[str, Any]:
"""Sediment a single thread into a knowledge note.
With ``background=true`` the heavy capture (read checkpoint → extract →
write vault + DB) is deferred to a background task and the endpoint returns
``{"queued": true}`` immediately, so the user's "保存" click never blocks.
"""
service = get_knowledge_service(request)
checkpointer = get_checkpointer(request)
if body.background:
# Capture the user id now — the contextvar is gone once the task runs.
background_tasks.add_task(
_ingest_thread_background,
service,
checkpointer,
thread_id,
body,
get_effective_user_id(),
)
return {"queued": True}
values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values)
if not messages:
raise HTTPException(status_code=400, detail="Thread has no messages to sediment")
try:
result = await service.capture_thread(
thread_id=thread_id,
messages=messages,
thread_title=title,
mode=body.mode,
title=body.title,
status=body.status,
tags=body.tags,
include_sources=body.include_sources,
reference_batches=batches,
created_by=get_effective_user_id(),
model_name=body.model_name,
)
except EmptyThreadError:
raise HTTPException(status_code=400, detail="Thread has no sediment-able content")
return {
"note": result["note"],
"created": result["created"],
"notes": result.get("notes", [result["note"]]),
"llm_fallback": result.get("llm_fallback", False),
}
@router.post("/ingest/threads/batch")
@require_auth
async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> dict[str, Any]:
"""Batch-sediment the caller's recent threads into knowledge notes."""
service = get_knowledge_service(request)
checkpointer = get_checkpointer(request)
thread_store = get_thread_store(request)
threads = await thread_store.search(limit=body.limit, offset=body.offset)
created = skipped = failed = 0
# Notes that landed via the rule-based fallback because the chosen model
# produced nothing usable (timeout / API error / non-JSON). Surfaced so the
# caller can tell the user "X 条因大模型异常改用了规则抽取".
llm_fallback = 0
items: list[dict[str, Any]] = []
for meta in threads:
thread_id = meta.get("thread_id")
if not thread_id:
continue
try:
values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values)
if not messages:
skipped += 1
items.append({"thread_id": thread_id, "note_id": None, "status": "skipped"})
continue
result = await service.capture_thread(
thread_id=thread_id,
messages=messages,
thread_title=title or meta.get("display_name"),
mode=body.mode,
status=body.status,
include_sources=body.include_sources,
reference_batches=batches,
created_by=get_effective_user_id(),
skip_existing=body.skip_existing,
model_name=body.model_name,
)
if result.get("llm_fallback"):
llm_fallback += 1
if result["created"]:
created += 1
items.append({"thread_id": thread_id, "note_id": result["note"]["id"], "status": "created", "llm_fallback": bool(result.get("llm_fallback"))})
else:
skipped += 1
items.append({"thread_id": thread_id, "note_id": result["note"]["id"], "status": "skipped"})
except EmptyThreadError:
skipped += 1
items.append({"thread_id": thread_id, "note_id": None, "status": "skipped"})
except HTTPException:
failed += 1
items.append({"thread_id": thread_id, "note_id": None, "status": "failed"})
except Exception:
logger.exception("Batch ingest failed for thread %s", sanitize_log_param(str(thread_id)))
failed += 1
items.append({"thread_id": thread_id, "note_id": None, "status": "failed"})
return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "llm_fallback": llm_fallback, "items": items}
@router.post("/ingest/search")
@require_auth
async def ingest_search(body: IngestSearchRequest, request: Request) -> dict[str, Any]:
"""Sediment search/tool results as a reference note."""
service = get_knowledge_service(request)
note = await service.capture_search(
query=body.query,
results=[r.model_dump() for r in body.results],
thread_id=body.thread_id,
message_id=body.message_id,
title=body.title,
tags=body.tags,
status=body.status,
created_by=get_effective_user_id(),
)
return {"note": note}
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@router.post("/notes")
@require_auth
async def create_note(body: CreateNoteRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
note = await service.create_manual_note(
title=body.title,
content_md=body.content_md,
summary=body.summary,
tags=body.tags,
status=body.status,
created_by=get_effective_user_id(),
)
return {"note": note}
@router.get("/notes")
@require_auth
async def list_notes(
request: Request,
keyword: str | None = None,
tag: str | None = None,
source_type: str | None = None,
status: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
service = get_knowledge_service(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
return await service.list_notes(keyword=keyword, tag=tag, source_type=source_type, status=status, limit=limit, offset=offset)
@router.get("/notes/{note_id}")
@require_auth
async def get_note(note_id: str, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
note = await service.get_note(note_id)
if note is None:
raise HTTPException(status_code=404, detail="Knowledge note not found")
await _enrich_actor_names(note)
return note
@router.put("/notes/{note_id}")
@require_auth
async def update_note(note_id: str, body: UpdateNoteRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
note = await service.update_note(
note_id,
title=body.title,
content_md=body.content_md,
summary=body.summary,
tags=body.tags,
status=body.status,
updated_by=get_effective_user_id(),
)
if note is None:
raise HTTPException(status_code=404, detail="Knowledge note not found")
return note
@router.delete("/notes/{note_id}")
@require_auth
async def delete_note(note_id: str, request: Request, hard: bool = False) -> dict[str, Any]:
service = get_knowledge_service(request)
ok = await service.delete_note(note_id, updated_by=get_effective_user_id(), hard=hard)
if not ok:
raise HTTPException(status_code=404, detail="Knowledge note not found")
return {"ok": True, "note_id": note_id}
# --------------------------------------------------------------------------- #
# Search & graph
# --------------------------------------------------------------------------- #
@router.post("/search")
@require_auth
async def search(body: SearchRequest, request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
items = await service.search(body.query, mode=body.mode, limit=body.limit)
return {"items": items}
# --------------------------------------------------------------------------- #
# Wikilinks ([[...]] resolution + vault meta-page viewer)
# --------------------------------------------------------------------------- #
@router.post("/resolve")
@require_auth
async def resolve_wikilinks(body: ResolveWikilinksRequest, request: Request) -> dict[str, Any]:
"""Resolve ``[[wikilink]]`` targets to navigable destinations.
Each target resolves to a knowledge note (``kind=note``), a vault meta/hub
page (``kind=vault``), or nothing (``kind=missing``). Powers clickable
wikilinks in the knowledge UI.
"""
service = get_knowledge_service(request)
return {"resolved": await service.resolve_wikilinks(body.targets)}
@router.get("/notes/{note_id}/wikilinks")
@require_auth
async def resolve_note_wikilinks(note_id: str, request: Request) -> dict[str, Any]:
"""Resolve every ``[[wikilink]]`` found in a note's body in one call."""
service = get_knowledge_service(request)
resolved = await service.resolve_note_wikilinks(note_id)
if resolved is None:
raise HTTPException(status_code=404, detail="Knowledge note not found")
return {"resolved": resolved}
@router.get("/vault-page")
@require_auth
async def get_vault_page(request: Request, path: str) -> dict[str, Any]:
"""Return a raw vault Markdown meta/hub page (no DB note) by its path."""
service = get_knowledge_service(request)
page = service.read_vault_page(path)
if page is None:
raise HTTPException(status_code=404, detail="Vault page not found")
return page
class MergeRequest(BaseModel):
primary_id: str
duplicate_ids: list[str] = Field(default_factory=list)
@router.get("/duplicates")
@require_auth
async def list_duplicates(request: Request) -> dict[str, Any]:
"""Return suggested duplicate groups (phase 3, heuristic)."""
service = get_knowledge_service(request)
return {"groups": await service.find_duplicates()}
@router.post("/merge")
@require_auth
async def merge_notes(body: MergeRequest, request: Request) -> dict[str, Any]:
"""Merge duplicate notes into a primary note (sources moved, dups archived)."""
service = get_knowledge_service(request)
note = await service.merge_notes(body.primary_id, body.duplicate_ids, updated_by=get_effective_user_id())
if note is None:
raise HTTPException(status_code=404, detail="Primary note not found")
return {"note": note}
@router.post("/reindex")
@require_auth
async def reindex_embeddings(request: Request) -> dict[str, Any]:
"""Rebuild the vector index for all notes (maintenance; no-op if embeddings off)."""
service = get_knowledge_service(request)
return await service.reindex_all()
@router.get("/notes/{note_id}/versions")
@require_auth
async def list_note_versions(note_id: str, request: Request) -> dict[str, Any]:
"""Return the edit history (pre-update snapshots) for a note."""
service = get_knowledge_service(request)
return {"items": await service.list_versions(note_id)}
@router.get("/graph")
@require_auth
async def get_graph(request: Request) -> dict[str, Any]:
"""Return product knowledge-graph data (notes + entities + relations)."""
service = get_knowledge_service(request)
return await service.get_graph()
@router.post("/export/graph")
@require_auth
async def export_graph(request: Request) -> dict[str, Any]:
service = get_knowledge_service(request)
result = await service.export_graph()
base = "/api/knowledge/export/files"
return {
"ok": True,
"files": {
"graph_json": f"{base}/graph.json",
"graph_html": f"{base}/graph.html",
"graph_graphml": f"{base}/graph.graphml",
"cypher": f"{base}/cypher.txt",
},
"stats": result["stats"],
}
@router.get("/export/files/{filename}")
@require_auth
async def export_file(filename: str, request: Request) -> FileResponse:
service = get_knowledge_service(request)
path = service.export_file_path(filename)
if path is None:
# Lazily build the export on first access so the iframe never 404s.
await service.export_graph()
path = service.export_file_path(filename)
if path is None:
raise HTTPException(status_code=404, detail="Export file not found")
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return FileResponse(path, media_type=media_type)
@@ -0,0 +1,97 @@
"""Public (unauthenticated) read-only user leaderboard.
Mounted under the ``/api/public/`` prefix so the auth middleware lets this route
through without a session cookie. Powers the public "publish" page for the user
leaderboard — a view-only snapshot with no admin controls and no mutations.
The snapshot honors the same system-wide leaderboard settings (excluded users,
scheduled/admin/failed inclusion) as the authenticated admin endpoint, so any
accounts an admin chose to hide stay hidden on the public page as well.
"""
from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, HTTPException, Query
from app.gateway.routers.admin_users import (
AdminLeaderboardResponse,
_load_or_schedule_daily_leaderboard,
_resolve_range,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
logger = logging.getLogger(__name__)
# Whitelisted under ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``).
router = APIRouter(prefix="/api/public/leaderboard", tags=["public-leaderboard"])
def _mask_email(email: str | None) -> str:
"""Reduce an email to its local part so the public page can show a
recognizable handle without leaking the full address (domain).
The leaderboard only makes sense if it names its top users, so the local
part is kept intentionally; the privacy gain is dropping the domain and the
raw account id from the unauthenticated payload.
"""
if not email:
return ""
return email.split("@", 1)[0].strip() or ""
def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
"""Strip PII from a leaderboard snapshot before it leaves over the public,
unauthenticated endpoint: email → local part, account/thread ids → opaque
short hashes (kept stable so they still work as React keys)."""
def hashed(value: str | None) -> str:
if not value:
return ""
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:12]
seen_users: set[int] = set()
for bucket in (resp.users_by_activity, resp.users_by_questions, resp.users_by_tokens):
for user in bucket:
if id(user) in seen_users:
continue
seen_users.add(id(user))
user.email = _mask_email(user.email)
user.user_id = hashed(user.user_id)
for bucket in (resp.recent_questions, resp.frequent_questions):
for question in bucket:
question.email = _mask_email(question.email)
question.user_id = hashed(question.user_id)
question.thread_id = ""
question.run_id = ""
return resp
@router.get("", response_model=AdminLeaderboardResponse)
@router.get("/", response_model=AdminLeaderboardResponse)
async def get_public_leaderboard(
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return a read-only leaderboard snapshot for the public publish page."""
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
resp = await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=limit,
)
return _anonymize(resp)
@@ -56,6 +56,44 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin"
def _email_local_part(email: str | None) -> str | None:
"""Return the username portion (before ``@``) of an email address."""
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
async def _attach_owner_usernames(responses: list["SkillResponse"]) -> list["SkillResponse"]:
"""Resolve each custom skill's ``owner_user_id`` to a friendly username.
One lookup per distinct owner id; tolerates deleted accounts and an
uninitialised auth provider by leaving ``owner_username`` as None.
"""
owner_ids = {r.owner_user_id for r in responses if r.owner_user_id}
if not owner_ids:
return responses
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception:
return responses
names: dict[str, str] = {}
for uid in owner_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
for resp in responses:
if resp.owner_user_id:
resp.owner_username = names.get(resp.owner_user_id)
return responses
def _can_view_skill_source(
category: SkillCategory,
owner_user_id: str | None,
@@ -111,6 +149,7 @@ class SkillResponse(BaseModel):
"""Response model for skill information."""
name: str = Field(..., description="Name of the skill")
name_zh: str | None = Field(default=None, description="用户配置或大模型生成的中文名称")
description: str = Field(..., description="Description of what the skill does")
license: str | None = Field(None, description="License information")
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
@@ -125,6 +164,7 @@ class SkillResponse(BaseModel):
featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
@@ -267,11 +307,13 @@ def _skill_to_response(
published = False
builtin = skill.category != SkillCategory.CUSTOM
detail: str | None = None
name_zh: str | None = None
if ownership is not None:
owner_user_id = ownership.get("owner_user_id")
published = bool(ownership.get("published", False))
builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail")
name_zh = ownership.get("name_zh")
display = None if display_override is _UNSET else display_override
if display_override is _UNSET:
@@ -284,6 +326,7 @@ def _skill_to_response(
return SkillResponse(
name=skill.name,
name_zh=name_zh,
description=skill.description,
license=skill.license,
category=skill.category,
@@ -697,7 +740,8 @@ async def _attach_and_filter_tags(
resp.tags = assignments.get(resp.name, [])
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
# Match the English name OR the Chinese display name so 中文 search works.
responses = [r for r in responses if needle in r.name.lower() or (r.name_zh or "").lower().find(needle) >= 0]
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
@@ -726,7 +770,7 @@ async def list_skills(
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
sort: str = Query(
default="time_desc",
@@ -786,6 +830,7 @@ async def list_skills(
)
)
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = await _attach_owner_usernames(responses)
responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses)
except HTTPException:
@@ -1176,6 +1221,7 @@ async def list_custom_skills(
display_sample=adapter.get("sample") if adapter else None,
)
)
responses = await _attach_owner_usernames(responses)
return SkillsListResponse(skills=responses)
except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True)
@@ -2309,6 +2355,324 @@ async def update_skill_detail(
raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}")
# ---------------------------------------------------------------------------
# 技能中文名称(用户可配置 + 大模型自动生成 + 一键补充)
# ---------------------------------------------------------------------------
_SKILL_NAME_ZH_SYSTEM_PROMPT = (
"你是一名中文产品命名助手。根据给定技能的英文名与功能描述,生成一个简洁、自然、"
"易懂的中文名称。要求:2 到 10 个汉字;准确概括技能用途;不要包含标点、引号、英文、"
"解释或多余文字;只输出中文名称本身。"
)
class SkillNameZhModelError(Exception):
"""The LLM call backing skill-name generation failed (a *model-side* problem).
Distinguishes a genuine model failure (timeout / API error / unbuildable
model) — which must be surfaced to the user as "大模型报错导致无法补全" — from
the model merely returning an unusable name (callers get ``None`` instead).
"""
def _clean_generated_name_zh(raw: str) -> str | None:
"""Normalise an LLM-produced Chinese name: first line, strip quotes/punct, cap length."""
if not raw:
return None
text = str(raw).strip()
# Take the first non-empty line only.
for line in text.splitlines():
line = line.strip()
if line:
text = line
break
# Strip wrapping quotes / common trailing punctuation.
text = text.strip().strip("\"'“”‘’`「」《》【】()()[]").strip()
text = text.rstrip("。.,,、!!??;;::")
if not text:
return None
# Keep it short — guard against the model returning a sentence.
if len(text) > 20:
text = text[:20]
return text or None
def _resolve_skill_name_zh_model_name(app_config: AppConfig, model_name: str | None) -> str | None:
"""Pick the model name for skill-name generation.
An explicit ``model_name`` (user-selected in the UI) wins; otherwise fall
back to the curator/security-scan model setting (itself falling back to the
main model when unset).
"""
if model_name and model_name.strip():
return model_name.strip()
if app_config.skill_evolution and app_config.skill_evolution.curator:
return app_config.skill_evolution.curator.model_name
return None
async def _generate_skill_name_zh(skill: Skill, app_config: AppConfig, model: Any = None, *, model_name: str | None = None) -> str | None:
"""Generate a Chinese display name for ``skill`` via the LLM.
An explicit ``model_name`` lets the caller pick the model; otherwise it
reuses the curator/security-scan model setting (falls back to the main
model). Returns ``None`` only when the model replies but the output cannot be
normalised into a short Chinese name. Raises :class:`SkillNameZhModelError`
when the model itself fails (cannot be built / times out / API error), so the
caller can tell the user it was a *model* problem.
"""
user_prompt = (
f"技能英文名:{skill.name}\n"
f"功能描述:{skill.description or '(无描述)'}\n\n"
"请为该技能起一个中文名称。"
)
if model is None:
try:
from deerflow.models import create_chat_model
resolved = _resolve_skill_name_zh_model_name(app_config, model_name)
model = create_chat_model(name=resolved, thinking_enabled=False, app_config=app_config)
except Exception as e:
logger.warning("Skill name_zh model build failed for %s", skill.name, exc_info=True)
raise SkillNameZhModelError(str(e) or "无法初始化所选大模型") from e
try:
response = await asyncio.wait_for(
model.ainvoke(
[
{"role": "system", "content": _SKILL_NAME_ZH_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
config={"run_name": "skill_name_zh"},
),
timeout=60,
)
except asyncio.TimeoutError as e:
logger.warning("Skill name_zh LLM call timed out for %s", skill.name)
raise SkillNameZhModelError("大模型响应超时(60 秒)") from e
except Exception as e:
logger.warning("Skill name_zh LLM call failed for %s", skill.name, exc_info=True)
raise SkillNameZhModelError(str(e) or "大模型调用失败") from e
return _clean_generated_name_zh(str(getattr(response, "content", "") or ""))
async def _persist_skill_name_zh(
skill: Skill,
name_zh: str | None,
skill_store: SkillStore,
user_id: str,
*,
is_admin: bool,
) -> str | None:
"""Persist ``name_zh`` into the ``skills`` DB table (never the on-disk SKILL.md).
Authorization mirrors the ``detail`` annotation: a custom skill is editable by
its owner or an admin; a built-in / public skill only by an admin (its DB row
is materialized on demand). Returns the stored value.
"""
if skill.category == SkillCategory.CUSTOM:
_record, mode = await _get_editable_skill_or_raise(skill_store, skill.name, user_id, is_admin=is_admin)
if mode == "admin":
updated = await skill_store.update_admin(skill.name, {"name_zh": name_zh})
else:
updated = await skill_store.update(skill.name, user_id, {"name_zh": name_zh})
else:
if not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill Chinese names")
if await skill_store.get_any(skill.name) is None:
await skill_store.ensure_legacy({"name": skill.name, "owner_user_id": None, "published": True})
updated = await skill_store.update_admin(skill.name, {"name_zh": name_zh})
if updated is None:
raise HTTPException(status_code=500, detail="Failed to persist skill Chinese name")
return updated.get("name_zh")
class SkillNameZhRequest(BaseModel):
"""Request body for setting a skill's Chinese display name."""
name_zh: str = Field(default="", description="中文名称;传空字符串表示清空")
class SkillNameZhResponse(BaseModel):
"""Response model for a single skill's Chinese-name update."""
name: str = Field(..., description="Skill name")
name_zh: str | None = Field(default=None, description="中文名称(为空表示已清空)")
class SkillNameZhBulkResponse(BaseModel):
"""Response model for the one-click bulk fill of missing Chinese names."""
generated: list[SkillNameZhResponse] = Field(default_factory=list, description="本次成功生成中文名的技能")
failed: list[str] = Field(default_factory=list, description="生成失败的技能英文名")
skipped: int = Field(default=0, description="已有中文名而被跳过的技能数")
model_error: str | None = Field(default=None, description="若有技能因大模型报错而失败,这里给出代表性的报错信息")
class GenerateSkillNameZhRequest(BaseModel):
"""Optional body for skill-name generation: pick the model to use."""
model_name: str | None = Field(default=None, description="可选:指定用于生成的大模型名称;留空使用默认配置")
def _can_edit_skill_name_zh(skill: Skill, record: dict[str, Any] | None, user_id: str, *, is_admin: bool) -> bool:
"""Whether ``user_id`` may set this skill's Chinese name (mirrors detail auth)."""
if is_admin:
return True
if skill.category != SkillCategory.CUSTOM:
return False
return record is not None and record.get("owner_user_id") == user_id
@router.put(
"/skills/{skill_name}/name-zh",
response_model=SkillNameZhResponse,
summary="Set Skill Chinese Name",
description="设置技能的中文名称(存数据库,不改源文件)。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。传空字符串表示清空,回退到使用英文名。",
)
async def set_skill_name_zh(
skill_name: str,
body: SkillNameZhRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillNameZhResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
name_zh = body.name_zh.strip() or None
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
return SkillNameZhResponse(name=skill_name, name_zh=stored)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to set name_zh for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to set skill Chinese name: {str(e)}")
@router.post(
"/skills/{skill_name}/name-zh/generate",
response_model=SkillNameZhResponse,
summary="Generate Skill Chinese Name",
description="用大模型为该技能生成一个中文名称并保存到数据库。可在请求体里通过 model_name 指定使用的大模型。",
)
async def generate_skill_name_zh(
skill_name: str,
request: Request,
body: GenerateSkillNameZhRequest | None = None,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillNameZhResponse:
try:
body = body or GenerateSkillNameZhRequest()
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
try:
name_zh = await _generate_skill_name_zh(skill, config, model_name=body.model_name)
except SkillNameZhModelError as e:
raise HTTPException(status_code=502, detail=f"大模型报错,无法补全技能中文名:{e}")
if not name_zh:
raise HTTPException(status_code=502, detail="大模型未能生成有效的中文名称,请稍后重试或手动填写")
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
return SkillNameZhResponse(name=skill_name, name_zh=stored)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to generate name_zh for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to generate skill Chinese name: {str(e)}")
@router.post(
"/skills/name-zh/generate-missing",
response_model=SkillNameZhBulkResponse,
summary="Bulk Generate Missing Skill Chinese Names",
description="管理员一键为库中所有尚未配置中文名称的技能生成中文名(存数据库)。可在请求体里通过 model_name 指定使用的大模型。",
)
async def generate_missing_skill_names_zh(
request: Request,
body: GenerateSkillNameZhRequest | None = None,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillNameZhBulkResponse:
try:
from deerflow.models import create_chat_model
body = body or GenerateSkillNameZhRequest()
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
# 一键补充技能中文名仅对管理员开放。
if not is_admin:
raise HTTPException(status_code=403, detail="仅管理员可一键补充技能中文名")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
# Admin sees every row, so existing name_zh values come from the full set.
records = {r["name"]: r for r in await skill_store.list_all()}
pending: list[Skill] = []
skipped = 0
for skill in skills:
record = records.get(skill.name)
if record and record.get("name_zh"):
skipped += 1
continue
pending.append(skill)
generated: list[SkillNameZhResponse] = []
failed: list[str] = []
model_error: str | None = None
if pending:
# Build the model once and reuse it for every skill. A build failure
# is a model problem — fail fast with a clear message rather than
# silently producing zero results.
try:
resolved_name = _resolve_skill_name_zh_model_name(config, body.model_name)
model = create_chat_model(name=resolved_name, thinking_enabled=False, app_config=config)
except Exception as e:
logger.warning("Failed to build model for bulk name_zh generation", exc_info=True)
raise HTTPException(status_code=502, detail=f"大模型报错,无法补全技能中文名:{str(e) or '无法初始化所选大模型'}")
# Generate concurrently with a small bound to avoid overloading the model.
semaphore = asyncio.Semaphore(4)
async def _one(skill: Skill) -> tuple[Skill, str | None, str | None]:
async with semaphore:
try:
return skill, await _generate_skill_name_zh(skill, config, model=model), None
except SkillNameZhModelError as e:
return skill, None, str(e)
results = await asyncio.gather(*[_one(s) for s in pending])
for skill, name_zh, err in results:
if err and model_error is None:
model_error = err
if not name_zh:
failed.append(skill.name)
continue
try:
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
generated.append(SkillNameZhResponse(name=skill.name, name_zh=stored))
except HTTPException:
# Permission / persistence issue for this one skill — skip it.
failed.append(skill.name)
return SkillNameZhBulkResponse(generated=generated, failed=failed, skipped=skipped, model_error=model_error)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to bulk-generate skill Chinese names: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to bulk-generate skill Chinese names: {str(e)}")
class SkillPinRequest(BaseModel):
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
+148 -83
View File
@@ -10,90 +10,90 @@ gateway:
port: 8001
models:
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
# - name: deepseek-chat
# display_name: DeepSeek Chat
# use: langchain_openai:ChatOpenAI
# model: deepseek-chat
# api_key: sk-1520028668cb4cc388ed8315ff1e856e
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
- name: deepseek-v4-flash
display_name: DeepSeek V4 Flash
use: langchain_openai:ChatOpenAI
model: deepseek-v4-flash
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-flash
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
- name: deepseek-v4-pro
display_name: DeepSeek V4 Pro
# - name: deepseek-v4-pro
# display_name: DeepSeek V4 Pro
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-pro
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: MiniMax-M2.7
# display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# base_url: https://api.minimaxi.com/v1
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: glm-5.1
# display_name: glm-5.1
# use: langchain_openai:ChatOpenAI
# model: glm-5.1
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
# base_url: https://open.bigmodel.cn/api/paas/v4/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: deepseek-ai/DeepSeek-OCR
# display_name: deepseek-ai/DeepSeek-OCR
# use: langchain_openai:ChatOpenAI
# model: deepseek-ai/DeepSeek-OCR
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
# use: langchain_openai:ChatOpenAI
# model: zai-org/GLM-5-FP8
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
# base_url: https://api.us-west-2.modal.direct/v1
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-v4-pro
api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: glm-5.1
display_name: glm-5.1
use: langchain_openai:ChatOpenAI
model: glm-5.1
api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
base_url: https://open.bigmodel.cn/api/paas/v4/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Qwen/Qwen3.6-27B
display_name: Qwen/Qwen3.6-27B
use: langchain_openai:ChatOpenAI
model: Qwen/Qwen3.6-27B
model: deepseek-ai/DeepSeek-V4-Flash
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
@@ -122,6 +122,26 @@ uploads:
auto_convert_documents: true
pdf_converter: auto
# Chrome 插件「当前网页发送到 Deer」入口。默认关闭,打开后才启用
# /api/browser-context/capture。它只保存当前页 Markdown 到线程 uploads,
# 不会改变普通聊天、上传或工具调用流程。
browser_context:
enabled: true
max_content_chars: 200000
max_title_chars: 300
max_url_chars: 2048
filename_prefix: webpage
route_base: /page/workspace
task_result_timeout_seconds: 60
task_poll_interval_seconds: 1
task_lease_seconds: 120
tool_result_max_chars: 60000
# 交互式页面操作(browser_act:点击/输入/滚动/等待/跳转)。关闭后只保留只读抓取。
actions_enabled: true
action_result_timeout_seconds: 45
max_observed_elements: 150
observation_text_max_chars: 12000
title:
enabled: true
max_words: 6
@@ -139,6 +159,7 @@ title:
tool_groups:
- name: web
- name: browser
- name: file:read
- name: file:write
- name: bash
@@ -154,6 +175,14 @@ tools:
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 15
- name: browser_fetch_page
group: browser
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
- name: browser_act
group: browser
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
@@ -223,7 +252,7 @@ auth_login:
database:
backend: sqlite
sqlite_dir: .deer-flow/data
sqlite_dir: /app/backend/.deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
@@ -231,7 +260,7 @@ database:
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
connection_string: /app/backend/.deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
@@ -313,3 +342,39 @@ log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
# 产品内知识库 —— obsidian-wiki 兼容 vault。知识元数据写入数据库,正文
# content_md 写入 Markdown vault(index.md/log.md/hot.md/.manifest.json/frontmatter/
# wikilinks/wiki-export 图谱产物)。知识库为全局共享,不做 user_id 隔离,
# 写入时记录 created_by / updated_by 作为审计。
knowledge:
enabled: true
provider: obsidian_wiki
vault_dir: data/knowledge/obsidian-vault
project_name: zncm
auto_ingest_enabled: false
auto_ingest_min_chars: 300
# 用大模型按 wiki-capture 规范提炼陈述式知识(而非堆砌对话原文)。
# 关闭则回退到规则抽取;extract_model_name 留空时用 models[0]。
llm_extract_enabled: true
extract_model_name: ""
extract_max_input_chars: 12000
max_thread_messages: 80
max_source_items: 20
rag_enabled: false
rag_limit: 5
# 阶段三:写入前脱敏(token/密钥/手机号/身份证/邮箱);重复检测相似度阈值。
sensitive_redaction_enabled: true
dedup_similarity_threshold: 0.82
export:
enabled: true
graph_html_enabled: true
graph_json_enabled: true
query:
use_hot_md: true
use_index_md: true
use_frontmatter_scan: true
embedding:
enabled: false
provider: local
model: ""
@@ -299,6 +299,16 @@ def _build_middlewares(
if not is_bootstrap and not is_writing_setup:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
# per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting
# to the global ``knowledge.rag_enabled`` config. No-op when knowledge is
# disabled. Skipped in bootstrap/writing-setup like MemoryMiddleware.
knowledge_cfg = getattr(resolved_app_config, "knowledge", None)
if not is_bootstrap and not is_writing_setup and knowledge_cfg is not None and knowledge_cfg.enabled:
from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
# ingests raw image data). In delegated mode the view_image tool already
# returns a text description and stores nothing in `viewed_images`, so the
@@ -0,0 +1,190 @@
"""KnowledgeRagMiddleware — inject knowledge-base recall before the model call.
Phase 2 of the product knowledge base. When enabled (per-conversation runtime
flag ``knowledge_rag_enabled``, falling back to the global
``knowledge.rag_enabled`` config), the latest user question is used to retrieve
the most relevant knowledge notes, which are injected as a transient
``<knowledge-context>`` system message — not persisted to state, so it never
leaks to the frontend transcript.
Retrieval is hybrid (vector + keyword) when an embedding endpoint is
configured, else keyword/wiki-query layered search. Failures are swallowed: RAG
must never break a conversation.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import HumanMessage
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
_TAG = "knowledge-context"
def _coerce_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text", "")))
return " ".join(parts)
return str(content or "")
def _latest_human_text(messages: list) -> str:
for msg in reversed(messages or []):
if isinstance(msg, HumanMessage):
return _coerce_text(msg.content).strip()
return ""
class KnowledgeRagMiddleware(AgentMiddleware[AgentState]):
"""Inject knowledge-base recall as transient system context."""
state_schema = AgentState
def __init__(self, *, limit: int = 5, default_enabled: bool = False) -> None:
super().__init__()
self._limit = limit
self._default_enabled = default_enabled
self._service = None # lazy
def _get_service(self):
if self._service is not None:
return self._service
try:
from deerflow.config import get_app_config
from deerflow.knowledge import make_knowledge_service
from deerflow.persistence.engine import get_session_factory
config = get_app_config()
self._service = make_knowledge_service(get_session_factory(), config.knowledge)
except Exception:
logger.exception("KnowledgeRagMiddleware: failed to build knowledge service")
self._service = None
return self._service
def _enabled_for(self, request: Any) -> bool:
runtime = getattr(request, "runtime", None)
if runtime is not None and getattr(runtime, "context", None):
flag = runtime.context.get("knowledge_rag_enabled")
if flag is not None:
return bool(flag)
return self._default_enabled
async def _build_context_message(self, request: Any) -> HumanMessage | None:
if not self._enabled_for(request):
return None
service = self._get_service()
if service is None:
return None
query = _latest_human_text(getattr(request, "messages", None) or [])
if not query:
return None
try:
result = await service.build_rag_context(query, limit=self._limit)
except Exception:
logger.exception("KnowledgeRagMiddleware: recall failed (non-fatal)")
return None
text = result.get("text") if isinstance(result, dict) else ""
if not text:
return None
fenced = (
f"<{_TAG}>\n"
"[系统提示:以下是知识库召回的背景资料,不是用户的新输入。回答时优先参考,"
"并在使用到时标注来源 note_id。]\n\n"
f"{text}\n"
f"</{_TAG}>"
)
return HumanMessage(content=fenced)
@override
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
msg = await self._build_context_message(request)
if msg is not None:
request = request.override(messages=[*request.messages, msg])
return await handler(request)
@override
def wrap_model_call(self, request: Any, handler: Any) -> Any:
# Sync path: best-effort. The gateway runtime is async, so this is rarely
# hit; run the async recall in a private loop when there is none.
if not self._enabled_for(request):
return handler(request)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Cannot block a running loop from sync code; skip injection.
return handler(request)
msg = loop.run_until_complete(self._build_context_message(request))
except Exception:
msg = None
if msg is not None:
request = request.override(messages=[*request.messages, msg])
return handler(request)
# ---- after_agent: enqueue background auto-sedimentation -------------
def _resolve_thread_id(self, runtime: Runtime | None) -> str | None:
if runtime is not None and getattr(runtime, "context", None):
tid = runtime.context.get("thread_id")
if tid:
return str(tid)
try:
from langgraph.config import get_config
return get_config().get("configurable", {}).get("thread_id")
except Exception:
return None
@override
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
"""When auto-ingest is on, hand the finished turn to the background queue."""
try:
from deerflow.config import get_app_config
knowledge_cfg = getattr(get_app_config(), "knowledge", None)
if knowledge_cfg is None or not knowledge_cfg.enabled or not knowledge_cfg.auto_ingest_enabled:
return None
from deerflow.knowledge.auto_ingest import AutoIngestItem, get_auto_ingest_queue
queue = get_auto_ingest_queue()
if queue is None:
return None
messages = state.get("messages") if isinstance(state, dict) else getattr(state, "messages", None)
if not messages:
return None
from deerflow.runtime.references import build_reference_batches
from deerflow.runtime.serialization import serialize_lc_object
from deerflow.runtime.user_context import get_effective_user_id
serialized = [serialize_lc_object(m) for m in messages]
thread_id = self._resolve_thread_id(runtime)
if not thread_id:
return None
title = state.get("title") if isinstance(state, dict) else getattr(state, "title", None)
queue.enqueue(
AutoIngestItem(
thread_id=thread_id,
user_id=get_effective_user_id(),
messages=serialized,
thread_title=title if isinstance(title, str) else None,
reference_batches=build_reference_batches(serialized),
)
)
except Exception:
logger.exception("KnowledgeRagMiddleware.after_agent enqueue failed (non-fatal)")
return None
@@ -0,0 +1,2 @@
"""Browser-backed page context helpers."""
@@ -0,0 +1,303 @@
"""Small file-backed task queue for browser page fetch requests."""
from __future__ import annotations
import asyncio
import json
import logging
import re
import threading
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
from deerflow.config.runtime_paths import runtime_home
logger = logging.getLogger(__name__)
_LOCK = threading.RLock()
_TASK_ID_RE = re.compile(r"^[a-f0-9]{32}$")
_TERMINAL_STATUSES = {"done", "error", "expired"}
def tasks_dir() -> Path:
"""Return the browser-context task directory, creating it if needed."""
path = runtime_home() / "browser-context" / "tasks"
path.mkdir(parents=True, exist_ok=True)
return path
def _now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime | None = None) -> str:
return (value or _now()).isoformat()
def _parse_iso(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _task_path(task_id: str) -> Path | None:
if not _TASK_ID_RE.fullmatch(task_id or ""):
return None
return tasks_dir() / f"{task_id}.json"
def _read_task(path: Path) -> dict[str, Any] | None:
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
return None
except Exception:
logger.warning("Ignoring unreadable browser task file: %s", path, exc_info=True)
return None
return data if isinstance(data, dict) else None
def _write_task(path: Path, data: dict[str, Any]) -> None:
tmp = path.with_name(f"{path.name}.{uuid4().hex}.tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, separators=(",", ":"))
tmp.replace(path)
def _public_task(data: dict[str, Any]) -> dict[str, Any]:
return {
"id": data.get("id"),
"kind": data.get("kind") or "fetch",
"url": data.get("url") or "",
"instruction": data.get("instruction") or "",
"session_id": data.get("session_id") or "",
"action": data.get("action") or None,
"created_at": data.get("created_at") or "",
"updated_at": data.get("updated_at") or "",
"max_content_chars": int(data.get("max_content_chars") or 200_000),
}
def _expire_if_needed(path: Path, data: dict[str, Any], now: datetime) -> dict[str, Any]:
if data.get("status") in _TERMINAL_STATUSES:
return data
expires_at = _parse_iso(data.get("expires_at"))
if expires_at is not None and now >= expires_at:
data = dict(data)
data["status"] = "expired"
data["error"] = "Browser task expired before the extension returned a result."
data["updated_at"] = _iso(now)
_write_task(path, data)
return data
def prune_browser_tasks(max_age_seconds: int = 86_400) -> int:
"""Delete old terminal tasks and return the number removed."""
cutoff = _now() - timedelta(seconds=max(60, int(max_age_seconds)))
removed = 0
with _LOCK:
for path in tasks_dir().glob("*.json"):
data = _read_task(path)
if not data or data.get("status") not in _TERMINAL_STATUSES:
continue
updated_at = _parse_iso(data.get("updated_at")) or _parse_iso(data.get("created_at"))
if updated_at is None or updated_at < cutoff:
try:
path.unlink()
removed += 1
except OSError:
logger.debug("Failed to delete old browser task: %s", path, exc_info=True)
return removed
def create_browser_task(
*,
user_id: str,
url: str,
instruction: str = "",
timeout_seconds: int = 60,
max_content_chars: int = 200_000,
metadata: dict[str, Any] | None = None,
kind: str = "fetch",
session_id: str = "",
action: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a browser task for the current user's extension to pick up.
``kind`` is ``"fetch"`` for one-shot page captures (the legacy behavior) or
``"act"`` for an interactive observe/act step driven by ``action`` within a
long-lived ``session_id`` (a tab kept open by the extension).
"""
now = _now()
task_id = uuid4().hex
task = {
"id": task_id,
"user_id": str(user_id or "default"),
"status": "pending",
"kind": kind if kind in {"fetch", "act"} else "fetch",
"url": url,
"instruction": instruction or "",
"session_id": str(session_id or ""),
"action": action or None,
"created_at": _iso(now),
"updated_at": _iso(now),
"expires_at": _iso(now + timedelta(seconds=max(5, int(timeout_seconds)))),
"max_content_chars": max(1_000, int(max_content_chars)),
"worker_id": None,
"claimed_at": None,
"result": None,
"error": None,
"metadata": metadata or {},
}
path = tasks_dir() / f"{task_id}.json"
with _LOCK:
_write_task(path, task)
return task
def get_browser_task(*, task_id: str, user_id: str) -> dict[str, Any] | None:
"""Read a task if it belongs to the requested user."""
path = _task_path(task_id)
if path is None:
return None
with _LOCK:
data = _read_task(path)
if not data or str(data.get("user_id") or "") != str(user_id or "default"):
return None
return _expire_if_needed(path, data, _now())
def claim_next_browser_task(
*,
user_id: str,
worker_id: str,
lease_seconds: int = 120,
) -> dict[str, Any] | None:
"""Claim the oldest pending task for a user."""
now = _now()
user_id = str(user_id or "default")
worker_id = (worker_id or "chrome-extension").strip()[:128] or "chrome-extension"
lease_seconds = max(10, int(lease_seconds))
with _LOCK:
paths = sorted(tasks_dir().glob("*.json"), key=lambda p: p.stat().st_mtime)
for path in paths:
data = _read_task(path)
if not data or str(data.get("user_id") or "") != user_id:
continue
data = _expire_if_needed(path, data, now)
status = data.get("status")
if status == "running":
updated_at = _parse_iso(data.get("updated_at")) or now
if (now - updated_at).total_seconds() <= lease_seconds:
continue
status = "pending"
data = dict(data)
data["status"] = status
if status != "pending":
continue
data = dict(data)
data["status"] = "running"
data["worker_id"] = worker_id
data["claimed_at"] = _iso(now)
data["updated_at"] = _iso(now)
_write_task(path, data)
return _public_task(data)
return None
def complete_browser_task(
*,
task_id: str,
user_id: str,
title: str = "",
url: str = "",
content: str = "",
content_type: str = "markdown",
error: str = "",
metadata: dict[str, Any] | None = None,
observation: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Mark a browser task complete or failed.
For ``act`` tasks the extension returns a structured ``observation`` (final
URL/title, interactive element list, page text) instead of a captured
markdown document; it is stored alongside the result verbatim.
"""
path = _task_path(task_id)
if path is None:
return None
now = _now()
with _LOCK:
data = _read_task(path)
if not data or str(data.get("user_id") or "") != str(user_id or "default"):
return None
data = dict(data)
if error:
data["status"] = "error"
data["error"] = str(error).strip()[:4000]
data["result"] = None
else:
raw_content = str(content or "").strip()
max_content_chars = max(1_000, int(data.get("max_content_chars") or 200_000))
truncated = len(raw_content) > max_content_chars
data["status"] = "done"
data["error"] = None
data["result"] = {
"title": str(title or "").strip()[:500],
"url": str(url or data.get("url") or "").strip()[:4096],
"content": raw_content[:max_content_chars] if truncated else raw_content,
"content_type": content_type if content_type in {"markdown", "text"} else "markdown",
"truncated": truncated,
"observation": observation if isinstance(observation, dict) else None,
"captured_at": _iso(now),
"metadata": metadata or {},
}
data["updated_at"] = _iso(now)
_write_task(path, data)
return data
async def wait_for_browser_task_result(
*,
task_id: str,
user_id: str,
timeout_seconds: int,
poll_interval_seconds: float = 1.0,
) -> dict[str, Any] | None:
"""Wait for a browser task to reach a terminal state."""
deadline = time.monotonic() + max(1, int(timeout_seconds))
interval = max(0.25, float(poll_interval_seconds))
while True:
task = get_browser_task(task_id=task_id, user_id=user_id)
if task is None:
return None
if task.get("status") in _TERMINAL_STATUSES:
return task
if time.monotonic() >= deadline:
return task
await asyncio.sleep(interval)
@@ -10,11 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
from deerflow.config.browser_context_config import BrowserContextConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
@@ -133,6 +135,8 @@ class AppConfig(BaseModel):
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
@classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -0,0 +1,74 @@
"""Configuration for browser page context capture."""
from pydantic import BaseModel, Field
class BrowserContextConfig(BaseModel):
"""Controls the optional Chrome-extension page capture integration."""
enabled: bool = Field(
default=False,
description="Enable /api/browser-context endpoints for Chrome extension page capture.",
)
max_content_chars: int = Field(
default=200_000,
ge=1_000,
description="Maximum captured page text stored per request. Longer content is truncated.",
)
max_title_chars: int = Field(
default=300,
ge=20,
description="Maximum title length accepted from the browser extension.",
)
max_url_chars: int = Field(
default=2_048,
ge=100,
description="Maximum source URL length accepted from the browser extension.",
)
filename_prefix: str = Field(
default="webpage",
description="Prefix used for Markdown files created from captured pages.",
)
route_base: str = Field(
default="/page/workspace",
description="Frontend route base used when returning chat paths to the extension.",
)
task_result_timeout_seconds: int = Field(
default=60,
ge=5,
description="Maximum time the browser_fetch_page tool waits for the extension result.",
)
task_poll_interval_seconds: float = Field(
default=1.0,
ge=0.25,
description="How often the backend tool checks for a returned browser task result.",
)
task_lease_seconds: int = Field(
default=120,
ge=10,
description="How long a claimed browser task is reserved before another extension may retry it.",
)
tool_result_max_chars: int = Field(
default=60_000,
ge=1_000,
description="Maximum page characters returned from browser_fetch_page into the model context.",
)
actions_enabled: bool = Field(
default=True,
description="Enable the interactive browser_act tool (click/type/scroll/wait/navigate on the user's page).",
)
action_result_timeout_seconds: int = Field(
default=45,
ge=5,
description="Maximum time the browser_act tool waits for the extension to perform one step and return an observation.",
)
max_observed_elements: int = Field(
default=150,
ge=10,
description="Maximum number of interactive elements returned in a browser_act observation.",
)
observation_text_max_chars: int = Field(
default=12_000,
ge=500,
description="Maximum page text characters returned alongside a browser_act observation.",
)
@@ -3,7 +3,7 @@
import json
import os
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -53,115 +53,115 @@ class McpServerConfig(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayTableColumn(BaseModel):
"""A configured table column for structured skill result display."""
label: str = Field(description="Column label shown in the frontend table")
field: str = Field(description="Field name or dot path read from each result item")
width: str | None = Field(default=None, description="Optional CSS width for this column")
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayResultSetConfig(BaseModel):
"""Display adapter for one result array inside a skill response."""
key: str = Field(description="Stable key for this result set")
label: str | None = Field(default=None, description="Human-readable result set label")
enabled: bool = Field(default=True, description="Whether this result set is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="citation",
description="How the frontend should render this result set",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="singleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=100, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="none",
description="How the frontend should render this skill's structured results",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="doubleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=8, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
result_sets: list[SkillDisplayResultSetConfig] = Field(
default_factory=list,
description="Optional per-result-array display adapters",
alias="resultSets",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
class SkillDisplayTableColumn(BaseModel):
"""A configured table column for structured skill result display."""
label: str = Field(description="Column label shown in the frontend table")
field: str = Field(description="Field name or dot path read from each result item")
width: str | None = Field(default=None, description="Optional CSS width for this column")
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayResultSetConfig(BaseModel):
"""Display adapter for one result array inside a skill response."""
key: str = Field(description="Stable key for this result set")
label: str | None = Field(default=None, description="Human-readable result set label")
enabled: bool = Field(default=True, description="Whether this result set is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="citation",
description="How the frontend should render this result set",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="singleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=100, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="none",
description="How the frontend should render this skill's structured results",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="doubleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=8, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
result_sets: list[SkillDisplayResultSetConfig] = Field(
default_factory=list,
description="Optional per-result-array display adapters",
alias="resultSets",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
class ExtensionsConfig(BaseModel):
@@ -0,0 +1,75 @@
"""Configuration for the product knowledge base (obsidian-wiki compatible)."""
from pydantic import BaseModel, Field
class KnowledgeExportConfig(BaseModel):
"""Knowledge graph export switches (``wiki-export`` compatible output)."""
enabled: bool = Field(default=True, description="Master switch for graph export.")
graph_html_enabled: bool = Field(default=True, description="Generate wiki-export/graph.html.")
graph_json_enabled: bool = Field(default=True, description="Generate wiki-export/graph.json.")
class KnowledgeQueryConfig(BaseModel):
"""Layered query strategy flags (``wiki-query`` style)."""
use_hot_md: bool = Field(default=True, description="Consult hot.md when searching.")
use_index_md: bool = Field(default=True, description="Consult index.md when searching.")
use_frontmatter_scan: bool = Field(default=True, description="Scan frontmatter when searching.")
class KnowledgeEmbeddingConfig(BaseModel):
"""Vector embedding config (phase 2).
Vector RAG is opt-in: set ``enabled: true`` and point ``base_url`` at an
OpenAI-compatible ``/embeddings`` endpoint. When disabled or unreachable,
search transparently falls back to keyword scoring, so the feature degrades
gracefully on offline deployments without an embedding model.
"""
enabled: bool = Field(default=False, description="Enable vector embeddings.")
provider: str = Field(default="openai", description="Embedding provider (openai-compatible).")
model: str = Field(default="", description="Embedding model name, e.g. text-embedding-3-small / bge-m3.")
base_url: str = Field(default="", description="OpenAI-compatible base URL (…/v1). Empty disables vector mode.")
api_key: str = Field(default="", description="API key for the embedding endpoint ($ENV resolved like other config).")
dimensions: int | None = Field(default=None, description="Optional embedding dimensions hint sent to the endpoint.")
batch_size: int = Field(default=32, description="Texts per embedding request.")
timeout_seconds: int = Field(default=30, description="HTTP timeout per embedding request.")
class KnowledgeConfig(BaseModel):
"""Product knowledge base configuration.
The first release ships a global, shared, obsidian-wiki compatible vault.
Knowledge metadata is mirrored into the database for fast listing/search,
while ``content_md`` is written to a Markdown vault that stays compatible
with ``ar9av/obsidian-wiki`` (index.md / log.md / hot.md / .manifest.json,
frontmatter, wikilinks, wiki-export graph artifacts).
"""
enabled: bool = Field(default=True, description="Enable the knowledge base feature.")
provider: str = Field(default="obsidian_wiki", description="Knowledge organization provider.")
vault_dir: str = Field(
default="data/knowledge/obsidian-vault",
description="Vault directory (relative to DEER_FLOW_HOME, or absolute).",
)
project_name: str = Field(default="zncm", description="Project namespace for projects/<name>/ pages.")
auto_ingest_enabled: bool = Field(default=False, description="Auto-sediment conversations in the background (phase 3).")
auto_ingest_min_chars: int = Field(default=300, description="Minimum characters before auto-ingest considers a turn.")
llm_extract_enabled: bool = Field(default=True, description="Distill notes with an LLM (wiki-capture style) instead of the rule-based fallback.")
extract_model_name: str | None = Field(default=None, description="Model (from models[]) used to distill knowledge. Falls back to models[0] when unset.")
extract_max_input_chars: int = Field(default=12000, description="Max characters of conversation transcript fed to the extraction LLM.")
max_thread_messages: int = Field(default=80, description="Maximum thread messages to scan when capturing.")
max_source_items: int = Field(default=20, description="Maximum source items recorded per note.")
related_notes_enabled: bool = Field(default=True, description="On capture, find existing notes on the same topic and link them under ## Related (real cross-references instead of placeholders).")
related_notes_limit: int = Field(default=3, description="Maximum real related notes linked into each captured note.")
multi_note_enabled: bool = Field(default=True, description="Allow the LLM extractor to split one conversation into multiple cross-linked knowledge notes.")
rag_enabled: bool = Field(default=False, description="Enable RAG injection before agent runs (phase 2).")
rag_limit: int = Field(default=5, description="Maximum knowledge snippets injected per query.")
sensitive_redaction_enabled: bool = Field(default=True, description="Redact secrets/PII (tokens, keys, phone, id-card, email) before persisting notes (phase 3).")
dedup_similarity_threshold: float = Field(default=0.82, description="Title/summary similarity above which two notes are flagged as duplicates (phase 3).")
entity_extraction_enabled: bool = Field(default=True, description="Extract entities/relations during LLM distillation and feed them into the graph (phase 4).")
export: KnowledgeExportConfig = Field(default_factory=KnowledgeExportConfig, description="Graph export configuration.")
query: KnowledgeQueryConfig = Field(default_factory=KnowledgeQueryConfig, description="Layered query configuration.")
embedding: KnowledgeEmbeddingConfig = Field(default_factory=KnowledgeEmbeddingConfig, description="Embedding configuration.")
@@ -0,0 +1,41 @@
"""Product knowledge base module (obsidian-wiki compatible).
Self-contained: knowledge ingestion, storage (DB mirror + Markdown vault),
search and graph export all live here. The Gateway only depends on
:class:`KnowledgeService` via :func:`make_knowledge_service`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from deerflow.knowledge.obsidian_adapter import ObsidianWikiAdapter
from deerflow.knowledge.repository import KnowledgeRepository
from deerflow.knowledge.service import EmptyThreadError, KnowledgeService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.knowledge_config import KnowledgeConfig
__all__ = [
"EmptyThreadError",
"KnowledgeRepository",
"KnowledgeService",
"ObsidianWikiAdapter",
"make_knowledge_service",
]
def make_knowledge_service(
session_factory: async_sessionmaker[AsyncSession] | None,
config: KnowledgeConfig,
) -> KnowledgeService | None:
"""Build the knowledge service.
Returns ``None`` when persistence is memory-only (no session factory) or
the feature is disabled — the router then responds 503.
"""
if session_factory is None or not config.enabled:
return None
return KnowledgeService(KnowledgeRepository(session_factory), config)
@@ -0,0 +1,125 @@
"""Background auto-sediment queue (phase 3).
A single asyncio worker drains a bounded queue of finished conversations and
sediments the worthwhile ones into the knowledge base — entirely off the chat
request path, so it never adds latency to a reply. Enqueue happens from
``KnowledgeRagMiddleware.after_agent`` when ``knowledge.auto_ingest_enabled``.
Value judgment is intentionally cheap (min assistant chars + dedup via
``skip_existing``); the LLM still distills the kept conversations via the normal
``capture_thread`` path.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
_QUEUE: AutoIngestQueue | None = None
@dataclass
class _QueueUser:
"""Minimal CurrentUser stand-in (only ``.id`` is needed for context)."""
id: str
@dataclass
class AutoIngestItem:
thread_id: str
user_id: str
messages: list[dict[str, Any]]
thread_title: str | None = None
reference_batches: list[dict[str, Any]] = field(default_factory=list)
class AutoIngestQueue:
"""Bounded background queue + single worker for auto-sedimentation."""
def __init__(self, service_provider: Callable[[], Any], *, min_chars: int = 300, maxsize: int = 256) -> None:
self._provider = service_provider
self._min_chars = min_chars
self._queue: asyncio.Queue[AutoIngestItem] = asyncio.Queue(maxsize=maxsize)
self._task: asyncio.Task | None = None
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._worker(), name="knowledge-auto-ingest")
logger.info("Knowledge auto-ingest worker started")
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
def enqueue(self, item: AutoIngestItem) -> None:
try:
self._queue.put_nowait(item)
except asyncio.QueueFull:
logger.warning("Knowledge auto-ingest queue full; dropping thread %s", item.thread_id)
def _worth_ingesting(self, item: AutoIngestItem) -> bool:
chars = 0
for m in item.messages:
if str(m.get("type") or m.get("role") or "").lower() == "ai":
content = m.get("content")
chars += len(content) if isinstance(content, str) else len(str(content or ""))
return chars >= self._min_chars
async def _process(self, item: AutoIngestItem) -> None:
service = self._provider()
if service is None:
return
if not self._worth_ingesting(item):
logger.debug("Auto-ingest skipped (below min chars) thread=%s", item.thread_id)
return
from deerflow.runtime.user_context import reset_current_user, set_current_user
token = set_current_user(_QueueUser(id=item.user_id))
try:
result = await service.capture_thread(
thread_id=item.thread_id,
messages=item.messages,
thread_title=item.thread_title,
status="draft", # auto-sedimented notes land as drafts for review
include_sources=True,
reference_batches=item.reference_batches,
created_by=item.user_id,
skip_existing=True,
)
if result.get("created"):
logger.info("Auto-ingested thread %s -> note %s", item.thread_id, result["note"]["id"])
except Exception:
logger.exception("Auto-ingest failed for thread %s", item.thread_id)
finally:
reset_current_user(token)
async def _worker(self) -> None:
while True:
item = await self._queue.get()
try:
await self._process(item)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Auto-ingest worker error")
finally:
self._queue.task_done()
def set_auto_ingest_queue(queue: AutoIngestQueue | None) -> None:
global _QUEUE
_QUEUE = queue
def get_auto_ingest_queue() -> AutoIngestQueue | None:
return _QUEUE
@@ -0,0 +1,60 @@
"""Markdown chunking for embedding/index (phase 2).
Splits a note body into overlapping chunks along Markdown structure (headings →
paragraphs) so each embedded chunk is semantically coherent and bounded in
size. Frontmatter is assumed already stripped by the caller.
"""
from __future__ import annotations
import re
_HEADING_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE)
def _split_paragraphs(text: str) -> list[str]:
return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
def chunk_markdown(content: str, *, max_chars: int = 800, overlap: int = 120) -> list[str]:
"""Return a list of text chunks for ``content``.
Sections are cut at Markdown headings; long sections are further split into
~``max_chars`` windows with ``overlap`` characters of carry-over.
"""
text = (content or "").strip()
if not text:
return []
# Split into heading-delimited sections, keeping the heading with its body.
sections: list[str] = []
last = 0
for m in _HEADING_RE.finditer(text):
if m.start() > last:
sections.append(text[last : m.start()].strip())
last = m.start()
sections.append(text[last:].strip())
sections = [s for s in sections if s]
chunks: list[str] = []
for section in sections:
if len(section) <= max_chars:
chunks.append(section)
continue
# Pack paragraphs into windows, then hard-split anything still too long.
buf = ""
for para in _split_paragraphs(section):
if len(buf) + len(para) + 2 <= max_chars:
buf = f"{buf}\n\n{para}".strip()
else:
if buf:
chunks.append(buf)
if len(para) <= max_chars:
buf = para
else:
for i in range(0, len(para), max_chars - overlap):
chunks.append(para[i : i + max_chars])
buf = ""
if buf:
chunks.append(buf)
return [c for c in chunks if c.strip()]
@@ -0,0 +1,75 @@
"""Duplicate detection for knowledge notes (phase 3).
First-release heuristic: token Jaccard over ``title + summary + tags`` plus an
exact ``content_md`` hash match. Cheap, deterministic and embedding-free so it
works on offline deployments. Returns *suggestions*; merging is an explicit,
user-driven action.
"""
from __future__ import annotations
import re
from typing import Any
from deerflow.knowledge.manifest import content_hash
_TOKEN_RE = re.compile(r"[\w一-鿿]+", re.UNICODE)
def _tokens(*texts: str) -> set[str]:
out: set[str] = set()
for t in texts:
out.update(m.lower() for m in _TOKEN_RE.findall(t or ""))
return out
def _jaccard(a: set[str], b: set[str]) -> float:
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def find_duplicate_groups(notes: list[dict[str, Any]], *, threshold: float = 0.82, max_notes: int = 800) -> list[dict[str, Any]]:
"""Return groups ``{primary, duplicates:[{id,title,score,reason}]}``.
``primary`` is the most recently updated note in each cluster.
"""
notes = notes[:max_notes]
feats: list[tuple[dict[str, Any], set[str], str]] = []
for n in notes:
toks = _tokens(n.get("title") or "", n.get("summary") or "", " ".join(n.get("tags") or []))
feats.append((n, toks, content_hash(n.get("content_md") or "")))
used: set[str] = set()
groups: list[dict[str, Any]] = []
for i in range(len(feats)):
ni, ti, hi = feats[i]
if ni["id"] in used:
continue
dups: list[dict[str, Any]] = []
for j in range(i + 1, len(feats)):
nj, tj, hj = feats[j]
if nj["id"] in used:
continue
if hi and hi == hj:
score, reason = 1.0, "identical-content"
else:
score = _jaccard(ti, tj)
reason = "similar-title-summary"
if score >= threshold:
dups.append({"id": nj["id"], "title": nj.get("title"), "score": round(score, 4), "reason": reason})
used.add(nj["id"])
if dups:
used.add(ni["id"])
# Primary = newest by updated_at.
cluster = [ni, *[next(f[0] for f in feats if f[0]["id"] == d["id"]) for d in dups]]
primary = max(cluster, key=lambda x: x.get("updated_at") or "")
others = [
{"id": c["id"], "title": c.get("title"), "score": next((d["score"] for d in dups if d["id"] == c["id"]), 1.0)}
for c in cluster
if c["id"] != primary["id"]
]
groups.append({"primary": {"id": primary["id"], "title": primary.get("title")}, "duplicates": others})
return groups
@@ -0,0 +1,79 @@
"""Embedding generation for vector RAG (phase 2).
Talks to an OpenAI-compatible ``/embeddings`` endpoint configured under
``knowledge.embedding``. Everything here is **best-effort**: if embeddings are
disabled, unconfigured, or the endpoint errors, callers get an empty result and
the search layer falls back to keyword scoring — so vector RAG never breaks a
deployment that has no embedding model.
"""
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from deerflow.config.knowledge_config import KnowledgeEmbeddingConfig
logger = logging.getLogger(__name__)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Cosine similarity of two equal-length vectors (0 when degenerate)."""
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b, strict=False))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
class EmbeddingClient:
"""Thin async client over an OpenAI-compatible embeddings endpoint."""
def __init__(self, config: KnowledgeEmbeddingConfig) -> None:
self.config = config
def is_enabled(self) -> bool:
return bool(self.config.enabled and self.config.base_url and self.config.model)
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
"""Return one vector per input text; ``[]`` on any failure/disabled."""
if not self.is_enabled() or not texts:
return []
try:
import httpx
except Exception: # pragma: no cover - httpx is a core dep
logger.warning("httpx unavailable; knowledge embeddings disabled")
return []
url = self.config.base_url.rstrip("/") + "/embeddings"
headers = {"Content-Type": "application/json"}
if self.config.api_key:
headers["Authorization"] = f"Bearer {self.config.api_key}"
vectors: list[list[float]] = []
batch = max(1, self.config.batch_size)
try:
async with httpx.AsyncClient(timeout=self.config.timeout_seconds) as client:
for start in range(0, len(texts), batch):
chunk = texts[start : start + batch]
payload: dict = {"model": self.config.model, "input": chunk}
if self.config.dimensions:
payload["dimensions"] = self.config.dimensions
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
items = sorted(data.get("data", []), key=lambda d: d.get("index", 0))
vectors.extend([item.get("embedding", []) for item in items])
except Exception:
logger.exception("Knowledge embedding request failed; falling back to keyword search")
return []
return vectors
async def embed_query(self, text: str) -> list[float]:
vecs = await self.embed_texts([text])
return vecs[0] if vecs else []
@@ -0,0 +1,227 @@
"""obsidian-wiki ``wiki-export`` compatible graph generation.
Builds a knowledge graph from the notes (nodes) plus their wikilinks and shared
tags (edges) and writes the four native artifacts into ``<vault>/wiki-export/``:
``graph.json``, ``graph.graphml``, ``cypher.txt`` and a self-contained
``graph.html`` (no external CDN, safe to iframe offline).
"""
from __future__ import annotations
import html
import json
from pathlib import Path
from typing import Any
from deerflow.knowledge.markdown_vault import extract_wikilinks
def _vault_key(vault_path: str | None) -> str | None:
if not vault_path:
return None
key = vault_path.replace("\\", "/")
if key.endswith(".md"):
key = key[:-3]
return key
def build_graph(
notes: list[dict[str, Any]],
entities: list[dict[str, Any]] | None = None,
relations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Return ``{nodes, edges}`` from notes (+ optional entities/relations).
Note nodes come from ``notes`` (id, title, tags, vault_path, content_md);
when ``entities``/``relations`` are supplied (phase 4), entity nodes and
note→entity / entity→entity edges are added on top of the wikilink and
shared-tag edges.
"""
nodes = []
by_key: dict[str, str] = {}
note_ids: set[str] = set()
for n in notes:
nodes.append({"id": n["id"], "type": "note", "label": n.get("title") or n["id"], "category": n.get("source_type")})
note_ids.add(n["id"])
key = _vault_key(n.get("vault_path"))
if key:
by_key[key] = n["id"]
# Only surface entities that are still mentioned by a LIVE note. Entities whose
# only referencing notes were deleted/archived would otherwise linger forever
# as orphan nodes ("graph doesn't follow my deletions").
mentioned: set[str] = set()
for rel in relations or []:
if rel.get("from_note_id") in note_ids and rel.get("to_entity_id"):
mentioned.add(rel["to_entity_id"])
entity_ids: set[str] = set()
for ent in entities or []:
if ent["id"] not in mentioned:
continue
nodes.append({"id": ent["id"], "type": "entity", "label": ent.get("name") or ent["id"], "category": ent.get("entity_type")})
entity_ids.add(ent["id"])
edges: list[dict[str, Any]] = []
edge_seen: set[tuple[str, str, str]] = set()
def add_edge(a: str, b: str, label: str, weight: float) -> None:
if a == b:
return
key = (min(a, b), max(a, b), label)
if key in edge_seen:
return
edge_seen.add(key)
edges.append({"id": f"rel_{len(edges) + 1}", "source": a, "target": b, "label": label, "weight": weight})
# Wikilink edges.
for n in notes:
for target in extract_wikilinks(n.get("content_md") or ""):
tid = by_key.get(target.replace("\\", "/").removesuffix(".md"))
if tid:
add_edge(n["id"], tid, "links", 2)
# Shared-tag edges.
tag_to_notes: dict[str, list[str]] = {}
for n in notes:
for tag in n.get("tags") or []:
tag_to_notes.setdefault(tag.lower(), []).append(n["id"])
for tag, ids in tag_to_notes.items():
if len(ids) < 2 or len(ids) > 50: # skip ubiquitous tags (e.g. knowledge-base)
continue
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
add_edge(ids[i], ids[j], "related", 1)
# Entity / relation edges (phase 4) — only when both endpoints exist as nodes.
valid = note_ids | entity_ids
for rel in relations or []:
src = rel.get("from_note_id") or rel.get("from_entity_id")
tgt = rel.get("to_note_id") or rel.get("to_entity_id")
if src in valid and tgt in valid:
add_edge(src, tgt, rel.get("relation_type") or "related", float(rel.get("weight") or 1))
return {"nodes": nodes, "edges": edges}
def to_graphml(graph: dict[str, Any]) -> str:
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<graphml xmlns="http://graphml.graphdrawing.org/xmlns">',
' <key id="label" for="node" attr.name="label" attr.type="string"/>',
' <key id="elabel" for="edge" attr.name="label" attr.type="string"/>',
' <graph edgedefault="undirected">',
]
for n in graph["nodes"]:
lines.append(f' <node id="{html.escape(str(n["id"]))}"><data key="label">{html.escape(str(n["label"]))}</data></node>')
for e in graph["edges"]:
lines.append(
f' <edge id="{html.escape(str(e["id"]))}" source="{html.escape(str(e["source"]))}" '
f'target="{html.escape(str(e["target"]))}"><data key="elabel">{html.escape(str(e["label"]))}</data></edge>'
)
lines += [" </graph>", "</graphml>"]
return "\n".join(lines)
def to_cypher(graph: dict[str, Any]) -> str:
lines = []
for n in graph["nodes"]:
label = str(n["label"]).replace("'", "\\'")
lines.append(f"CREATE (:Note {{id:'{n['id']}', title:'{label}'}});")
for e in graph["edges"]:
rel = str(e["label"]).upper()
lines.append(f"MATCH (a:Note {{id:'{e['source']}'}}),(b:Note {{id:'{e['target']}'}}) CREATE (a)-[:{rel} {{weight:{e['weight']}}}]->(b);")
return "\n".join(lines)
def to_html(graph: dict[str, Any]) -> str:
"""Self-contained force-directed graph (canvas + vanilla JS, no CDN)."""
data_json = json.dumps(graph, ensure_ascii=False)
return _HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
def export_all(
vault_root: Path | str,
notes: list[dict[str, Any]],
*,
entities: list[dict[str, Any]] | None = None,
relations: list[dict[str, Any]] | None = None,
write_html: bool = True,
write_json: bool = True,
) -> dict[str, Any]:
"""Generate graph artifacts under ``<vault>/wiki-export/`` and return stats."""
out_dir = Path(vault_root).resolve() / "wiki-export"
out_dir.mkdir(parents=True, exist_ok=True)
graph = build_graph(notes, entities, relations)
written: list[str] = []
if write_json:
(out_dir / "graph.json").write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
written.append("graph.json")
(out_dir / "graph.graphml").write_text(to_graphml(graph), encoding="utf-8")
written.append("graph.graphml")
(out_dir / "cypher.txt").write_text(to_cypher(graph), encoding="utf-8")
written.append("cypher.txt")
if write_html:
(out_dir / "graph.html").write_text(to_html(graph), encoding="utf-8")
written.append("graph.html")
return {
"files": written,
"stats": {"nodes": len(graph["nodes"]), "edges": len(graph["edges"])},
"graph": graph,
}
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Knowledge Graph</title>
<style>
html,body{margin:0;height:100%;background:#0b1020;color:#e7ecff;font-family:system-ui,Segoe UI,Arial,sans-serif;overflow:hidden}
#hud{position:fixed;top:10px;left:12px;font-size:12px;opacity:.8;z-index:2}
#tip{position:fixed;padding:4px 8px;background:#111a36;border:1px solid #2a3a66;border-radius:4px;font-size:12px;pointer-events:none;display:none;z-index:3}
canvas{display:block}
#empty{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;opacity:.6}
</style>
</head>
<body>
<div id="hud"></div>
<div id="tip"></div>
<canvas id="c"></canvas>
<script>
const GRAPH = __GRAPH_DATA__;
const canvas = document.getElementById('c'), ctx = canvas.getContext('2d');
const hud = document.getElementById('hud'), tip = document.getElementById('tip');
let W=0,H=0;
function resize(){W=canvas.width=innerWidth;H=canvas.height=innerHeight;}
addEventListener('resize',resize);resize();
hud.textContent = GRAPH.nodes.length + ' 个知识节点 · ' + GRAPH.edges.length + ' 条关系';
if(!GRAPH.nodes.length){document.body.insertAdjacentHTML('beforeend','<div id=\"empty\">暂无知识图谱数据</div>');}
const idx={};GRAPH.nodes.forEach((n,i)=>{idx[n.id]=n;n.x=W/2+Math.cos(i)*200*Math.random()+(Math.random()-.5)*200;n.y=H/2+Math.sin(i)*200*Math.random()+(Math.random()-.5)*200;n.vx=0;n.vy=0;});
const edges=GRAPH.edges.map(e=>({s:idx[e.source],t:idx[e.target],w:e.w||e.weight||1})).filter(e=>e.s&&e.t);
let cam={x:0,y:0,k:1},drag=null,pan=null;
function step(){
for(const n of GRAPH.nodes){for(const m of GRAPH.nodes){if(n===m)continue;let dx=n.x-m.x,dy=n.y-m.y,d2=dx*dx+dy*dy+0.01,f=1200/d2;n.vx+=dx*f*0.0006;n.vy+=dy*f*0.0006;}}
for(const e of edges){let dx=e.t.x-e.s.x,dy=e.t.y-e.s.y,d=Math.sqrt(dx*dx+dy*dy)||1,f=(d-110)*0.01;e.s.vx+=dx/d*f;e.s.vy+=dy/d*f;e.t.vx-=dx/d*f;e.t.vy-=dy/d*f;}
for(const n of GRAPH.nodes){n.vx+=(W/2-n.x)*0.0008;n.vy+=(H/2-n.y)*0.0008;n.x+=n.vx*=0.85;n.y+=n.vy*=0.85;}
}
function draw(){
ctx.setTransform(cam.k,0,0,cam.k,cam.x,cam.y);ctx.clearRect(-cam.x/cam.k,-cam.y/cam.k,W/cam.k,H/cam.k);
ctx.strokeStyle='rgba(120,150,255,.25)';
for(const e of edges){ctx.lineWidth=e.w*0.6;ctx.beginPath();ctx.moveTo(e.s.x,e.s.y);ctx.lineTo(e.t.x,e.t.y);ctx.stroke();}
for(const n of GRAPH.nodes){const r=6;ctx.fillStyle='#5b8cff';ctx.beginPath();ctx.arc(n.x,n.y,r,0,7);ctx.fill();
ctx.fillStyle='rgba(231,236,255,.85)';ctx.font='11px system-ui';ctx.fillText((n.label||'').slice(0,18),n.x+9,n.y+3);}
}
function loop(){step();draw();requestAnimationFrame(loop);}loop();
function toWorld(x,y){return {x:(x-cam.x)/cam.k,y:(y-cam.y)/cam.k};}
canvas.addEventListener('mousedown',ev=>{const p=toWorld(ev.clientX,ev.clientY);let hit=null;
for(const n of GRAPH.nodes){if((n.x-p.x)**2+(n.y-p.y)**2<100){hit=n;break;}}if(hit)drag=hit;else pan={x:ev.clientX,y:ev.clientY,cx:cam.x,cy:cam.y};});
addEventListener('mousemove',ev=>{const p=toWorld(ev.clientX,ev.clientY);if(drag){drag.x=p.x;drag.y=p.y;drag.vx=drag.vy=0;}else if(pan){cam.x=pan.cx+ev.clientX-pan.x;cam.y=pan.cy+ev.clientY-pan.y;}
let hov=null;for(const n of GRAPH.nodes){if((n.x-p.x)**2+(n.y-p.y)**2<100){hov=n;break;}}
if(hov){tip.style.display='block';tip.style.left=(ev.clientX+12)+'px';tip.style.top=(ev.clientY+12)+'px';tip.textContent=hov.label;}else{tip.style.display='none';}});
addEventListener('mouseup',()=>{drag=null;pan=null;});
canvas.addEventListener('wheel',ev=>{ev.preventDefault();const s=ev.deltaY<0?1.1:0.9;cam.k*=s;cam.x=ev.clientX-(ev.clientX-cam.x)*s;cam.y=ev.clientY-(ev.clientY-cam.y)*s;},{passive:false});
</script>
</body>
</html>"""
@@ -0,0 +1,262 @@
"""Extract knowledge notes from threads / searches / tool results.
First-release strategy is rule-based (wiki-capture aligned, §11 of the dev doc):
keep high-value content (user questions, final assistant answers, cited
sources), drop noise (empty/greeting/intermediate reasoning), and write a
*declarative* note rather than a chat transcript. An LLM extractor can later
replace :func:`extract_thread` while keeping the same :class:`NoteDraft` output.
"""
from __future__ import annotations
import re
from typing import Any
from deerflow.knowledge.schemas import NoteDraft, SourceDraft
# Greeting / low-value lines filtered from extracted turns.
_GREETING_RE = re.compile(r"^(你好|您好|hi|hello|hey|thanks?|谢谢|好的|ok|okay)[\s!!。.,,~]*$", re.IGNORECASE)
_WS_RE = re.compile(r"\s+")
def _one_line(text: str, limit: int = 300) -> str:
"""Collapse whitespace and clip ``text`` to a single line."""
collapsed = _WS_RE.sub(" ", text or "").strip()
return collapsed[:limit]
def message_text(msg: dict[str, Any]) -> str:
"""Return the plain-text content of a serialized LangChain message."""
content = msg.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
if block.get("type") in (None, "text") and isinstance(block.get("text"), str):
parts.append(block["text"])
return "\n".join(p for p in parts if p).strip()
return ""
def _msg_type(msg: dict[str, Any]) -> str:
return str(msg.get("type") or msg.get("role") or "").lower()
def split_turns(messages: list[dict[str, Any]], *, max_messages: int = 80) -> list[dict[str, str]]:
"""Split messages into ``{user, ai}`` turns keyed by human messages.
For each turn we keep the last substantial AI message (with text body).
"""
msgs = messages[-max_messages:] if max_messages and len(messages) > max_messages else list(messages)
turns: list[dict[str, str]] = []
current: dict[str, str] | None = None
for msg in msgs:
mtype = _msg_type(msg)
text = message_text(msg)
if mtype == "human":
if current and (current.get("user") or current.get("ai")):
turns.append(current)
current = {"user": text, "ai": ""}
elif mtype == "ai" and text:
if current is None:
current = {"user": "", "ai": text}
else:
current["ai"] = text # last substantial AI message wins
if current and (current.get("user") or current.get("ai")):
turns.append(current)
# Drop turns that are pure greetings with no answer.
cleaned = []
for t in turns:
user = (t.get("user") or "").strip()
ai = (t.get("ai") or "").strip()
if not ai and _GREETING_RE.match(user):
continue
if not user and not ai:
continue
cleaned.append({"user": user, "ai": ai})
return cleaned
def _auto_title(thread_title: str | None, turns: list[dict[str, str]]) -> str:
if thread_title and thread_title.strip() and thread_title.strip().lower() != "untitled":
return thread_title.strip()[:200]
for t in turns:
if t.get("user"):
first = t["user"].splitlines()[0].strip()
return (first[:40] + ("…" if len(first) > 40 else "")) or "知识笔记"
return "知识笔记"
def _summary(turns: list[dict[str, str]], *, limit: int = 280) -> str:
"""Simple rule-based summary: the first answer, clipped."""
for t in turns:
if t.get("ai"):
text = re.sub(r"\s+", " ", t["ai"]).strip()
return text[:limit] + ("…" if len(text) > limit else "")
for t in turns:
if t.get("user"):
text = re.sub(r"\s+", " ", t["user"]).strip()
return text[:limit] + ("…" if len(text) > limit else "")
return ""
def related_links(project_name: str | None) -> list[str]: # noqa: ARG001
"""Structural wikilinks every note used to carry.
Product decision: notes no longer auto-link the vault meta/hub pages
(``index`` / ``_meta/taxonomy`` / the project hub) — they were noise in every
note's ``## Related``. Only real, content-specific related notes (injected by
the service layer, which has DB access) are linked now. Kept as a function
(returns empty) so existing callers and the wiki-capture contract stay intact.
"""
return []
# Backwards-compatible internal alias.
_related_links = related_links
def _related_section_md(related_lines: list[str], source_lines: list[str]) -> str:
"""Render the ``## Related`` section, or ``""`` when there is nothing to show."""
if not related_lines and not source_lines:
return ""
inner = "\n".join(related_lines)
if source_lines:
sources_block = "### Sources\n\n" + "\n".join(source_lines)
inner = (inner + "\n\n" + sources_block) if inner else sources_block
return f"## Related\n\n{inner}\n"
def _build_body(
*,
title: str,
turns: list[dict[str, str]],
mode: str,
project_name: str | None,
sources: list[SourceDraft],
) -> str:
"""Render the wiki-capture 5-section declarative body."""
full = mode == "full"
answer_clip = 4000 if full else 800
context_lines = []
for t in turns:
if t.get("user"):
q = re.sub(r"\s+", " ", t["user"]).strip()
context_lines.append(f"- {q[:300]}")
context = "\n".join(context_lines[:8]) or "- 来自一次对话沉淀。^[inferred]"
finding_lines = []
for i, t in enumerate(turns, 1):
if t.get("ai"):
ans = t["ai"].strip()
ans = ans if full else (ans[:answer_clip] + ("…" if len(ans) > answer_clip else ""))
finding_lines.append(f"### 结论 {i}\n\n{ans}")
finding = "\n\n".join(finding_lines) or "_暂无可提炼的结论。_"
reasoning = "本页由对话自动沉淀而成,结论来自助手最终回答;过程性推理已省略。判断与归纳部分标记为 ^[inferred]。"
implications = "- 后续可在相关问答中参考本页知识。\n- 如内容过期或冲突,请编辑或归档。"
related_lines = [f"- {link}" for link in _related_links(project_name)]
source_lines = [f"- {s.title or s.url or '来源'}" + (f" — {s.url}" if s.url else "") for s in sources[:20]]
related_section = _related_section_md(related_lines, source_lines)
return (
f"# {title}\n\n"
f"## Context\n\n{context}\n\n"
f"## Finding / Decision\n\n{finding}\n\n"
f"## Reasoning\n\n{reasoning}\n\n"
f"## Implications\n\n{implications}\n\n"
f"{related_section}"
)
def extract_thread(
messages: list[dict[str, Any]],
*,
thread_id: str,
thread_title: str | None = None,
mode: str = "summary",
title: str | None = None,
tags: list[str] | None = None,
project_name: str | None = "zncm",
sources: list[SourceDraft] | None = None,
max_messages: int = 80,
) -> NoteDraft | None:
"""Build a :class:`NoteDraft` from thread messages, or ``None`` if empty."""
turns = split_turns(messages, max_messages=max_messages)
if not turns:
return None
final_title = (title or "").strip() or _auto_title(thread_title, turns)
summary = _summary(turns)
sources = sources or []
body = _build_body(title=final_title, turns=turns, mode=mode, project_name=project_name, sources=sources)
base_tags = list(tags or [])
for default in (project_name or "zncm", "knowledge-base"):
if default and default not in base_tags:
base_tags.append(default)
return NoteDraft(
title=final_title,
summary=summary,
content_md=body,
category="synthesis",
source_type="thread",
source_id=thread_id,
tags=base_tags,
confidence=0.6,
related=_related_links(project_name),
sources=sources,
source_key=f"thread:{thread_id}",
)
def extract_search(
query: str,
sources: list[SourceDraft],
*,
thread_id: str | None = None,
project_name: str | None = "zncm",
title: str | None = None,
tags: list[str] | None = None,
) -> NoteDraft:
"""Build a reference-category :class:`NoteDraft` from search/tool results."""
final_title = (title or "").strip() or f"检索:{query[:40]}"
body_lines = [f"# {final_title}\n", "## Context\n", f"- 检索关键词:{query}\n", "## Finding / Decision\n"]
for s in sources:
label = s.title or s.url or "来源"
line = f"- **{label}**"
if s.url:
line += f" — {s.url}"
if s.snippet:
line += "\n - " + _one_line(s.snippet)
body_lines.append(line)
body_lines += [
"\n## Reasoning\n",
"本页汇总外部检索来源,供后续问答引用。来源可信度需结合原文判断。^[ambiguous]\n",
"## Implications\n",
"- 引用前请核对原始来源。\n",
"## Related\n",
*[f"- {link}" for link in _related_links(project_name)],
]
base_tags = list(tags or [])
for default in (project_name or "zncm", "knowledge-base", "reference"):
if default and default not in base_tags:
base_tags.append(default)
return NoteDraft(
title=final_title,
summary=f"检索“{query}”的来源汇总,共 {len(sources)} 条。",
content_md="\n".join(body_lines),
category="references",
source_type="search",
source_id=f"search:{query[:120]}",
tags=base_tags,
confidence=0.4,
related=_related_links(project_name),
sources=sources,
source_key=None,
)
@@ -0,0 +1,354 @@
"""LLM-based knowledge extraction (wiki-capture style).
Turns a conversation into a *declarative* knowledge note instead of dumping the
raw chat. Follows the obsidian-wiki ``wiki-capture`` skill: classify the note,
keep only high-value findings/decisions, drop greetings / intermediate
reasoning / verbatim transcripts, mark inferred content, and emit structured
JSON that we render into the Context / Finding / Reasoning / Implications /
Related body.
Falls back to the rule-based :func:`deerflow.knowledge.extractor.extract_thread`
when the LLM is unavailable or returns something unusable, so capture never
hard-fails.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from deerflow.knowledge import extractor
from deerflow.knowledge.schemas import NoteDraft, SourceDraft
from deerflow.knowledge.skill_templates import WIKI_CAPTURE_GUIDANCE
logger = logging.getLogger(__name__)
_VALID_CATEGORIES = {"synthesis", "concepts", "references", "decision", "journal"}
class LLMRejectedIngest(Exception):
"""The LLM judged the conversation as having no knowledge value.
Raised (instead of returning ``[]``) when the model explicitly sets
``should_ingest: false`` with no notes — so callers can *skip* ingestion
entirely rather than falling back to the rule-based verbatim dump.
"""
def _is_falsey(value: Any) -> bool:
if value is False:
return True
if isinstance(value, str):
return value.strip().lower() in ("false", "0", "no")
return False
_SYSTEM_PROMPT = """\
你是知识库整理专家,遵循 obsidian-wiki 的 wiki-capture 规范,把一次对话提炼成\
**陈述式知识(declarative knowledge)**,而不是聊天流水账。
要求:
- 只保留高价值内容:明确结论、方案、决策、关键事实、被引用的来源。
- 丢弃:寒暄、客套、中间推理过程、重复内容、无意义的原文堆砌。
- 不要写“用户问了什么、AI 回答了什么”,要写“结论是什么、为什么成立”。
- 推断内容用 ^[inferred] 标记,不确定/冲突用 ^[ambiguous] 标记。
- 分类 category 取值之一:synthesis(分析/方案/结论)、concepts(概念/框架)、\
references(外部来源汇总)、decision(架构/设计决策)、journal(会话纪要)。
- 用中文输出。
一次对话可能只讲一个主题,也可能覆盖多个相互独立的主题。请把它拆成 1~4 页知识:
- 默认只产出 **1 页**;只有当对话明确包含**多个彼此独立、可各自成文**的主题时才拆成多页。
- 每一页聚焦单一主题,标题与 summary 必须彼此区分,不要把同一结论拆到多页。
- 不值得沉淀时返回空的 notes 数组。
只输出一个 JSON 对象,不要任何额外文字或 Markdown 代码块,结构如下:
{
"should_ingest": true,
"reason": "为什么值得/不值得沉淀",
"notes": [
{
"title": "简洁的知识标题(不超过 40 字)",
"summary": "1-2 句话概括这页知识",
"category": "synthesis",
"context": "问题背景或触发场景,1-3 句",
"findings": ["结论/要点(陈述式,可含 ^[inferred])", "..."],
"reasoning": "为什么成立、取舍、不确定性",
"implications": ["后续建议/风险/下一步", "..."],
"tags": ["标签1", "标签2"],
"entities": [{"name": "实体名", "type": "technology|concept|person|org|product|other"}],
"relations": [{"from": "实体A", "to": "实体B", "type": "uses|relates_to|part_of|depends_on"}],
"confidence": 0.0
}
]
}"""
def _build_transcript(turns: list[dict[str, str]], sources: list[SourceDraft], *, max_chars: int) -> str:
parts: list[str] = []
for i, t in enumerate(turns, 1):
if t.get("user"):
parts.append(f"[用户 {i}] {t['user']}")
if t.get("ai"):
parts.append(f"[助手 {i}] {t['ai']}")
if sources:
parts.append("\n[来源]")
for s in sources[:20]:
label = s.title or s.url or s.tool_name or "来源"
parts.append(f"- {label}" + (f" ({s.url})" if s.url else ""))
text = "\n\n".join(parts)
if len(text) > max_chars:
text = text[:max_chars] + "\n…(已截断)"
return text
def _parse_json(raw: str) -> dict[str, Any] | None:
if not raw:
return None
text = raw.strip()
# Strip ```json fences if present.
fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
if fence:
text = fence.group(1).strip()
# Otherwise grab the outermost { ... }.
if not text.startswith("{"):
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
text = text[start : end + 1]
try:
data = json.loads(text)
return data if isinstance(data, dict) else None
except json.JSONDecodeError:
logger.warning("Knowledge LLM returned non-JSON output; falling back to rule-based")
return None
def _render_body(data: dict[str, Any], title: str, related: list[str], sources: list[SourceDraft]) -> str:
def _bullets(items: Any) -> str:
if isinstance(items, str):
items = [items]
if not isinstance(items, list):
return ""
return "\n".join(f"- {str(x).strip()}" for x in items if str(x).strip())
context = str(data.get("context") or "").strip() or "- 来自一次对话沉淀。^[inferred]"
findings = _bullets(data.get("findings")) or "_暂无可提炼的结论。_"
reasoning = str(data.get("reasoning") or "").strip() or "(未提供推理说明)"
implications = _bullets(data.get("implications")) or "- 如内容过期或冲突,请编辑或归档。"
related_lines = [f"- {link}" for link in related]
source_lines = [f"- {s.title or s.url or '来源'}" + (f" — {s.url}" if s.url else "") for s in sources[:20]]
related_section = extractor._related_section_md(related_lines, source_lines)
return (
f"# {title}\n\n"
f"## Context\n\n{context}\n\n"
f"## Finding / Decision\n\n{findings}\n\n"
f"## Reasoning\n\n{reasoning}\n\n"
f"## Implications\n\n{implications}\n\n"
f"{related_section}"
)
def _notes_from_data(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Normalize the LLM JSON into a list of per-note dicts.
Accepts the multi-note shape ``{"notes": [...]}`` as well as the legacy
single-note shape (``{"title": ..., "findings": ...}``) for backward
compatibility. Notes without any title/findings are dropped.
"""
raw_notes = data.get("notes")
if isinstance(raw_notes, list):
candidates = [n for n in raw_notes if isinstance(n, dict)]
elif data.get("title") or data.get("findings") or data.get("context"):
candidates = [data] # legacy single-object output
else:
candidates = []
return [n for n in candidates if str(n.get("title") or "").strip() or n.get("findings")]
def _build_note_draft(
note_data: dict[str, Any],
*,
thread_id: str,
thread_title: str | None,
turns: list[dict[str, str]],
title_override: str | None,
tags: list[str] | None,
project_name: str | None,
sources: list[SourceDraft],
source_key_suffix: str = "",
) -> NoteDraft:
"""Render one note dict into a :class:`NoteDraft`."""
category = str(note_data.get("category") or "synthesis").strip().lower()
if category not in _VALID_CATEGORIES:
category = "synthesis"
final_title = (title_override or "").strip() or str(note_data.get("title") or "").strip() or extractor._auto_title(thread_title, turns)
summary = str(note_data.get("summary") or "").strip()
related = extractor.related_links(project_name)
body = _render_body(note_data, final_title, related, sources)
base_tags = list(tags or [])
llm_tags = note_data.get("tags") if isinstance(note_data.get("tags"), list) else []
for tag in [*llm_tags, project_name or "zncm", "knowledge-base"]:
tag = str(tag).strip()
if tag and tag not in base_tags:
base_tags.append(tag)
try:
confidence = float(note_data.get("confidence") or 0.7)
except (TypeError, ValueError):
confidence = 0.7
entities = note_data.get("entities") if isinstance(note_data.get("entities"), list) else []
relations = note_data.get("relations") if isinstance(note_data.get("relations"), list) else []
return NoteDraft(
title=final_title,
summary=summary or final_title,
content_md=body,
category=category,
source_type="thread",
source_id=thread_id,
tags=base_tags,
confidence=max(0.0, min(confidence, 1.0)),
related=related,
sources=sources,
entities=[e for e in entities if isinstance(e, dict) and e.get("name")],
relations=[r for r in relations if isinstance(r, dict) and r.get("from") and r.get("to")],
source_key=f"thread:{thread_id}{source_key_suffix}",
)
async def extract_thread_llm_multi(
messages: list[dict[str, Any]],
*,
thread_id: str,
thread_title: str | None = None,
mode: str = "summary",
title: str | None = None,
tags: list[str] | None = None,
project_name: str | None = "zncm",
sources: list[SourceDraft] | None = None,
max_messages: int = 80,
model_name: str | None = None,
max_input_chars: int = 12000,
allow_multi: bool = True,
max_notes: int = 4,
) -> list[NoteDraft]:
"""Distill a thread into one *or several* cross-linkable knowledge notes.
The LLM may split a conversation that covers multiple independent topics into
several declarative notes. Returns ``[]`` when there is nothing to sediment
or the LLM path fails — callers should fall back to the rule-based extractor.
When ``allow_multi`` is false only the first note is kept.
"""
turns = extractor.split_turns(messages, max_messages=max_messages)
if not turns:
return []
sources = sources or []
try:
from langchain_core.messages import HumanMessage, SystemMessage
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name or None, thinking_enabled=False)
transcript = _build_transcript(turns, sources, max_chars=max_input_chars)
prompt = [
SystemMessage(content=_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE),
HumanMessage(content=f"以下是一次对话,请按规范提炼为知识 JSON:\n\n{transcript}"),
]
response = await model.ainvoke(prompt)
raw = response.content if isinstance(response.content, str) else str(response.content)
data = _parse_json(raw)
except Exception:
logger.exception("[knowledge] thread %s — LLM extraction call failed; using rule-based fallback", thread_id)
return []
if not data:
logger.warning("[knowledge] thread %s — LLM returned no parsable JSON; using rule-based fallback", thread_id)
return []
note_dicts = _notes_from_data(data)
if not note_dicts:
reason = str(data.get("reason") or "")[:200]
if _is_falsey(data.get("should_ingest")):
# The model deliberately judged this conversation worthless (greeting,
# meta chatter, no findings). Skip ingestion entirely — do NOT fall
# back to the rule-based verbatim dump.
logger.warning("[knowledge] thread %s SKIPPED — model judged not worth ingesting: %s", thread_id, reason)
raise LLMRejectedIngest(reason or "no knowledge value")
logger.warning(
"[knowledge] thread %s — LLM produced no notes (should_ingest=%s, reason=%r); using rule-based fallback",
thread_id,
data.get("should_ingest"),
reason,
)
return []
if not allow_multi:
note_dicts = note_dicts[:1]
else:
note_dicts = note_dicts[: max(1, max_notes)]
drafts: list[NoteDraft] = []
for i, nd in enumerate(note_dicts):
# The user-supplied title override only applies to a single-note result;
# multi-note splits keep each note's own LLM title.
override = title if (len(note_dicts) == 1) else None
suffix = "" if len(note_dicts) == 1 else f"#{i}"
drafts.append(
_build_note_draft(
nd,
thread_id=thread_id,
thread_title=thread_title,
turns=turns,
title_override=override,
tags=tags,
project_name=project_name,
sources=sources,
source_key_suffix=suffix,
)
)
return drafts
async def extract_thread_llm(
messages: list[dict[str, Any]],
*,
thread_id: str,
thread_title: str | None = None,
mode: str = "summary",
title: str | None = None,
tags: list[str] | None = None,
project_name: str | None = "zncm",
sources: list[SourceDraft] | None = None,
max_messages: int = 80,
model_name: str | None = None,
max_input_chars: int = 12000,
) -> NoteDraft | None:
"""Distill a thread into a single :class:`NoteDraft` (backward-compatible).
Thin wrapper over :func:`extract_thread_llm_multi` that returns the first
note, or ``None`` when nothing was produced.
"""
try:
drafts = await extract_thread_llm_multi(
messages,
thread_id=thread_id,
thread_title=thread_title,
mode=mode,
title=title,
tags=tags,
project_name=project_name,
sources=sources,
max_messages=max_messages,
model_name=model_name,
max_input_chars=max_input_chars,
allow_multi=False,
)
except LLMRejectedIngest:
return None
return drafts[0] if drafts else None
@@ -0,0 +1,74 @@
""".manifest.json read/write for the vault.
The manifest records, per page, the source key, content hash, vault path and
update time — so batch ingest can skip already-sedimented threads (delta/dedup)
exactly like obsidian-wiki's native ``.manifest.json``.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
def content_hash(text: str) -> str:
"""Stable sha256 hex digest of ``text`` (used for dedup)."""
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
class Manifest:
"""JSON manifest stored at ``<vault>/.manifest.json``."""
def __init__(self, root: Path | str) -> None:
self.path = Path(root).resolve() / ".manifest.json"
def load(self) -> dict[str, Any]:
if not self.path.exists():
return {"version": 1, "updated": None, "pages": {}, "sources": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {"version": 1, "updated": None, "pages": {}, "sources": {}}
data.setdefault("pages", {})
data.setdefault("sources", {})
return data
def save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def record_page(
self,
*,
note_id: str,
vault_path: str,
source_key: str | None,
hash_value: str,
updated: str,
) -> None:
data = self.load()
data["pages"][note_id] = {
"vault_path": vault_path,
"source": source_key,
"hash": hash_value,
"updated": updated,
}
if source_key:
data["sources"][source_key] = {"note_id": note_id, "hash": hash_value, "updated": updated}
data["updated"] = updated
self.save(data)
def remove_page(self, note_id: str) -> None:
data = self.load()
page = data["pages"].pop(note_id, None)
if page and page.get("source"):
data["sources"].pop(page["source"], None)
self.save(data)
def has_source(self, source_key: str) -> bool:
return source_key in self.load().get("sources", {})
def get_source(self, source_key: str) -> dict[str, Any] | None:
return self.load().get("sources", {}).get(source_key)
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more