Merge remote-tracking branch 'origin/master' into xuhd
# Conflicts: # frontend-web/.env.production # frontend-web/.env.sit # frontend-web/package.json # offline-backend-20260512/backend/config.yaml
This commit is contained in:
commit
1668c1da87
177 files changed
+21030
-2738
No files matched your search
+4
-1
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# 生产打包(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
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# CMZS后端接口
|
||||
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# 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
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# CMZS后端接口
|
||||
VITE_API_BASE_URL=https://ch1.b.sit.4.cn/zncm
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
@@ -21,6 +26,11 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
@@ -161,6 +161,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={() =>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
@@ -21,20 +22,20 @@ import {
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trophy,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -61,8 +62,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
},
|
||||
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{
|
||||
id: "qa-zzjc",
|
||||
label: "zzjc支持",
|
||||
icon: Bot,
|
||||
path: "/page/strategy/agents/6bf/chats/new",
|
||||
},
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
@@ -209,10 +215,16 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{
|
||||
id: "mgmt-business-chain-mapping",
|
||||
label: "业务链条管理",
|
||||
label: "配置管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/business-chain-mapping",
|
||||
},
|
||||
{
|
||||
id: "mgmt-business-chains",
|
||||
label: "业务链条配置",
|
||||
icon: Layers,
|
||||
path: "/page/strategy/business-chains",
|
||||
},
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
@@ -221,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: "大模型调用统计",
|
||||
@@ -256,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: "定时任务管理",
|
||||
@@ -283,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: "知识库资源管理",
|
||||
@@ -306,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`` 的项及其子项 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 业务链条管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
|
||||
* 配置管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
|
||||
*
|
||||
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
|
||||
* - 智能体来自 `/api/agents`
|
||||
@@ -129,7 +129,7 @@ export default function BusinessChainMappingPage() {
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
|
||||
业务链条管理
|
||||
配置管理
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
|
||||
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components
|
||||
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
@@ -174,6 +176,8 @@ export default function StrategyRoutes() {
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
|
||||
<Route path="business-chains" element={<BusinessChainListPage />} />
|
||||
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
|
||||
|
||||
<Route
|
||||
path="scheduled"
|
||||
|
||||
@@ -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';
|
||||
@@ -42,6 +44,7 @@ import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -326,6 +329,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="business-chains"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<BusinessChainListPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|
||||
<Route
|
||||
path="roundtable/chain-preview"
|
||||
@@ -335,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 />}
|
||||
|
||||
@@ -142,10 +142,24 @@ export interface IntentSummary {
|
||||
*/
|
||||
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
|
||||
|
||||
/**
|
||||
* 用户上传文件的引用(已经过 `/api/threads/{thread_id}/uploads`)。透传给后端
|
||||
* `additional_kwargs.files`,由 UploadsMiddleware 注入文件清单并解锁 read_file 等工具。
|
||||
* 字段与普通聊天的 `FileInMessage` 对齐:`path` 用上传返回的 `virtual_path`。
|
||||
*/
|
||||
export interface IntentFileRef {
|
||||
filename: string;
|
||||
size: number;
|
||||
path: string;
|
||||
status?: "uploading" | "uploaded";
|
||||
}
|
||||
|
||||
export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
/** Step 1 用户上传的参考文件(可选)。 */
|
||||
files?: IntentFileRef[];
|
||||
signal?: AbortSignal;
|
||||
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
@@ -318,6 +332,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
thread_id: req.threadId,
|
||||
message: req.message,
|
||||
model: req.model,
|
||||
// 仅在有文件时带上,空数组后端也安全忽略。
|
||||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
@@ -227,6 +227,12 @@ export interface StreamMultiAgentRequest {
|
||||
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
|
||||
*/
|
||||
peerDeliveries?: Array<{ name: string; content: string }>;
|
||||
/**
|
||||
* 用户随干预消息上传的参考文件(已经过 `/api/threads/{thread_id}/uploads`)。
|
||||
* 透传给后端 `additional_kwargs.files`,由 UploadsMiddleware 注入清单 + 解锁
|
||||
* read_file 等工具。字段 `path` 用上传返回的 `virtual_path`。
|
||||
*/
|
||||
files?: Array<{ filename: string; size: number; path: string; status?: string }>;
|
||||
signal?: AbortSignal;
|
||||
/** Fires for every non-status data frame the upstream emits. */
|
||||
onUpstreamFrame?: (frame: unknown) => void;
|
||||
@@ -447,6 +453,8 @@ export async function streamMultiAgent(
|
||||
parallel_no_file: req.parallelNoFile ?? false,
|
||||
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
|
||||
peer_deliveries: req.peerDeliveries ?? [],
|
||||
// 用户随干预上传的参考文件;仅在有文件时带上,后端注入 additional_kwargs.files。
|
||||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
@@ -40,11 +40,30 @@ export interface BusinessChainListDialogProps {
|
||||
onConfigure: (chainId?: string) => void;
|
||||
}
|
||||
|
||||
export function BusinessChainListDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
export interface BusinessChainListBodyProps {
|
||||
/**
|
||||
* 是否激活(控制何时拉取列表):弹窗里传 `open`(打开才拉),
|
||||
* 独立页面里恒为 `true`。
|
||||
*/
|
||||
active: boolean;
|
||||
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
|
||||
onConfigure: (chainId?: string) => void;
|
||||
/** 列表为空时的「去创建」引导文案差异(弹窗说「点下方配置」,页面说「点右上配置」)。 */
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
|
||||
*
|
||||
* 从 `BusinessChainListDialog` 抽出,供弹窗与独立配置页(`BusinessChainListPage`)
|
||||
* 复用,保证两处的加载 / 发布 / 卡片渲染逻辑完全一致。外层负责标题栏与「配置」入口、
|
||||
* 以及撑出 `flex flex-col` 高度(本组件用 `flex-1` 占满剩余空间)。
|
||||
*/
|
||||
export function BusinessChainListBody({
|
||||
active,
|
||||
onConfigure,
|
||||
}: BusinessChainListDialogProps) {
|
||||
emptyHint,
|
||||
}: BusinessChainListBodyProps) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -65,9 +84,9 @@ export function BusinessChainListDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||||
// 每次激活 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
@@ -84,7 +103,7 @@ export function BusinessChainListDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, scope]);
|
||||
}, [active, scope]);
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
@@ -101,6 +120,73 @@ export function BusinessChainListDialog({
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载业务链条…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
加载业务链条失败:{error}
|
||||
</div>
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: emptyHint ??
|
||||
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{chains.map((chain) => (
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
publishing={publishingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessChainListDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfigure,
|
||||
}: BusinessChainListDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||
@@ -114,61 +200,7 @@ export function BusinessChainListDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载业务链条…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
加载业务链条失败:{error}
|
||||
</div>
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{chains.map((chain) => (
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
publishing={publishingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BusinessChainListBody active={open} onConfigure={onConfigure} />
|
||||
|
||||
<DialogFooter className="shrink-0 border-t pt-3 sm:justify-end">
|
||||
<Button onClick={() => onConfigure()} className="gap-1.5">
|
||||
@@ -246,22 +278,70 @@ function ChainRow({
|
||||
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
|
||||
)}
|
||||
|
||||
{/* 席位名 tag(按顺序,→ 箭头连接发言次序) */}
|
||||
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
|
||||
{chain.seats.map((seat, i) => (
|
||||
<Fragment key={seat.agent_id}>
|
||||
{i > 0 && <ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs font-medium",
|
||||
chainTagClasses(getAvatarTypeFor(seat.agent_id, i)),
|
||||
)}
|
||||
>
|
||||
{seat.name || seat.agent_id}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。 */}
|
||||
<ChainSeatsFlow chain={chain} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 链条席位编排流(体现「同层并行 · 层间串行」)─────────────────────────────
|
||||
|
||||
function ChainSeatsFlow({ chain }: { chain: BusinessChain }) {
|
||||
// 名称回退表:用落库快照(seats)解析 stage 里的 agent_id。
|
||||
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
|
||||
|
||||
// 分层来源:带 stages(分层编排)就按 stages 渲染(过滤空阶段);否则每个席位各成
|
||||
// 一段、顺序串行(线性链,与旧版展示一致)。
|
||||
const stages =
|
||||
chain.stages && chain.stages.length > 0
|
||||
? chain.stages.filter((s) => s.length > 0)
|
||||
: chain.seats.map((s) => [s.agent_id]);
|
||||
|
||||
// 跨阶段累加的全局序号 —— 决定头像配色,与编辑器 / 展平 seats 顺序对齐。
|
||||
const orderById = new Map<string, number>();
|
||||
let counter = 0;
|
||||
for (const stageIds of stages) {
|
||||
for (const id of stageIds) {
|
||||
orderById.set(id, counter);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const seatTag = (agentId: string) => {
|
||||
const seat = seatById.get(agentId);
|
||||
return (
|
||||
<span
|
||||
key={agentId}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs font-medium",
|
||||
chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0)),
|
||||
)}
|
||||
>
|
||||
{seat?.name || agentId}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
|
||||
{stages.map((stageIds, stageIdx) => (
|
||||
<Fragment key={stageIdx}>
|
||||
{stageIdx > 0 && (
|
||||
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
{stageIds.length > 1 ? (
|
||||
// 并行阶段:席位竖向并列在虚线框里,左上角标「并行 N」。
|
||||
<div className="relative flex flex-col gap-1 rounded-lg border border-dashed border-primary/40 bg-primary/[0.04] px-1.5 pb-1.5 pt-2.5">
|
||||
<span className="pointer-events-none absolute -top-2 left-1.5 rounded-full bg-card px-1.5 text-[10px] font-semibold leading-tight text-primary">
|
||||
并行 {stageIds.length}
|
||||
</span>
|
||||
{stageIds.map((id) => seatTag(id))}
|
||||
</div>
|
||||
) : (
|
||||
seatTag(stageIds[0])
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -289,6 +289,43 @@ function ChainCard({
|
||||
const deletedCount = chain.seats.length - survivingCount;
|
||||
const usable = survivingCount >= CHAIN_MIN_SEATS;
|
||||
|
||||
// ── 席位编排(体现「同层并行 · 层间串行」)─────────────────────────────
|
||||
// 名称回退表:实时 name(nameById)→ 落库快照(seat.name)→ agent_id。
|
||||
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
|
||||
// 带 stages(分层编排)按 stages 渲染(过滤空阶段);否则每席位各成一段、顺序串行。
|
||||
const stages =
|
||||
chain.stages && chain.stages.length > 0
|
||||
? chain.stages.filter((s) => s.length > 0)
|
||||
: chain.seats.map((s) => [s.agent_id]);
|
||||
// 跨阶段累加的全局序号 —— 决定头像配色,与展平 seats 顺序对齐。
|
||||
const orderById = new Map<string, number>();
|
||||
let seatOrder = 0;
|
||||
for (const stageIds of stages) {
|
||||
for (const id of stageIds) {
|
||||
orderById.set(id, seatOrder);
|
||||
seatOrder += 1;
|
||||
}
|
||||
}
|
||||
// 单个席位 tag:存活按配色,已删除置灰删除线(保留旧行为)。
|
||||
const seatTag = (agentId: string) => {
|
||||
const alive = isExisting(agentId);
|
||||
const label = nameById.get(agentId) ?? seatById.get(agentId)?.name ?? agentId;
|
||||
return (
|
||||
<span
|
||||
key={`tag-${agentId}`}
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
alive
|
||||
? chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0))
|
||||
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
|
||||
)}
|
||||
title={alive ? undefined : "该智能体已不存在"}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -322,36 +359,36 @@ function ChainCard({
|
||||
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">{chain.description}</div>
|
||||
)}
|
||||
|
||||
{/* 智能体名 tag(按顺序,← 箭头连接) */}
|
||||
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。
|
||||
不用 <Fragment>:dev 期 tagger 会往 Fragment 注入 data-lov-id 触发 React 警告,
|
||||
这里用带 key 的扁平数组(箭头 + 阶段),注入落到真实元素上即可。 */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{chain.seats.flatMap((seat, i) => {
|
||||
const alive = isExisting(seat.agent_id);
|
||||
const label = nameById.get(seat.agent_id) ?? seat.name ?? seat.agent_id;
|
||||
// 不用 <Fragment>:dev 期 tagger 会往 Fragment 注入 data-lov-id 触发 React 警告,
|
||||
// 这里改成带 key 的扁平数组(箭头 + tag),注入落到真实元素上即可。
|
||||
{stages.flatMap((stageIds, stageIdx) => {
|
||||
const nodes: ReactNode[] = [];
|
||||
if (i > 0) {
|
||||
if (stageIdx > 0) {
|
||||
nodes.push(
|
||||
<ArrowRightIcon
|
||||
key={`arrow-${seat.agent_id}`}
|
||||
key={`arrow-${stageIdx}`}
|
||||
className="size-3 shrink-0 text-muted-foreground/50"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
<span
|
||||
key={`tag-${seat.agent_id}`}
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
alive
|
||||
? chainTagClasses(getAvatarTypeFor(seat.agent_id, i))
|
||||
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
|
||||
)}
|
||||
title={alive ? undefined : "该智能体已不存在"}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
);
|
||||
if (stageIds.length > 1) {
|
||||
// 并行阶段:席位竖向并列在虚线框里,左上角标「并行 N」。
|
||||
nodes.push(
|
||||
<div
|
||||
key={`stage-${stageIdx}`}
|
||||
className="relative flex flex-col gap-1 rounded-lg border border-dashed border-primary/40 bg-primary/[0.04] px-1.5 pb-1.5 pt-2.5"
|
||||
>
|
||||
<span className="pointer-events-none absolute -top-2 left-1.5 rounded-full bg-card px-1.5 text-[10px] font-semibold leading-tight text-primary">
|
||||
并行 {stageIds.length}
|
||||
</span>
|
||||
{stageIds.map((id) => seatTag(id))}
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
nodes.push(seatTag(stageIds[0]));
|
||||
}
|
||||
return nodes;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Repeat,
|
||||
Save,
|
||||
@@ -25,6 +27,13 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 人类可读的文件大小(B / KB / MB)。 */
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
@@ -62,7 +71,7 @@ export interface Step1PanelProps {
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<string, IntentClarificationSelection>>
|
||||
>;
|
||||
onSendMessage: (text: string) => void;
|
||||
onSendMessage: (text: string, files?: File[]) => void;
|
||||
onStopIntent: () => void;
|
||||
onSkipQuestion: () => void;
|
||||
onFollowUp: () => void;
|
||||
@@ -101,13 +110,34 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
|
||||
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
// 待发送的本地附件(点发送时连同文本一起上传)。
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const charactersLeft = inputText.length;
|
||||
|
||||
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
if (picked.length > 0) {
|
||||
// 去重(按 名称+大小),追加到已选列表。
|
||||
setAttachedFiles((prev) => {
|
||||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
||||
});
|
||||
}
|
||||
// 清空 input 值,允许再次选同名文件。
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const removeAttachedFile = (idx: number) => {
|
||||
setAttachedFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputText.trim()) return;
|
||||
onSendMessage(inputText);
|
||||
if (!inputText.trim() && attachedFiles.length === 0) return;
|
||||
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
|
||||
setInputText("");
|
||||
setAttachedFiles([]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -150,6 +180,33 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
{/* Bottom input area */}
|
||||
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
|
||||
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
|
||||
{/* 已选附件预览(点发送时随消息上传) */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-2 mb-2 border-b border-slate-100">
|
||||
{attachedFiles.map((file, idx) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="inline-flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachedFile(idx)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
id="input-text-area"
|
||||
value={inputText}
|
||||
@@ -177,6 +234,21 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
>
|
||||
<Smile className="w-4 h-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePickFiles}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
|
||||
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
|
||||
@@ -422,8 +494,30 @@ function Step1Message({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
<div className="space-y-2">
|
||||
{msg.files && msg.files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{msg.files.map((file, i) => (
|
||||
<span
|
||||
key={`${file.filename}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 bg-white/70 border border-slate-200 rounded-lg px-2 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.filename}>
|
||||
{file.filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{msg.content && (
|
||||
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</MessageBubble>
|
||||
|
||||
@@ -23,11 +23,20 @@ import {
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
Network,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Play,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 人类可读的文件大小(B / KB / MB)。 */
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -124,7 +133,7 @@ export interface Step2PanelProps {
|
||||
// ─── Handlers ─────────────────────────────────────────────────
|
||||
onPauseRoundtable: () => void;
|
||||
onResumeRoundtable: () => void;
|
||||
onHandleInjectToRoundtable: () => void;
|
||||
onHandleInjectToRoundtable: (files?: File[]) => void;
|
||||
onSubmitClarification: (answer: string) => void;
|
||||
onSavePersona: (id: number) => void;
|
||||
onProceedToStep3: () => void;
|
||||
@@ -251,11 +260,34 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||
const [pauseFlash, setPauseFlash] = useState(false);
|
||||
|
||||
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
|
||||
const [injectFiles, setInjectFiles] = useState<File[]>([]);
|
||||
const injectFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePickInjectFiles = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
if (picked.length > 0) {
|
||||
setInjectFiles((prev) => {
|
||||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
||||
});
|
||||
}
|
||||
e.target.value = "";
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeInjectFile = useCallback((idx: number) => {
|
||||
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||
}, []);
|
||||
|
||||
const handleInject = useCallback(() => {
|
||||
onHandleInjectToRoundtable();
|
||||
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
|
||||
setInjectFiles([]);
|
||||
setPauseFlash(true);
|
||||
window.setTimeout(() => setPauseFlash(false), 1600);
|
||||
}, [onHandleInjectToRoundtable]);
|
||||
}, [onHandleInjectToRoundtable, injectFiles]);
|
||||
|
||||
const isSandboxStreaming =
|
||||
isOrchestrating ||
|
||||
@@ -530,7 +562,35 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
)}
|
||||
|
||||
{/* Console + intervention bar */}
|
||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
|
||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
|
||||
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
|
||||
{injectFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{injectFiles.map((file, idx) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
>
|
||||
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInjectFile(idx)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={customAgentInput}
|
||||
@@ -542,6 +602,22 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={injectFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePickInjectFiles}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => injectFileInputRef.current?.click()}
|
||||
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
|
||||
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInject}
|
||||
@@ -581,6 +657,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
initIntent,
|
||||
streamIntent,
|
||||
type IntentErrorDetail,
|
||||
type IntentFileRef,
|
||||
type IntentStreamPhase,
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import { uploadFiles } from "@/core/uploads/api";
|
||||
import {
|
||||
clarificationKey,
|
||||
formatIntentClarificationAnswer,
|
||||
@@ -42,6 +44,8 @@ export type InteractiveMessage = {
|
||||
time: string;
|
||||
streaming?: boolean;
|
||||
phase?: string;
|
||||
/** 用户气泡里附带的上传文件(仅展示用:文件名 + 大小)。 */
|
||||
files?: Array<{ filename: string; size: number }>;
|
||||
/** First clarification (back-compat); prefer `clarifications`. */
|
||||
clarification?: ClarificationPayload;
|
||||
/**
|
||||
@@ -109,8 +113,12 @@ export interface UseStep1IntentReturn {
|
||||
// ─── Handlers ────────────────────────────────────────────────────────
|
||||
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
||||
runStep1Init: () => void;
|
||||
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
|
||||
sendIntentMessage: (userText: string) => Promise<void>;
|
||||
/**
|
||||
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
|
||||
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
|
||||
* 注入 additional_kwargs.files,意图智能体即可 read_file 读取)。允许「仅文件无文本」。
|
||||
*/
|
||||
sendIntentMessage: (userText: string, files?: File[]) => Promise<void>;
|
||||
/**
|
||||
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
|
||||
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
|
||||
@@ -123,6 +131,12 @@ export interface UseStep1IntentReturn {
|
||||
clarifications: ClarificationPayload[],
|
||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||
) => void;
|
||||
/**
|
||||
* 用户在「当前意图摘要」卡里手动编辑后保存:直接覆盖 `intentReady`。下游(Step 2 seed
|
||||
* `buildInitialIntent`、左右侧栏摘要卡、step2Locked 判定)都读 `intentReady`,改这里即全局生效。
|
||||
* 持久化由调用方(页面)在更新后触发草稿保存。
|
||||
*/
|
||||
updateIntentReady: (next: IntentSummary) => void;
|
||||
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
||||
stopIntentStream: () => void;
|
||||
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
|
||||
@@ -269,9 +283,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
}, [interactiveMessages, intentReady]);
|
||||
|
||||
const sendIntentMessage = useCallback(
|
||||
async (userText: string) => {
|
||||
async (userText: string, files?: File[]) => {
|
||||
const trimmed = userText.trim();
|
||||
if (!trimmed) return;
|
||||
const hasFiles = !!(files && files.length > 0);
|
||||
if (!trimmed && !hasFiles) return;
|
||||
if (!intentThreadId) {
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
@@ -298,16 +313,24 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
senderName: "用户",
|
||||
content: trimmed,
|
||||
time: userMsgTime,
|
||||
files: hasFiles
|
||||
? files!.map((f) => ({ filename: f.name, size: f.size }))
|
||||
: undefined,
|
||||
};
|
||||
setInteractiveMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
const timelineDesc = trimmed
|
||||
? trimmed.length > 25
|
||||
? trimmed.substring(0, 25) + "..."
|
||||
: trimmed
|
||||
: `上传了 ${files!.length} 个文件`;
|
||||
setTimelineEntries((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: userMsgTime,
|
||||
title: "用户实时反馈",
|
||||
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
|
||||
desc: timelineDesc,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -327,10 +350,49 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
|
||||
setIsIntentSending(true);
|
||||
try {
|
||||
// 先把本地文件上传到 intent thread —— 与普通聊天同一上传接口/技能链路。
|
||||
// 拿到 virtual_path 后随消息提交,后端注入 additional_kwargs.files。
|
||||
let fileRefs: IntentFileRef[] | undefined;
|
||||
if (hasFiles) {
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId ? { ...m, phase: "正在上传文件" } : m,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const up = await uploadFiles(intentThreadId, files!);
|
||||
fileRefs = up.files.map((f) => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
path: f.virtual_path,
|
||||
status: "uploaded" as const,
|
||||
}));
|
||||
} catch (err) {
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: `(文件上传失败) ${(err as Error).message}`,
|
||||
streaming: false,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
|
||||
if (intentAborterRef.current === aborter) {
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
setIsIntentSending(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
setInteractiveMessages((prev) =>
|
||||
@@ -568,6 +630,12 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setInteractiveMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
const updateIntentReady = useCallback((next: IntentSummary) => {
|
||||
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
|
||||
setIntentReady(next);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
@@ -582,6 +650,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
runStep1Init,
|
||||
sendIntentMessage,
|
||||
submitIntentClarifications,
|
||||
updateIntentReady,
|
||||
stopIntentStream,
|
||||
hydrateFromDraft,
|
||||
appendLocalMessage,
|
||||
|
||||
@@ -23,8 +23,20 @@ import {
|
||||
type ClarificationPayload,
|
||||
type LeaderDispatch,
|
||||
type StreamPhase,
|
||||
type StreamMultiAgentRequest,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { uploadFiles } from "@/core/uploads/api";
|
||||
|
||||
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
|
||||
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
||||
|
||||
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
|
||||
function takeOnce<T>(ref: { current: T | null }): T | undefined {
|
||||
const v = ref.current ?? undefined;
|
||||
ref.current = null;
|
||||
return v;
|
||||
}
|
||||
import {
|
||||
buildDagStageLeaderTask,
|
||||
pickDagSeatTask,
|
||||
@@ -305,8 +317,11 @@ export interface UseStep2OrchestrationReturn {
|
||||
recordInjectMessage: () => void;
|
||||
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
|
||||
abortAllInFlight: () => void;
|
||||
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
|
||||
handleInjectToRoundtable: () => void;
|
||||
/**
|
||||
* 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。
|
||||
* `files`:可选的参考文件,发送前先上传到总控 thread,首轮 leader run 据此 read_file 读取。
|
||||
*/
|
||||
handleInjectToRoundtable: (files?: File[]) => void;
|
||||
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
|
||||
submitClarification: (rawAnswer: string) => void;
|
||||
/** 预算滑块(本地演示,非编排真实输入)。 */
|
||||
@@ -464,6 +479,11 @@ export function useStep2Orchestration(
|
||||
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
||||
/** 干预研审:恢复会商后待执行的 leader prompt。 */
|
||||
const pendingInjectPromptRef = useRef<string | null>(null);
|
||||
/**
|
||||
* 干预消息携带的上传文件引用:由下一轮编排的**首个 leader run**消费一次后清空,
|
||||
* 总控据此 read_file 读取文件再派活。consume-once 语义避免后续轮次重复带文件。
|
||||
*/
|
||||
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
|
||||
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
||||
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
||||
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
||||
@@ -1353,6 +1373,8 @@ export function useStep2Orchestration(
|
||||
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
|
||||
// 首轮纯派活、无交付,传 false 保持上下文精简。
|
||||
synthesisMode: safetyCounter > 1,
|
||||
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
@@ -1600,6 +1622,8 @@ export function useStep2Orchestration(
|
||||
dAgentThreadId: ids,
|
||||
agentName: coord,
|
||||
newMessage: leaderTask,
|
||||
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
@@ -1887,6 +1911,8 @@ export function useStep2Orchestration(
|
||||
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
|
||||
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
|
||||
suppressPreBroadcast: true,
|
||||
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
@@ -2289,38 +2315,75 @@ export function useStep2Orchestration(
|
||||
triggerToast("干预指令已记录,会商已自动恢复执行");
|
||||
}, [appendDialogue, customAgentInput, triggerToast]);
|
||||
|
||||
const handleInjectToRoundtable = useCallback(() => {
|
||||
const userText = customAgentInput.trim();
|
||||
if (!userText) return;
|
||||
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
||||
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
||||
return;
|
||||
}
|
||||
const handleInjectToRoundtable = useCallback(
|
||||
async (files?: File[]) => {
|
||||
const userText = customAgentInput.trim();
|
||||
const hasFiles = !!(files && files.length > 0);
|
||||
if (!userText && !hasFiles) return;
|
||||
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
||||
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
||||
return;
|
||||
}
|
||||
|
||||
// 中止当前编排(如有)
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
// 强制标记非编排中,使后续 runOrchestration 可立即启动
|
||||
isOrchestratingRef.current = false;
|
||||
setIsOrchestrating(false);
|
||||
// 先把文件上传到总控 thread(与普通聊天同一上传接口/技能链路)。拿到 virtual_path
|
||||
// 存进 consume-once ref,供下一轮首个 leader run 注入 additional_kwargs.files。
|
||||
// 上传失败则中止本次干预,不打断当前会商。
|
||||
if (hasFiles) {
|
||||
const coordThreadId = resolveSeatThreadId(
|
||||
threadIdsRef.current,
|
||||
coordinatorNameRef.current,
|
||||
);
|
||||
try {
|
||||
const up = await uploadFiles(coordThreadId, files!);
|
||||
pendingInjectFilesRef.current = up.files.map((f) => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
path: f.virtual_path,
|
||||
status: "uploaded",
|
||||
}));
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCustomAgentInput("");
|
||||
appendDialogue({
|
||||
sender: "用户 (插话干预)",
|
||||
avatarType: "human",
|
||||
content: `💡 (人工指令介入) ${userText}`,
|
||||
confidence: "最高指令",
|
||||
});
|
||||
// 中止当前编排(如有)
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
// 强制标记非编排中,使后续 runOrchestration 可立即启动
|
||||
isOrchestratingRef.current = false;
|
||||
setIsOrchestrating(false);
|
||||
|
||||
const prompt = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
|
||||
setCustomAgentInput("");
|
||||
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
|
||||
const shownText = userText || "(上传了参考文件)";
|
||||
appendDialogue({
|
||||
sender: "用户 (插话干预)",
|
||||
avatarType: "human",
|
||||
content: `💡 (人工指令介入) ${shownText}${fileNote}`,
|
||||
confidence: "最高指令",
|
||||
});
|
||||
|
||||
// 确保处于运行状态(同步更新按钮 UI)
|
||||
setIsRoundTableRunning(true);
|
||||
triggerToast("干预指令已发送,会商继续执行");
|
||||
const promptText =
|
||||
userText || "我上传了参考文件,请先阅读文件内容,再据此推进研讨。";
|
||||
const prompt = `用户人工干预新指令:${promptText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
|
||||
|
||||
// 直接启动新一轮编排
|
||||
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
|
||||
// 确保处于运行状态(同步更新按钮 UI)
|
||||
setIsRoundTableRunning(true);
|
||||
triggerToast("干预指令已发送,会商继续执行");
|
||||
|
||||
// 直接启动新一轮编排
|
||||
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||
},
|
||||
[
|
||||
customAgentInput,
|
||||
appendDialogue,
|
||||
abortAllInFlight,
|
||||
runActiveOrchestration,
|
||||
resolveSeatThreadId,
|
||||
triggerToast,
|
||||
],
|
||||
);
|
||||
|
||||
const submitClarification = useCallback(
|
||||
(rawAnswer: string) => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 业务链条配置页(独立页面版)。
|
||||
*
|
||||
* 把原本挂在圆桌页顶栏「业务链条」按钮上的 `BusinessChainListDialog` 弹窗内容,
|
||||
* 单独画成一个全屏页面:综合管理 → 业务链条配置 入口进来即是本页。
|
||||
*
|
||||
* - 列表主体复用 `BusinessChainListBody`(与弹窗同一份加载 / 发布 / 卡片逻辑)。
|
||||
* - 顶部「配置」按钮 = 进入编辑器空白新建;卡片「设置」= 进入编辑器并切到该链条。
|
||||
*
|
||||
* 路由 base 感知:用 `useRouteNavigate` 跳转,strategy / workspace 两套路由树下
|
||||
* 都能落到当前 base 的编辑器(`roundtable/chains`)。
|
||||
*/
|
||||
import { LayersIcon, SettingsIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouteNavigate } from "@/core/route-base";
|
||||
|
||||
import { BusinessChainListBody } from "@/roundtable-planning/components/BusinessChainListDialog";
|
||||
|
||||
export default function BusinessChainListPage() {
|
||||
const navigate = useRouteNavigate();
|
||||
|
||||
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
||||
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
||||
const goConfigure = (chainId?: string) => {
|
||||
navigate(
|
||||
chainId
|
||||
? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}`
|
||||
: "/page/workspace/roundtable/chains",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
{/* 顶栏:标题 + 描述 + 配置入口 */}
|
||||
<header className="shrink-0 border-b border-border bg-card px-6 py-4">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-base font-bold text-foreground">
|
||||
<LayersIcon className="size-5 text-primary" />
|
||||
业务链条配置
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
可复用的有序智能体编排。点击卡片「设置」可进入编辑器修改该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => goConfigure()} className="shrink-0 gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
配置
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 列表主体(与弹窗共用 BusinessChainListBody) */}
|
||||
<div className="mx-auto flex w-full min-h-0 max-w-3xl flex-1 flex-col px-6 py-4">
|
||||
<BusinessChainListBody
|
||||
active
|
||||
onConfigure={goConfigure}
|
||||
emptyHint="业务链条是可复用的有序智能体编排。点击右上角「配置」去创建第一条链条。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,12 +41,14 @@ import {
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
type SeatSelectionResult,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
@@ -499,6 +502,26 @@ export default function RoundtablePlanningPage() {
|
||||
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
|
||||
});
|
||||
|
||||
// ─── 用户手动编辑「当前意图摘要」并保存 ───────────────────────
|
||||
// 先 updateIntentReady 覆盖意图,再 bump 计数触发一次草稿保存。**必须用计数 + effect**
|
||||
// 而非直接调 handleManualSave:enqueueSave 闭包捕获的 getStep1Snapshot 读的是本次渲染的
|
||||
// step1.intentReady,同步调用会在 setState flush 前存进旧意图(踩坑);放到 effect 里,等
|
||||
// 重渲染后再保存,getStep1Snapshot 才读到新值(与 step2/step3 的计数触发器同构)。
|
||||
const [intentEditTick, setIntentEditTick] = useState(0);
|
||||
const handleSaveIntent = useCallback(
|
||||
(next: IntentSummary) => {
|
||||
step1.updateIntentReady(next);
|
||||
setIntentEditTick((t) => t + 1);
|
||||
},
|
||||
[step1],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (intentEditTick === 0) return;
|
||||
drafts.handleManualSave();
|
||||
// 仅在 tick 递增时保存;handleManualSave 取最新闭包(effect 跑在重渲染之后)。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intentEditTick]);
|
||||
|
||||
// ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
|
||||
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
|
||||
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
|
||||
@@ -987,7 +1010,7 @@ export default function RoundtablePlanningPage() {
|
||||
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={step1.intentClarificationSelections}
|
||||
setIntentClarificationSelections={step1.setIntentClarificationSelections}
|
||||
onSendMessage={(text) => void step1.sendIntentMessage(text)}
|
||||
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
|
||||
onStopIntent={step1.stopIntentStream}
|
||||
onSkipQuestion={handleSkipQuestion}
|
||||
onFollowUp={handleAgentFollowUp}
|
||||
@@ -1106,6 +1129,7 @@ export default function RoundtablePlanningPage() {
|
||||
intentReady={step1.intentReady}
|
||||
timelineEntries={step1.timelineEntries}
|
||||
triggerToast={triggerToast}
|
||||
onSaveIntent={handleSaveIntent}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -1763,6 +1787,148 @@ function ModelSelectorCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「当前意图摘要」编辑态:目标(单行/多行文本)、约束、关键假设(均为可增删的列表)。
|
||||
* 受控本地草稿,保存时过滤空行、要求目标非空,再上抛 {@link IntentSummary}。
|
||||
*/
|
||||
function IntentSummaryEditor({
|
||||
initial,
|
||||
title,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: IntentSummary;
|
||||
title: string;
|
||||
onSave: (next: IntentSummary) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [objective, setObjective] = useState(initial.objective ?? "");
|
||||
const [constraints, setConstraints] = useState<string[]>(
|
||||
initial.constraints?.length ? [...initial.constraints] : [""],
|
||||
);
|
||||
const [assumptions, setAssumptions] = useState<string[]>(
|
||||
initial.assumptions?.length ? [...initial.assumptions] : [],
|
||||
);
|
||||
|
||||
const objectiveInvalid = objective.trim().length === 0;
|
||||
|
||||
const updateRow = (
|
||||
setter: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
idx: number,
|
||||
value: string,
|
||||
) => setter((prev) => prev.map((v, i) => (i === idx ? value : v)));
|
||||
const removeRow = (setter: React.Dispatch<React.SetStateAction<string[]>>, idx: number) =>
|
||||
setter((prev) => prev.filter((_, i) => i !== idx));
|
||||
const addRow = (setter: React.Dispatch<React.SetStateAction<string[]>>) =>
|
||||
setter((prev) => [...prev, ""]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (objectiveInvalid) return;
|
||||
onSave({
|
||||
objective: objective.trim(),
|
||||
constraints: constraints.map((c) => c.trim()).filter(Boolean),
|
||||
assumptions: assumptions.map((a) => a.trim()).filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
const rowList = (
|
||||
label: string,
|
||||
rows: string[],
|
||||
setter: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
placeholder: string,
|
||||
) => (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={row}
|
||||
onChange={(e) => updateRow(setter, i, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[11.5px] text-slate-700 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(setter, i)}
|
||||
title="删除该项"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addRow(setter)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-500 hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-primary/30 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<IntentSectionIcon className="w-4 h-4" />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">
|
||||
编辑{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={objectiveInvalid}
|
||||
title="保存"
|
||||
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-2.5 text-[11px] font-bold text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
title="取消"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
|
||||
<textarea
|
||||
value={objective}
|
||||
onChange={(e) => setObjective(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="一句话说明本次要达成什么"
|
||||
className={cn(
|
||||
"w-full resize-y rounded-lg border bg-white px-2.5 py-1.5 text-[11.5px] leading-relaxed text-slate-700 focus:outline-none focus:ring-1",
|
||||
objectiveInvalid
|
||||
? "border-rose-300 focus:border-rose-400 focus:ring-rose-200/50"
|
||||
: "border-slate-200 focus:border-primary focus:ring-[var(--brand-200)]/50",
|
||||
)}
|
||||
/>
|
||||
{objectiveInvalid && (
|
||||
<p className="text-[10px] text-rose-500">核心目标不能为空</p>
|
||||
)}
|
||||
</div>
|
||||
{rowList("约束", constraints, setConstraints, "如:预算 / 时间 / 合规 / 范围")}
|
||||
{rowList("假设", assumptions, setAssumptions, "智能体为你做的假设(可选)")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntentSummaryCardProps {
|
||||
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||
title?: string;
|
||||
@@ -1773,6 +1939,11 @@ interface IntentSummaryCardProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
/**
|
||||
* 提供则显示「编辑」按钮,允许用户手动修改识别出的意图(目标 / 约束 / 假设)并保存。
|
||||
* 保存后回调上抛新意图,由页面覆盖 `intentReady` 并持久化草稿。不传 = 只读。
|
||||
*/
|
||||
onSave?: (next: IntentSummary) => void;
|
||||
}
|
||||
|
||||
function IntentSummaryCard({
|
||||
@@ -1780,9 +1951,25 @@ function IntentSummaryCard({
|
||||
title = "当前意图摘要",
|
||||
collapsible = false,
|
||||
sidebarToggle,
|
||||
onSave,
|
||||
}: IntentSummaryCardProps) {
|
||||
const [expanded, setExpanded] = useState(!collapsible);
|
||||
const showFullDetail = !collapsible || expanded;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
if (editing && intentReady) {
|
||||
return (
|
||||
<IntentSummaryEditor
|
||||
initial={intentReady}
|
||||
title={title}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSave={(next) => {
|
||||
onSave?.(next);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
@@ -1791,21 +1978,33 @@ function IntentSummaryCard({
|
||||
<IntentSectionIcon className="w-4 h-4" />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
|
||||
</div>
|
||||
{sidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sidebarToggle.onToggle}
|
||||
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
||||
aria-expanded={sidebarToggle.expanded}
|
||||
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{sidebarToggle.expanded ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onSave && intentReady && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
title="编辑意图"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{sidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sidebarToggle.onToggle}
|
||||
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
||||
aria-expanded={sidebarToggle.expanded}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{sidebarToggle.expanded ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
||||
@@ -2052,6 +2251,8 @@ interface RightSidebarProps {
|
||||
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
|
||||
triggerToast: (msg: string) => void;
|
||||
/** 用户编辑并保存意图的回调(透传给 IntentSummaryCard)。 */
|
||||
onSaveIntent: (next: IntentSummary) => void;
|
||||
}
|
||||
|
||||
function RightSidebar({
|
||||
@@ -2063,6 +2264,7 @@ function RightSidebar({
|
||||
intentReady,
|
||||
timelineEntries,
|
||||
triggerToast,
|
||||
onSaveIntent,
|
||||
}: RightSidebarProps) {
|
||||
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||
if (currentStep !== 1) return null;
|
||||
@@ -2078,7 +2280,7 @@ function RightSidebar({
|
||||
modelsLoading={modelsLoading}
|
||||
/>
|
||||
|
||||
<IntentSummaryCard intentReady={intentReady} />
|
||||
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
|
||||
|
||||
{/* 澄清记录 Timeline */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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');
|
||||
|
||||
@@ -37,10 +37,10 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
||||
|
||||
const columnClass = isDark
|
||||
? 'border border-[rgba(67,89,124,0.46)] bg-[linear-gradient(180deg,rgba(18,36,67,0.96),rgba(12,28,54,0.96))] shadow-[0_18px_40px_rgba(3,10,22,0.18)]'
|
||||
: 'border border-[#E8E8E8] bg-[linear-gradient(180deg,#FFFFFF_0%,#FFF9F9_100%)] shadow-[0_14px_28px_rgba(245,34,45,0.06)]';
|
||||
: 'border border-[#E8E8E8] bg-[linear-gradient(180deg,#FFFFFF_0%,#FFF9F9_100%)] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
|
||||
|
||||
return (
|
||||
<section className={`flex h-full flex-col rounded-[28px] p-4 ${columnClass}`}>
|
||||
<section className={`flex h-full flex-col rounded p-4 ${columnClass}`}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@@ -53,7 +53,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
||||
{title}
|
||||
</h2>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs ${
|
||||
className={`inline-flex rounded-sm px-2 py-0.5 text-xs ${
|
||||
isDark ? 'bg-[rgba(77,141,255,0.14)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'
|
||||
}`}
|
||||
>
|
||||
@@ -76,14 +76,14 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={`mt-2 text-xs leading-6 ${isDark ? 'text-slate-400' : 'text-[#8C8C8C]'}`}>
|
||||
<p className={`mt-1.5 text-xs leading-5 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`}>
|
||||
按时间倒序展示,支持分页切换。
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex-1 space-y-4">
|
||||
<div className="mt-3 flex-1 space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
<div
|
||||
className={`flex h-full min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed ${
|
||||
className={`flex h-full min-h-[220px] flex-col items-center justify-center rounded border border-dashed ${
|
||||
isDark
|
||||
? 'border-[rgba(87,111,149,0.42)] bg-[rgba(12,24,44,0.46)] text-slate-400'
|
||||
: 'border-[#F3C4C8] bg-[#FFF7F7] text-[#A6A6A6]'
|
||||
|
||||
+46
-36
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowRight, Bot, Layers, Plus, Search, Users, X } from 'lucide-react';
|
||||
import { ArrowRight, Bot, CircleCheck, Inbox, Layers, Loader, Plus, Search, Users, X } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -170,7 +170,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
setShowAnalysisModal(false);
|
||||
};
|
||||
|
||||
// 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 单智能体分析:用「配置管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage
|
||||
// 自动发出并开始问答。未配置智能体则提示去配置。
|
||||
const handleSingleAgentAnalysis = async () => {
|
||||
@@ -184,7 +184,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
/* ignore → 下面按未配置处理 */
|
||||
}
|
||||
if (!agentId) {
|
||||
toast.error('该业务尚未在「业务链条管理」中配置单智能体');
|
||||
toast.error('该业务尚未在「配置管理」中配置单智能体');
|
||||
return;
|
||||
}
|
||||
const text = [
|
||||
@@ -226,7 +226,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
|
||||
const toolbarClass = isDark
|
||||
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.94)] text-white shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
|
||||
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_14px_30px_rgba(245,34,45,0.06)]';
|
||||
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
|
||||
|
||||
const metricClass = isDark
|
||||
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)]'
|
||||
@@ -237,31 +237,31 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]';
|
||||
|
||||
const metrics = [
|
||||
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal },
|
||||
{ label: '未分析', value: unanalyzedTotal },
|
||||
{ label: '分析中', value: runningTotal },
|
||||
{ label: '已完成', value: completedTotal },
|
||||
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal, icon: Layers, fg: '#222222', chipBg: '#F5F5F5', chipFg: '#7F7F7F' },
|
||||
{ label: '未分析', value: unanalyzedTotal, icon: Inbox, fg: '#F5222D', chipBg: '#FEEDED', chipFg: '#F5222D' },
|
||||
{ label: '分析中', value: runningTotal, icon: Loader, fg: '#1677FF', chipBg: '#EAF4FF', chipFg: '#1677FF' },
|
||||
{ label: '已完成', value: completedTotal, icon: CircleCheck, fg: '#52C41A', chipBg: '#EEFCF7', chipFg: '#189D72' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={shellClass}>
|
||||
<div className="mx-auto flex min-h-full max-w-[1680px] flex-col gap-4 px-6 py-6 lg:px-8">
|
||||
<section className={`rounded-[28px] p-5 ${toolbarClass}`}>
|
||||
<section className={`rounded px-6 py-5 ${toolbarClass}`}>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(108,161,255,0.24)] bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'border-[#FFD6D6] bg-[#FFF1F0] text-[#CF1322]'}`}>
|
||||
{category}
|
||||
</span>
|
||||
<ArrowRight className={`h-3.5 w-3.5 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
|
||||
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'bg-[#FFF6E8] text-[#D46B08]'}`}>
|
||||
<ArrowRight className={`h-3 w-3 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
|
||||
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(103,232,165,0.2)] bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'border-[#FFE2B8] bg-[#FFF6E8] text-[#D46B08]'}`}>
|
||||
{subCategory}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className={`text-[28px] font-semibold ${isDark ? 'text-white' : 'text-[#222222]'}`}>{title}</h1>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
|
||||
<h1 className={`text-[24px] font-semibold leading-8 ${isDark ? 'text-white' : 'text-[#222222]'}`}>{title}</h1>
|
||||
<p className={`mt-1.5 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
|
||||
面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。
|
||||
</p>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
|
||||
<div className="flex flex-col gap-3 xl:items-end">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-xl border px-4 ${inputClass}`}>
|
||||
<label className={`flex h-10 min-w-[300px] items-center gap-2 rounded-sm border px-3 ${inputClass}`}>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`} />
|
||||
<input
|
||||
value={searchQuery}
|
||||
@@ -281,6 +281,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
</label>
|
||||
<DateRangePicker
|
||||
className="sentiment-date-range"
|
||||
size="large"
|
||||
value={range}
|
||||
presets={presets}
|
||||
onChange={setRangeChange}
|
||||
@@ -292,7 +293,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}
|
||||
className={`inline-flex items-center gap-2 rounded-sm px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#F5222D] text-white hover:bg-[#CF1322]'}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建任务
|
||||
@@ -302,21 +303,30 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{metrics.map((item, index) => (
|
||||
<div key={item.label} className={`rounded-[24px] p-5 ${metricClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#8C8C8C]'}`}>{item.label}</p>
|
||||
<p className={`mt-3 text-[36px] font-semibold ${isDark ? 'text-white' : index === 0 ? 'text-[#222222]' : 'text-[#F5222D]'}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}>
|
||||
<Layers className="h-5 w-5" />
|
||||
{metrics.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.label} className={`rounded p-4 ${metricClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#7F7F7F]'}`}>{item.label}</p>
|
||||
<p
|
||||
className={`mt-2 text-[30px] font-semibold leading-none ${isDark ? 'text-white' : ''}`}
|
||||
style={isDark ? undefined : { color: item.fg }}
|
||||
>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-11 w-11 items-center justify-center rounded ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : ''}`}
|
||||
style={isDark ? undefined : { backgroundColor: item.chipBg, color: item.chipFg }}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
|
||||
@@ -375,7 +385,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
|
||||
{showAnalysisModal && selectedTask ? (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/45 px-4">
|
||||
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.98)] text-white' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_18px_36px_rgba(0,0,0,0.12)]'}`}>
|
||||
<div className={`w-full max-w-[560px] rounded border p-6 ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.98)] text-white' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_8px_32px_rgba(34,34,34,0.16)]'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">选择分析模式</h3>
|
||||
@@ -386,7 +396,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAnalysisModal(false)}
|
||||
className={`rounded-xl p-2 ${isDark ? 'hover:bg-[rgba(21,42,72,0.9)]' : 'hover:bg-[#FFF1F0]'}`}
|
||||
className={`rounded-sm p-2 ${isDark ? 'hover:bg-[rgba(21,42,72,0.9)]' : 'hover:bg-[#FFF1F0]'}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -396,7 +406,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTaskDetail}
|
||||
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(110,168,255,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
className={`flex w-full items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(110,168,255,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">查看任务详情</div>
|
||||
@@ -410,7 +420,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenConsultation}
|
||||
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(103,232,165,0.42)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
className={`flex w-full items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(103,232,165,0.42)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Users className={`mt-0.5 h-5 w-5 ${isDark ? 'text-[#89E8B8]' : 'text-[#CF1322]'}`} />
|
||||
@@ -427,7 +437,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSingleAgentAnalysis}
|
||||
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(167,139,250,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
className={`flex w-full items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(167,139,250,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Bot className={`mt-0.5 h-5 w-5 ${isDark ? 'text-[#C4B5FD]' : 'text-[#7C3AED]'}`} />
|
||||
|
||||
+100
-101
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowRight, Bot, Layers, Plus, Search, Users, X } from 'lucide-react';
|
||||
import { ArrowRight, Bot, CircleCheck, Inbox, Layers, Loader, Plus, Search, Users, X } from 'lucide-react';
|
||||
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -170,7 +170,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
setShowAnalysisModal(false);
|
||||
};
|
||||
|
||||
// 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 单智能体分析:用「配置管理」里为本业务配置的智能体,跳转到其问答页(新会话),
|
||||
// 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage
|
||||
// 自动发出并开始问答。未配置智能体则提示去配置。
|
||||
const handleSingleAgentAnalysis = async () => {
|
||||
@@ -184,7 +184,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
/* ignore → 下面按未配置处理 */
|
||||
}
|
||||
if (!agentId) {
|
||||
toast.error('该业务尚未在「业务链条管理」中配置单智能体');
|
||||
toast.error('该业务尚未在「配置管理」中配置单智能体');
|
||||
return;
|
||||
}
|
||||
const text = [
|
||||
@@ -224,115 +224,105 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
? 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100'
|
||||
: 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(245,34,45,0.08),transparent_26%),linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]';
|
||||
|
||||
const toolbarClass = isDark
|
||||
? 'border border-[rgba(110,136,176,0.24)] bg-[rgba(10,23,42,0.94)] text-slate-100 shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
|
||||
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_2px_8px_rgba(34,34,34,0.08)]';
|
||||
|
||||
const metricCardClass = isDark
|
||||
? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(13,31,55,0.98),rgba(8,19,34,0.98))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]'
|
||||
: 'border border-[#E8E8E8] bg-white shadow-[0_12px_30px_rgba(245,34,45,0.06)]';
|
||||
: 'border border-[#E8E8E8] bg-white';
|
||||
|
||||
const inputClass = isDark
|
||||
? 'border-[rgba(110,136,176,0.3)] bg-[rgba(8,23,42,0.92)] text-slate-100 placeholder:text-slate-500'
|
||||
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6] shadow-[0_4px_14px_rgba(245,34,45,0.04)]';
|
||||
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]';
|
||||
|
||||
const panelGlowClass = isDark
|
||||
? 'before:pointer-events-none before:absolute before:inset-x-8 before:top-0 before:h-px before:bg-[linear-gradient(90deg,transparent,rgba(96,165,250,0.45),transparent)]'
|
||||
: '';
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: '全部任务',
|
||||
value: unanalyzedTotal + runningTotal + completedTotal,
|
||||
icon: Layers,
|
||||
accent: 'from-[#4D8DFF] to-[#7CC9FF]',
|
||||
},
|
||||
{
|
||||
label: '未分析',
|
||||
value: unanalyzedTotal,
|
||||
icon: Bot,
|
||||
accent: 'from-[#7C9BFF] to-[#5EEAD4]',
|
||||
},
|
||||
{
|
||||
label: '分析中',
|
||||
value: runningTotal,
|
||||
icon: Layers,
|
||||
accent: 'from-[#22C55E] to-[#67E8F9]',
|
||||
},
|
||||
{
|
||||
label: '已完成',
|
||||
value: completedTotal,
|
||||
icon: Bot,
|
||||
accent: 'from-[#38BDF8] to-[#A78BFA]',
|
||||
},
|
||||
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal, icon: Layers, fg: '#222222', chipBg: '#F5F5F5', chipFg: '#7F7F7F' },
|
||||
{ label: '未分析', value: unanalyzedTotal, icon: Inbox, fg: '#F5222D', chipBg: '#FEEDED', chipFg: '#F5222D' },
|
||||
{ label: '分析中', value: runningTotal, icon: Loader, fg: '#1677FF', chipBg: '#EAF4FF', chipFg: '#1677FF' },
|
||||
{ label: '已完成', value: completedTotal, icon: CircleCheck, fg: '#52C41A', chipBg: '#EEFCF7', chipFg: '#189D72' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={shellClass}>
|
||||
<div className={`relative mx-auto flex min-h-full max-w-[1680px] flex-col gap-5 px-6 py-6 lg:px-8 ${panelGlowClass}`}>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-300/80' : 'text-slate-500'}`}>
|
||||
面向规划任务的任务分析看板,聚焦目标拆解、受众判断和策略阻力识别,支持快速检索、启动分析和查看会商结果。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs ${isDark ? 'border-[rgba(110,136,176,0.28)] bg-[rgba(9,25,46,0.72)] text-slate-300' : 'border-slate-200 bg-white text-slate-600'}`}>
|
||||
<span>{category}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 opacity-70" />
|
||||
<span>{subCategory}</span>
|
||||
<div className={`relative mx-auto flex min-h-full max-w-[1680px] flex-col gap-4 px-6 py-6 lg:px-8 ${panelGlowClass}`}>
|
||||
<section className={`rounded px-6 py-5 ${toolbarClass}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<h1 className={`text-[24px] font-semibold leading-8 ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>{title}</h1>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(108,161,255,0.24)] bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'border-[#FFD6D6] bg-[#FFF1F0] text-[#CF1322]'}`}>
|
||||
{category}
|
||||
</span>
|
||||
<ArrowRight className={`h-3 w-3 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
|
||||
<span className={`rounded-sm border px-2 py-0.5 font-medium ${isDark ? 'border-[rgba(103,232,165,0.2)] bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'border-[#FFE2B8] bg-[#FFF6E8] text-[#D46B08]'}`}>
|
||||
{subCategory}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mt-1.5 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
|
||||
面向规划任务的任务分析看板,聚焦目标拆解、受众判断和策略阻力识别,支持快速检索、启动分析和查看会商结果。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:items-end">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-2xl border px-4 ${inputClass}`}>
|
||||
<Search className="h-4 w-4 text-[#7CC9FF]" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent text-sm outline-none"
|
||||
placeholder="搜索任务名称、摘要或负责人"
|
||||
/>
|
||||
</label>
|
||||
<DateRangePicker
|
||||
className="sentiment-date-range"
|
||||
value={dateRange}
|
||||
presets={presets}
|
||||
enableTimePicker
|
||||
clearable
|
||||
popupProps={{ overlayClassName: 'sentiment-date-range-popup' }}
|
||||
onChange={setRangeChange}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<label className={`flex h-10 min-w-[240px] flex-1 items-center gap-2 rounded-sm border px-3 ${inputClass}`}>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`} />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent text-sm outline-none"
|
||||
placeholder="搜索任务名称、摘要或负责人"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<DateRangePicker
|
||||
className="sentiment-date-range"
|
||||
size="large"
|
||||
value={dateRange}
|
||||
presets={presets}
|
||||
enableTimePicker
|
||||
clearable
|
||||
popupProps={{ overlayClassName: 'sentiment-date-range-popup' }}
|
||||
onChange={setRangeChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}
|
||||
className={`inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-sm px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#F5222D] text-white hover:bg-[#CF1322]'}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{metrics.map(({ label, value, icon: Icon, accent }) => (
|
||||
<div key={label} className={`rounded-3xl p-5 ${metricCardClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>{label}</p>
|
||||
<p className={`mt-3 text-3xl font-semibold tracking-[0.03em] ${isDark ? 'text-white' : 'text-[#222222]'}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br ${accent} shadow-[inset_0_1px_0_rgba(255,255,255,0.18)]`}>
|
||||
<Icon className="h-5 w-5 text-white" />
|
||||
{metrics.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.label} className={`rounded p-4 ${metricCardClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#7F7F7F]'}`}>{item.label}</p>
|
||||
<p
|
||||
className={`mt-2 text-[30px] font-semibold leading-none ${isDark ? 'text-white' : ''}`}
|
||||
style={isDark ? undefined : { color: item.fg }}
|
||||
>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-11 w-11 items-center justify-center rounded ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : ''}`}
|
||||
style={isDark ? undefined : { backgroundColor: item.chipBg, color: item.chipFg }}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid auto-rows-auto grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
|
||||
@@ -342,7 +332,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
<KanbanColumn
|
||||
title="未分析"
|
||||
count={unanalyzedTotal}
|
||||
color="bg-[#7C9BFF]"
|
||||
color={isDark ? 'bg-[#7CC9FF]' : 'bg-[#F5222D]'}
|
||||
tasks={unanalyzedList}
|
||||
taskType="unanalyzed"
|
||||
pageIndexSearch={pageIndexChange}
|
||||
@@ -358,7 +348,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
<KanbanColumn
|
||||
title="分析中"
|
||||
count={runningTotal}
|
||||
color="bg-[#22C55E]"
|
||||
color={isDark ? 'bg-[#5EEAD4]' : 'bg-[#1677FF]'}
|
||||
tasks={runningList}
|
||||
taskType="running"
|
||||
pageIndexSearch={pageIndexChange}
|
||||
@@ -374,7 +364,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
<KanbanColumn
|
||||
title="已完成"
|
||||
count={completedTotal}
|
||||
color="bg-[#38BDF8]"
|
||||
color={isDark ? 'bg-[#67E8A5]' : 'bg-[#52C41A]'}
|
||||
tasks={completedList}
|
||||
taskType="completed"
|
||||
pageIndexSearch={pageIndexChange}
|
||||
@@ -387,19 +377,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
</div>
|
||||
|
||||
{showAnalysisModal && selectedTask ? (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-[rgba(3,10,22,0.62)] px-4 backdrop-blur-sm">
|
||||
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(110,136,176,0.26)] bg-[linear-gradient(180deg,rgba(10,29,54,0.98),rgba(8,20,37,0.98))] text-slate-100 shadow-[0_28px_80px_rgba(2,8,20,0.42)]' : 'border-slate-200 bg-white text-slate-900 shadow-xl'}`}>
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/45 px-4">
|
||||
<div className={`w-full max-w-[560px] rounded border p-6 ${isDark ? 'border-[rgba(110,136,176,0.26)] bg-[linear-gradient(180deg,rgba(10,29,54,0.98),rgba(8,20,37,0.98))] text-slate-100 shadow-[0_28px_80px_rgba(2,8,20,0.42)]' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_8px_32px_rgba(34,34,34,0.16)]'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">选择分析模式</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
|
||||
当前任务:{selectedTask.taskName}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAnalysisModal(false)}
|
||||
className={`rounded-xl p-2 transition ${isDark ? 'hover:bg-[rgba(15,41,74,0.88)]' : 'hover:bg-slate-100'}`}
|
||||
className={`rounded-sm p-2 transition ${isDark ? 'hover:bg-[rgba(15,41,74,0.88)]' : 'hover:bg-[#FFF1F0]'}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -409,15 +399,18 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTaskDetail}
|
||||
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(96,165,250,0.48)] hover:bg-[rgba(12,38,69,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-sky-200 hover:bg-sky-50'}`}
|
||||
className={`group flex items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(96,165,250,0.48)] hover:bg-[rgba(12,38,69,0.96)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#1677FF,#0EA5E9)]">
|
||||
<Layers className="h-5 w-5 text-white" />
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-sm ${isDark ? 'bg-[linear-gradient(180deg,#1677FF,#0EA5E9)] text-white' : ''}`}
|
||||
style={isDark ? undefined : { backgroundColor: '#EAF4FF', color: '#0079FE' }}
|
||||
>
|
||||
<Layers className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">查看任务详情</div>
|
||||
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>
|
||||
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-[#575757]'}`}>
|
||||
进入 B1 系统查看任务详情。
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,15 +421,18 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenConsultation}
|
||||
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(45,212,191,0.48)] hover:bg-[rgba(10,42,58,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-emerald-200 hover:bg-emerald-50'}`}
|
||||
className={`group flex items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(45,212,191,0.48)] hover:bg-[rgba(10,42,58,0.96)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#14B8A6,#22C55E)]">
|
||||
<Users className="h-5 w-5 text-white" />
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-sm ${isDark ? 'bg-[linear-gradient(180deg,#14B8A6,#22C55E)] text-white' : ''}`}
|
||||
style={isDark ? undefined : { backgroundColor: '#FEEDED', color: '#CF1322' }}
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">多智能体会商</div>
|
||||
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>
|
||||
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-[#575757]'}`}>
|
||||
多个 AI 智能体从不同视角开展讨论并形成共识。
|
||||
</div>
|
||||
</div>
|
||||
@@ -447,15 +443,18 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSingleAgentAnalysis}
|
||||
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(167,139,250,0.48)] hover:bg-[rgba(28,24,58,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-violet-200 hover:bg-violet-50'}`}
|
||||
className={`group flex items-center justify-between rounded border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(167,139,250,0.48)] hover:bg-[rgba(28,24,58,0.96)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#7C3AED,#A78BFA)]">
|
||||
<Bot className="h-5 w-5 text-white" />
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-sm ${isDark ? 'bg-[linear-gradient(180deg,#7C3AED,#A78BFA)] text-white' : ''}`}
|
||||
style={isDark ? undefined : { backgroundColor: '#F3EDFF', color: '#7C3AED' }}
|
||||
>
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">单智能体分析</div>
|
||||
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>
|
||||
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-[#575757]'}`}>
|
||||
由单个 AI 智能体直接对该任务进行分析问答。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,7 +118,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
|
||||
const panelClass = isDark
|
||||
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(19,39,72,0.9)] shadow-[0_14px_34px_rgba(3,10,22,0.2)] hover:border-[rgba(110,168,255,0.52)]'
|
||||
: 'border border-[#E8E8E8] bg-white shadow-[0_10px_24px_rgba(245,34,45,0.06)] hover:border-[#F6B0B4] hover:shadow-[0_16px_32px_rgba(245,34,45,0.12)]';
|
||||
: 'border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] hover:border-[#F6B0B4] hover:shadow-[0_4px_16px_rgba(34,34,34,0.12)]';
|
||||
|
||||
const typeBadgeClass = isDark
|
||||
? 'bg-[rgba(77,141,255,0.14)] text-[#A9D2FF] border border-[rgba(108,161,255,0.24)]'
|
||||
@@ -151,25 +151,25 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-[24px] p-4 transition-all duration-200 ${panelClass}`}
|
||||
className={`group rounded p-4 transition-all duration-200 ${panelClass}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${typeBadgeClass}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs font-medium ${typeBadgeClass}`}
|
||||
>
|
||||
<TypeIcon className="h-3.5 w-3.5" />
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${statusClass}`}
|
||||
className={`inline-flex items-center rounded-sm px-2 py-1 text-xs font-medium ${statusClass}`}
|
||||
>
|
||||
{getColumnLabel(task.column)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={`mt-5 text-[16px] font-semibold leading-7 ${
|
||||
className={`mt-4 text-[16px] font-semibold leading-6 ${
|
||||
isDark ? 'text-white' : 'text-[#222222]'
|
||||
}`}
|
||||
>
|
||||
@@ -177,9 +177,10 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className={`mt-3 min-h-[56px] text-sm leading-7 ${
|
||||
className={`mt-3 line-clamp-3 min-h-[63px] text-sm leading-[21px] ${
|
||||
isDark ? 'text-slate-300' : 'text-[#575757]'
|
||||
}`}
|
||||
title={task.overview || undefined}
|
||||
>
|
||||
{task.overview || '暂无任务摘要'}
|
||||
</p>
|
||||
@@ -204,9 +205,9 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleActionClick}
|
||||
className={`mt-5 flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-medium transition ${actionClass}`}
|
||||
className={`mt-4 flex h-9 w-full items-center justify-center gap-1.5 rounded-sm text-sm font-medium transition ${actionClass}`}
|
||||
>
|
||||
{isCompleted ? <Eye className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isCompleted ? <Eye className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{isCompleted ? '查看结果' : '开始分析'}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { TaskListItem } from './TaskManagement';
|
||||
|
||||
/**
|
||||
* 任务看板假数据 —— 给「任务分析」「三情分析」两个页面提供演示数据,
|
||||
* 用于没有后端 `/task/cop-tasks` / `/three/cop-three-tasks` 接口时本地预览。
|
||||
*
|
||||
* 是否启用由环境变量 `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';
|
||||
export type MockDataset = 'task' | 'sentiment';
|
||||
|
||||
interface MockTaskSeed {
|
||||
taskName: string;
|
||||
overview: string;
|
||||
taskDirection: string;
|
||||
taskLevel: number; // 1 一般 / 2 紧急 / 3 加急
|
||||
taskType: number; // 1 单任务 / 2 场景 / 3 会商 / 4 多智能体 / 5 检索
|
||||
}
|
||||
|
||||
/** 列 → 状态码,与组件内 taskStatusMap 对齐。 */
|
||||
const COLUMN_STATUS: Record<MockColumnType, number> = {
|
||||
unanalyzed: 0,
|
||||
running: 20,
|
||||
completed: 30,
|
||||
};
|
||||
|
||||
/** 生成一条完整的 TaskListItem,仅覆盖有意义的字段,其余给安全默认值。 */
|
||||
function makeTask(
|
||||
id: number,
|
||||
column: MockColumnType,
|
||||
seed: MockTaskSeed,
|
||||
createTime: string,
|
||||
updateTime: string,
|
||||
): TaskListItem {
|
||||
return {
|
||||
createBy: 1001,
|
||||
createTime,
|
||||
deptId: null,
|
||||
id,
|
||||
isTop: 0,
|
||||
issuingDept: '战略规划处',
|
||||
keywords: null,
|
||||
overview: seed.overview,
|
||||
planTaskTop: 0,
|
||||
platform: null,
|
||||
ptaskId: null,
|
||||
recommendedBasis: null,
|
||||
scene: '',
|
||||
sourceType: 1,
|
||||
step: null,
|
||||
taskContent: seed.overview,
|
||||
taskDirection: seed.taskDirection,
|
||||
taskEndDate: '2026-06-30 18:00:00',
|
||||
taskLevel: seed.taskLevel,
|
||||
taskName: seed.taskName,
|
||||
taskNo: `T-${String(id).padStart(6, '0')}`,
|
||||
taskStartDate: '2026-06-01 09:00:00',
|
||||
taskStatus: COLUMN_STATUS[column],
|
||||
taskSuperiorFlag: 0,
|
||||
taskType: seed.taskType,
|
||||
column,
|
||||
updateBy: 1001,
|
||||
updateTime,
|
||||
endCount: column === 'completed' ? 1 : 0,
|
||||
sumAll: 1,
|
||||
progress: column === 'completed' ? 100 : column === 'running' ? 60 : 0,
|
||||
taskStatusData: null,
|
||||
} as TaskListItem;
|
||||
}
|
||||
|
||||
/** 「任务分析」页(业务码 6BF)数据种子。 */
|
||||
const TASK_SEEDS: Record<MockColumnType, MockTaskSeed[]> = {
|
||||
unanalyzed: [
|
||||
{
|
||||
taskName: '夏季文旅消费季传播任务分析',
|
||||
overview:
|
||||
'围绕"夏季文旅消费季"主题,梳理传播目标、核心受众与潜在策略阻力,输出可执行的任务拆解建议。',
|
||||
taskDirection: '王磊',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '新能源补贴政策解读专题',
|
||||
overview:
|
||||
'针对新一轮新能源汽车补贴政策,分析公众关注焦点与误读风险点,形成政策解读传播要点清单。',
|
||||
taskDirection: '李婷',
|
||||
taskLevel: 1,
|
||||
taskType: 5,
|
||||
},
|
||||
{
|
||||
taskName: '城市更新片区舆论引导任务',
|
||||
overview:
|
||||
'对老城区改造涉及的拆迁、安置等敏感议题进行任务目标拆解,识别需要重点回应的群体诉求。',
|
||||
taskDirection: '张伟',
|
||||
taskLevel: 3,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '高校毕业季就业服务宣传',
|
||||
overview:
|
||||
'梳理面向应届毕业生的就业服务政策传播任务,明确分阶段触达节奏与渠道组合。',
|
||||
taskDirection: '赵敏',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '防汛应急科普传播任务',
|
||||
overview:
|
||||
'汛期来临前,分析防汛减灾科普内容的传播目标与受众分层,制定预警信息发布预案。',
|
||||
taskDirection: '陈昊',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '乡村振兴成果展示任务',
|
||||
overview:
|
||||
'围绕本地乡村振兴典型案例,拆解成果展示传播任务,确定故事化表达与素材采集方向。',
|
||||
taskDirection: '周琳',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
],
|
||||
running: [
|
||||
{
|
||||
taskName: '社区养老服务满意度专题分析',
|
||||
overview:
|
||||
'正在对社区居家养老服务的群众反馈进行任务分析,重点判断受众诉求与服务短板。',
|
||||
taskDirection: '孙强',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '中小企业纾困政策落地评估',
|
||||
overview:
|
||||
'多智能体协同分析纾困政策落地过程中的执行阻力与企业关切,形成阶段性研判。',
|
||||
taskDirection: '吴静',
|
||||
taskLevel: 3,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '城市轨道交通延误舆情研判',
|
||||
overview:
|
||||
'针对近期地铁延误事件,正在拆解舆论传播路径与关键节点,评估引导策略有效性。',
|
||||
taskDirection: '郑凯',
|
||||
taskLevel: 2,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '食品安全抽检结果发布任务',
|
||||
overview:
|
||||
'正在分析食品安全抽检通报的发布口径与受众反应,优化信息透明度与安抚策略。',
|
||||
taskDirection: '冯雪',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
taskName: '春节文化活动传播复盘',
|
||||
overview:
|
||||
'已完成春节系列文化活动的传播任务分析,沉淀受众画像与高互动内容规律。',
|
||||
taskDirection: '黄涛',
|
||||
taskLevel: 1,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '医保改革政策宣传任务',
|
||||
overview:
|
||||
'已完成医保改革政策的传播目标拆解与受众分层,输出分群体沟通话术。',
|
||||
taskDirection: '宋佳',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '城市营商环境优化宣传',
|
||||
overview:
|
||||
'已完成营商环境优化举措的任务分析,明确企业端与公众端的差异化传播重点。',
|
||||
taskDirection: '高鹏',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '老旧小区加装电梯议题分析',
|
||||
overview:
|
||||
'已完成加装电梯涉及的邻里诉求与阻力识别,形成多方协商沟通建议。',
|
||||
taskDirection: '何丽',
|
||||
taskLevel: 2,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '生态环保主题宣传周复盘',
|
||||
overview:
|
||||
'已完成环保宣传周的传播效果分析,总结渠道贡献度与可复用素材。',
|
||||
taskDirection: '罗勇',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '青年人才引进政策解读',
|
||||
overview:
|
||||
'已完成人才引进政策的受众判断与策略阻力分析,输出落地传播节奏表。',
|
||||
taskDirection: '谭芳',
|
||||
taskLevel: 1,
|
||||
taskType: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 「三情分析」页(业务码 3Q)数据种子。 */
|
||||
const SENTIMENT_SEEDS: Record<MockColumnType, MockTaskSeed[]> = {
|
||||
unanalyzed: [
|
||||
{
|
||||
taskName: '暑期旅游热点情绪监测',
|
||||
overview:
|
||||
'对暑期热门景区相关讨论进行情绪与倾向分析,识别游客吐槽集中点与正向口碑话题。',
|
||||
taskDirection: '王磊',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '电价调整公众情绪研判',
|
||||
overview:
|
||||
'围绕居民阶梯电价调整,分析公众情绪走向与主要担忧,判断需要重点疏导的群体。',
|
||||
taskDirection: '李婷',
|
||||
taskLevel: 3,
|
||||
taskType: 5,
|
||||
},
|
||||
{
|
||||
taskName: '校园周边交通安全舆情',
|
||||
overview:
|
||||
'对校园周边交通安全话题进行三情分析,梳理家长群体情绪与诉求强度。',
|
||||
taskDirection: '张伟',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '景区门票预约制讨论分析',
|
||||
overview:
|
||||
'分析门票预约制相关讨论的情绪分布,识别便利性与公平性两类核心关切。',
|
||||
taskDirection: '赵敏',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '夜间经济噪音投诉情绪',
|
||||
overview:
|
||||
'对夜市、夜间演出引发的噪音投诉进行情绪分析,区分居民与商户的不同立场。',
|
||||
taskDirection: '陈昊',
|
||||
taskLevel: 1,
|
||||
taskType: 1,
|
||||
},
|
||||
],
|
||||
running: [
|
||||
{
|
||||
taskName: '台风预警信息传播情绪追踪',
|
||||
overview:
|
||||
'正在分析台风预警发布后的公众情绪变化,评估恐慌情绪与配合度的动态走势。',
|
||||
taskDirection: '孙强',
|
||||
taskLevel: 3,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '学区划分调整舆论情绪',
|
||||
overview:
|
||||
'多智能体协同追踪学区调整话题的情绪热度与对立观点,研判潜在升级风险。',
|
||||
taskDirection: '吴静',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '网络热点事件情绪态势',
|
||||
overview:
|
||||
'正在对近期网络热点事件进行三情分析,识别情绪峰值节点与关键意见领袖。',
|
||||
taskDirection: '郑凯',
|
||||
taskLevel: 2,
|
||||
taskType: 3,
|
||||
},
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
taskName: '春运出行体验情绪复盘',
|
||||
overview:
|
||||
'已完成春运期间出行体验的情绪与倾向分析,沉淀满意度变化与高频抱怨主题。',
|
||||
taskDirection: '黄涛',
|
||||
taskLevel: 1,
|
||||
taskType: 3,
|
||||
},
|
||||
{
|
||||
taskName: '惠民政策落地情绪评估',
|
||||
overview:
|
||||
'已完成惠民政策落地后的公众情绪评估,输出正向反馈与改进诉求清单。',
|
||||
taskDirection: '宋佳',
|
||||
taskLevel: 2,
|
||||
taskType: 4,
|
||||
},
|
||||
{
|
||||
taskName: '城市内涝应对情绪分析',
|
||||
overview:
|
||||
'已完成强降雨内涝事件的三情分析,总结情绪演化曲线与有效安抚措施。',
|
||||
taskDirection: '高鹏',
|
||||
taskLevel: 2,
|
||||
taskType: 1,
|
||||
},
|
||||
{
|
||||
taskName: '景区限流措施满意度分析',
|
||||
overview:
|
||||
'已完成节假日景区限流措施的情绪分析,明确游客接受度与体验改进方向。',
|
||||
taskDirection: '何丽',
|
||||
taskLevel: 1,
|
||||
taskType: 2,
|
||||
},
|
||||
{
|
||||
taskName: '公共交通票价听证情绪',
|
||||
overview:
|
||||
'已完成票价听证会相关讨论的情绪与立场分析,输出分群体沟通建议。',
|
||||
taskDirection: '罗勇',
|
||||
taskLevel: 1,
|
||||
taskType: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SEEDS: Record<MockDataset, Record<MockColumnType, MockTaskSeed[]>> = {
|
||||
task: TASK_SEEDS,
|
||||
sentiment: SENTIMENT_SEEDS,
|
||||
};
|
||||
|
||||
/** 给同一列里的卡片生成错落有致的时间,便于「按时间倒序」展示。 */
|
||||
function buildTimes(index: number): { createTime: string; updateTime: string } {
|
||||
const day = String(28 - index).padStart(2, '0');
|
||||
const hour = String(9 + (index % 8)).padStart(2, '0');
|
||||
return {
|
||||
createTime: `2026-05-${day} ${hour}:15:00`,
|
||||
updateTime: `2026-06-0${(index % 9) + 1} ${hour}:40:00`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据集 → 列 → 完整任务列表(按 id 区分,避免两页 key 冲突)。 */
|
||||
function buildPool(dataset: MockDataset, column: MockColumnType): TaskListItem[] {
|
||||
const base = dataset === 'task' ? 10000 : 20000;
|
||||
const columnOffset = column === 'unanalyzed' ? 0 : column === 'running' ? 300 : 600;
|
||||
return SEEDS[dataset][column].map((seed, index) => {
|
||||
const { createTime, updateTime } = buildTimes(index);
|
||||
return makeTask(base + columnOffset + index, column, seed, createTime, updateTime);
|
||||
});
|
||||
}
|
||||
|
||||
interface MockCopTasksPayload {
|
||||
content?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
taskStatus?: number[];
|
||||
}
|
||||
|
||||
/** 根据请求里的 taskStatus 数组反推当前查询的是哪一列。 */
|
||||
function resolveColumn(taskStatus?: number[]): MockColumnType {
|
||||
const list = Array.isArray(taskStatus) ? taskStatus : [];
|
||||
if (list.includes(20)) return 'running';
|
||||
if (list.includes(0) || list.includes(10)) return 'unanalyzed';
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 `/task/cop-tasks` / `/three/cop-three-tasks` 接口的 body:
|
||||
* 按列返回分页后的假数据,支持按 `content` 对任务名/摘要做模糊过滤。
|
||||
*/
|
||||
function buildCopTasksBody(dataset: MockDataset, payload: MockCopTasksPayload) {
|
||||
const { content = '', pageNum = 1, pageSize = 10, taskStatus } = payload;
|
||||
const column = resolveColumn(taskStatus);
|
||||
|
||||
let pool = buildPool(dataset, column);
|
||||
const keyword = content.trim();
|
||||
if (keyword) {
|
||||
pool = pool.filter(
|
||||
(item) =>
|
||||
item.taskName.includes(keyword) || item.overview.includes(keyword),
|
||||
);
|
||||
}
|
||||
|
||||
const total = pool.length;
|
||||
const start = (pageNum - 1) * pageSize;
|
||||
const records = pool.slice(start, start + pageSize);
|
||||
|
||||
return { code: 0, message: 'ok', data: { records, total } };
|
||||
}
|
||||
|
||||
/** 命中本 mock 的接口路径 → 对应数据集。 */
|
||||
const MOCK_ROUTES: Array<{ match: string; dataset: MockDataset }> = [
|
||||
{ match: '/three/cop-three-tasks', dataset: 'sentiment' },
|
||||
{ match: '/task/cop-tasks', dataset: 'task' },
|
||||
];
|
||||
|
||||
/** 判断某个请求 url 是否应走假数据。 */
|
||||
export function matchTaskBoardMock(url?: string): MockDataset | null {
|
||||
if (!url) return null;
|
||||
const hit = MOCK_ROUTES.find((route) => url.includes(route.match));
|
||||
return hit ? hit.dataset : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时假数据 axios adapter:命中任务看板接口时直接返回 mock body,不发真实请求。
|
||||
* 仅在 `VITE_USE_REAL_DATA !== 'true'` 时由 request.ts 挂上(见那里的请求拦截器)。
|
||||
*/
|
||||
export function taskBoardMockAdapter(config: {
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
}): Promise<{ data: unknown; status: number; statusText: string; headers: Record<string, unknown>; config: unknown }> {
|
||||
const dataset = matchTaskBoardMock(config.url) ?? 'task';
|
||||
|
||||
let payload: MockCopTasksPayload = {};
|
||||
try {
|
||||
payload =
|
||||
typeof config.data === 'string'
|
||||
? JSON.parse(config.data)
|
||||
: ((config.data as MockCopTasksPayload) ?? {});
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const body = buildCopTasksBody(dataset, payload);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 模拟网络延迟,让 Loading 态可见。
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: body,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
}, 280);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import axios from 'axios'
|
||||
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
|
||||
@@ -31,6 +39,10 @@ service.interceptors.response.use(
|
||||
)
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// 假数据模式:任务看板两个接口直接走本地 mock,不发真实请求(VITE_USE_REAL_DATA=true 时跳过)。
|
||||
if (!USE_REAL_DATA && matchTaskBoardMock(config.url)) {
|
||||
config.adapter = taskBoardMockAdapter as any;
|
||||
}
|
||||
// let token: any = localStorage.getItem('token');
|
||||
config.headers['Authorization'] = `Bearer admin`;
|
||||
// if (token) {
|
||||
|
||||
@@ -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,24 +19,30 @@
|
||||
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
|
||||
AIMessage.tool_calls 解析派活列表。
|
||||
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
|
||||
(``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 /
|
||||
展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是
|
||||
「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与
|
||||
``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。
|
||||
(``write_file`` / ``str_replace`` 产出 md 等成品文档)+ **可跑 ``bash``**
|
||||
(2026-06 放开:业务需要跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
|
||||
展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
|
||||
所以 Step3 报告也跟着拿到 bash;只有 ``leader`` 和 ``seat_parallel`` 仍各自显式禁。
|
||||
历史警示(保留):弱模型用 ``bash`` 跑 ``python xxx.py > out.md`` 生成产物会
|
||||
**绕过前端虚拟预览**(预览只拦截 ``write_file.args.content``)→ 沙箱里有文件但
|
||||
Step2 右侧预览空白。席位 SOUL 必须明确「bash 只用于辅助查询拿数据,**最终交付
|
||||
必须用 write_file 写 .md 落盘**,不要让脚本生成最终产物」。
|
||||
- ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件**
|
||||
(``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类,
|
||||
(``write_file`` / ``str_replace``,产物走正文)+ **禁 ``bash``** + 禁「打断 / 越权」类,
|
||||
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
|
||||
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
|
||||
并行席位同时跑 bash 会在共享 sandbox 里抢路径 / 占进程数;且 fast 模式整体不
|
||||
写文件,bash 跑出来的产物既不能预览也没意义。
|
||||
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
|
||||
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
|
||||
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
|
||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||
``seat_parallel`` **放开搜索**(本期诉求)。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁
|
||||
``str_replace``**:
|
||||
* ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在
|
||||
outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本
|
||||
产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
|
||||
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
|
||||
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
|
||||
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
|
||||
``write_file``,正是「没写完就去生成 py 文件」的现象)。
|
||||
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
|
||||
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
|
||||
而清空沙箱)。
|
||||
@@ -72,16 +78,21 @@ _LEADER_EXCLUDED: list[str] = [
|
||||
"hindsight_retain",
|
||||
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
|
||||
]
|
||||
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** ——
|
||||
# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又
|
||||
# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。
|
||||
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,**bash 放开**
|
||||
# (2026-06 决策,业务需要 bash 跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
|
||||
# 展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
|
||||
# 所以 Step3 报告跟着拿到 bash;只有 leader / seat_parallel 仍各自显式禁。
|
||||
#
|
||||
# **历史警示(保留)**:弱模型用 bash 跑 ``python xxx.py > out.md`` 生成最终产物会
|
||||
# 绕过前端虚拟预览(只拦 write_file.args.content) → 沙箱里有文件但 Step2 右侧预览
|
||||
# 空白。必须靠席位 SOUL 强约束「bash 只用于辅助查询拿数据,最终交付必须用 write_file
|
||||
# 写 .md 落盘」。
|
||||
_SEAT_EXCLUDED: list[str] = [
|
||||
"web_search",
|
||||
"ask_clarification",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
]
|
||||
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
|
||||
# **禁 web_search** —— 离线内网部署无外网,席位检索一律走「技能(skill)」(靠 read_file 读
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -328,9 +328,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
|
||||
# 自己的本地模型即可工作。
|
||||
model_name = str(payload.get("model") or "")
|
||||
# 用户在 Step 1 上传的参考文件(已经过 /api/threads/{thread_id}/uploads)。
|
||||
# 前端传 [{filename, size, path, status}, ...],这里原样透传给 _run_payload →
|
||||
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
|
||||
raw_files = payload.get("files")
|
||||
files = raw_files if isinstance(raw_files, list) else []
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||
if not message:
|
||||
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
|
||||
if not message and not files:
|
||||
raise HTTPException(status_code=400, detail="message is required")
|
||||
|
||||
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
|
||||
@@ -381,8 +387,17 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
_MAX_CLARIFICATION_ROUNDS,
|
||||
)
|
||||
|
||||
# 带了上传文件时,解锁「读」类工具(read_file / ls / grep / view_image),
|
||||
# 否则 UploadsMiddleware 注入了文件清单、模型却没工具读取。仍保留对
|
||||
# write_file / str_replace / present_files / bash / agent_orchestration 的封锁,
|
||||
# 防止意图阶段越界做方案/派活。
|
||||
if files:
|
||||
read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||
base_excluded_tools = [t for t in base_excluded_tools if t not in read_tools]
|
||||
|
||||
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||
effective_message = message
|
||||
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
|
||||
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
effective_message = (
|
||||
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
||||
@@ -409,6 +424,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# not reliable on its own, hard exclusion is.
|
||||
excluded_tools=base_excluded_tools,
|
||||
skill_stop_names=[],
|
||||
files=files,
|
||||
)
|
||||
|
||||
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
||||
|
||||
@@ -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)
|
||||
@@ -638,6 +638,7 @@ def _run_payload(
|
||||
reasoning_effort: str = "medium",
|
||||
multitask_strategy: str = "reject",
|
||||
extra_context: dict[str, Any] | None = None,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
@@ -650,13 +651,19 @@ def _run_payload(
|
||||
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
|
||||
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
|
||||
"""
|
||||
# 用户上传的文件元数据透传给 UploadsMiddleware(与普通聊天同链路):
|
||||
# before_agent 钩子读 additional_kwargs.files,生成 <uploaded_files> 清单注入消息,
|
||||
# 之后席位/总控用内置 read_file / grep / view_image 工具按需读取。空时落空 dict,行为不变。
|
||||
additional_kwargs: dict[str, Any] = {}
|
||||
if files:
|
||||
additional_kwargs["files"] = files
|
||||
payload: dict[str, Any] = {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": [{"type": "text", "text": new_message}],
|
||||
"additional_kwargs": {},
|
||||
"additional_kwargs": additional_kwargs,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1258,6 +1265,7 @@ async def _leader_run(
|
||||
seat_meta: dict[str, dict[str, Any]] | None = None,
|
||||
synthesis_mode: bool = False,
|
||||
suppress_pre_broadcast: bool = False,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||
|
||||
@@ -1346,18 +1354,26 @@ async def _leader_run(
|
||||
# 调用方可再叠加自己的 skill_stop。后台进程内网关用同一份 policy,保证传参一致。
|
||||
policy = roundtable_run_policy("leader")
|
||||
effective_stop_names = merge_skill_stop_names(policy.skill_stop_names, skill_stop_names)
|
||||
# 带了上传文件时,给总控解锁「读」类工具(leader policy 默认禁 read_file/ls/view_image),
|
||||
# 否则文件清单注入了却无工具读取。写/派活类仍按 policy 封锁,不放开。
|
||||
leader_excluded = policy.excluded_tools
|
||||
if files:
|
||||
_read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||
leader_excluded = [t for t in leader_excluded if t not in _read_tools]
|
||||
body = _run_payload(
|
||||
agent_name=agent_name,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
|
||||
new_message=leader_input,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
excluded_tools=leader_excluded,
|
||||
skill_stop_names=effective_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
|
||||
multitask_strategy="rollback",
|
||||
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
|
||||
files=files,
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
@@ -1532,6 +1548,7 @@ async def _special_run(
|
||||
display_name: str = "",
|
||||
no_file: bool = False,
|
||||
peer_deliveries: list[dict[str, Any]] | None = None,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
@@ -1560,12 +1577,18 @@ async def _special_run(
|
||||
else:
|
||||
role = "seat"
|
||||
policy = roundtable_run_policy(role)
|
||||
# 带了上传文件时,给席位解锁「读」类工具(view_image 默认被禁;read_file 本就可用),
|
||||
# 让席位能读取/识别用户上传的文件。写/派活类仍按 policy 封锁。
|
||||
seat_excluded = policy.excluded_tools
|
||||
if files:
|
||||
_read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||
seat_excluded = [t for t in seat_excluded if t not in _read_tools]
|
||||
body = _run_payload(
|
||||
agent_name=agent_name,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=new_message,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
excluded_tools=seat_excluded,
|
||||
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
@@ -1575,6 +1598,8 @@ async def _special_run(
|
||||
extra_context=(
|
||||
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
|
||||
),
|
||||
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
|
||||
files=files,
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
@@ -1677,6 +1702,11 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
display_name: str = str(payload.get("display_name") or "").strip()
|
||||
new_message: str = str(payload.get("new_message") or "")
|
||||
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
|
||||
# 用户随干预消息上传的参考文件(已经过 /api/threads/{thread_id}/uploads)。
|
||||
# 前端传 [{filename, size, path, status}, ...],透传给 _run_payload →
|
||||
# additional_kwargs.files → UploadsMiddleware 注入清单 + 解锁 read_file 等工具。
|
||||
_files_raw = payload.get("files")
|
||||
files: list[dict[str, Any]] = _files_raw if isinstance(_files_raw, list) else []
|
||||
# 综合轮标记(仅 leader 用):前端在「派活后让总控判断继续/综合」的轮次置真,网关据此
|
||||
# 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮
|
||||
# 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。
|
||||
@@ -1741,6 +1771,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
seat_meta=seat_meta,
|
||||
synthesis_mode=synthesis_mode,
|
||||
suppress_pre_broadcast=suppress_pre_broadcast,
|
||||
files=files,
|
||||
)
|
||||
else:
|
||||
gen = _special_run(
|
||||
@@ -1748,6 +1779,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
display_name=display_name,
|
||||
no_file=no_file,
|
||||
peer_deliveries=peer_deliveries,
|
||||
files=files,
|
||||
)
|
||||
async for chunk in gen:
|
||||
if isinstance(chunk, dict):
|
||||
|
||||
@@ -70,6 +70,14 @@ class TaskUpdateRequest(BaseModel):
|
||||
messages: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class TaskEnsureRequest(BaseModel):
|
||||
"""get-or-create:宿主传的 taskId 不固定,新 id 直接**初始化**一条空记录返回(不 404)。"""
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
title: str = Field(default="", max_length=512)
|
||||
brief: str | None = None
|
||||
agents: list[TaskAgentSchema] = []
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -115,6 +123,30 @@ async def create_task(request: Request, body: TaskCreateRequest) -> TaskResponse
|
||||
return TaskResponse(**row)
|
||||
|
||||
|
||||
@router.post("/ensure", response_model=TaskResponse)
|
||||
async def ensure_task(request: Request, body: TaskEnsureRequest) -> TaskResponse:
|
||||
"""存在则返回(含 messages 供回显);不存在则用该 id **初始化**一条空记录返回。
|
||||
|
||||
宿主传的 taskId 不固定:新 id 不再 404,而是即时建好、可直接回显/保存。幂等。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
existing = await store.get_task(body.id, user_id)
|
||||
if existing is not None:
|
||||
return TaskResponse(**existing)
|
||||
row = await store.create_task(
|
||||
user_id,
|
||||
{
|
||||
"id": body.id,
|
||||
"title": body.title.strip(),
|
||||
"brief": body.brief,
|
||||
"agents": [a.model_dump() for a in body.agents],
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
return TaskResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(request: Request, task_id: str) -> TaskResponse:
|
||||
store = _get_store(request)
|
||||
|
||||
@@ -276,6 +276,13 @@ def _build_task_message(task_name: str, brief: str) -> str:
|
||||
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
|
||||
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
|
||||
)
|
||||
# 澄清护栏:要向用户确认时,必须用 ask_clarification 工具发起(前端据此渲染可点击的协作
|
||||
# 卡片),不能只把问题写在正文里 —— 否则用户看不到卡片、无法回应。能合理假设就先假设并标注。
|
||||
parts.append(
|
||||
"【需要向用户确认时】如果确实缺少完成分析所必需的关键信息,请**调用 `ask_clarification` 工具**"
|
||||
"发起确认(这样用户会看到一张可点击的协作卡片来回答);**不要只把问题写在正文里**,否则用户无法回应。"
|
||||
"能基于已有信息合理假设的,就先假设并在正文中标注你的假设;只有当不同假设会显著改变结论时,才发起确认。"
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user