Browse Source

feat: 侧栏接到任务集/会话

master
Senor-Liu 1 month ago
parent
commit
1662d22f2a
  1. 19
      web/client/src/sections/superAgent/actions/agentResources.js
  2. 25
      web/client/src/sections/superAgent/actions/api.js
  3. 12
      web/client/src/sections/superAgent/components/SidebarFolderRow.jsx
  4. 10
      web/client/src/sections/superAgent/components/SuperAgentSidebar.jsx
  5. 4
      web/client/src/sections/superAgent/constants.js
  6. 9
      web/client/src/sections/superAgent/container/index.jsx
  7. 70
      web/client/src/sections/superAgent/hooks/useSuperAgentChat.js
  8. 62
      web/client/src/sections/superAgent/hooks/useSuperAgentSidebar.js
  9. 94
      web/client/src/sections/superAgent/utils/session.js
  10. 109
      web/tests/superAgentSidebarContract.test.mjs

19
web/client/src/sections/superAgent/actions/agentResources.js

@ -18,6 +18,21 @@ import { parseAgentPage } from "../utils/agentApiParse";
const jsonAction = (options) => requestAgentJson(options); const jsonAction = (options) => requestAgentJson(options);
const listAllPages = async (listPage, extraQuery = {}) => {
const items = [];
let cursor;
do {
const page = await listPage({
...extraQuery,
limit: extraQuery.limit || 100,
...(cursor ? { cursor } : {}),
});
items.push(...(page.items || []));
cursor = page.nextCursor || "";
} while (cursor);
return items;
};
export const listTasksets = (query = {}) => export const listTasksets = (query = {}) =>
jsonAction({ jsonAction({
method: "GET", method: "GET",
@ -52,6 +67,8 @@ export const deleteTaskset = (tasksetId) =>
fallbackMessage: "删除任务集失败", fallbackMessage: "删除任务集失败",
}); });
export const listAllTasksets = (query = {}) => listAllPages(listTasksets, query);
export const listConversations = (query = {}) => export const listConversations = (query = {}) =>
jsonAction({ jsonAction({
method: "GET", method: "GET",
@ -60,6 +77,8 @@ export const listConversations = (query = {}) =>
fallbackMessage: "获取会话失败", fallbackMessage: "获取会话失败",
}).then(parseAgentPage); }).then(parseAgentPage);
export const listAllConversations = (query = {}) => listAllPages(listConversations, query);
export const createConversation = (body, options = {}) => export const createConversation = (body, options = {}) =>
jsonAction({ jsonAction({
method: "POST", method: "POST",

25
web/client/src/sections/superAgent/actions/api.js

@ -112,13 +112,13 @@ export const getSuperAgentSidebar = createDirectJsonAction({
export const createSuperAgentFolder = createDirectJsonAction({ export const createSuperAgentFolder = createDirectJsonAction({
method: "POST", method: "POST",
path: superAgentServiceApiTable.tasksets, path: superAgentServiceApiTable.tasksets,
fallbackMessage: "创建文件夹失败", fallbackMessage: "创建任务集失败",
}); });
export const deleteSuperAgentFolder = createDirectJsonAction({ export const deleteSuperAgentFolder = createDirectJsonAction({
method: "DELETE", method: "DELETE",
path: superAgentServiceApiTable.tasksetDetail, path: superAgentServiceApiTable.tasksetDetail,
fallbackMessage: "删除文件夹失败", fallbackMessage: "删除任务集失败",
}); });
export const respondSuperAgentCheckpoint = (options = {}) => async () => { export const respondSuperAgentCheckpoint = (options = {}) => async () => {
@ -174,21 +174,24 @@ const requestSuperAgentJson = async (options) => requestAgentJson(options);
export const updateSuperAgentFolder = async ({ folderId, userId, body }) => export const updateSuperAgentFolder = async ({ folderId, userId, body }) =>
requestSuperAgentJson({ requestSuperAgentJson({
method: "PATCH", method: "PUT",
path: superAgentServiceApiTable.tasksetDetail, path: superAgentServiceApiTable.tasksetDetail,
params: { folderId }, params: { tasksetId: folderId },
query: { userId }, body: body?.name ? { name: body.name } : body,
body, fallbackMessage: "更新任务集失败",
fallbackMessage: "更新文件夹失败",
}); });
export const patchSuperAgentSession = async ({ sessionId, userId, body }) => export const patchSuperAgentSession = async ({ sessionId, userId, body }) =>
requestSuperAgentJson({ requestSuperAgentJson({
method: "PATCH", method: "PUT",
path: superAgentServiceApiTable.conversationDetail, path: superAgentServiceApiTable.conversationDetail,
params: { sessionId }, params: { conversationId: sessionId },
query: { userId }, body: {
body, ...(body?.name || body?.title ? { name: body.name || body.title } : {}),
...(body?.taskset_id || body?.tasksetId || body?.folder_id
? { taskset_id: body.taskset_id || body.tasksetId || body.folder_id }
: {}),
},
fallbackMessage: "更新会话失败", fallbackMessage: "更新会话失败",
}); });

12
web/client/src/sections/superAgent/components/SidebarFolderRow.jsx

@ -94,12 +94,12 @@ const SidebarFolderRow = ({
key: "delete", key: "delete",
label: ( label: (
<Popconfirm <Popconfirm
title="删除后下属会话将移到默认文件夹,确定删除吗?" title="仅空任务集可删除,确定删除吗?"
okText="删除" okText="删除"
cancelText="取消" cancelText="取消"
onConfirm={() => onDelete?.(folder)} onConfirm={() => onDelete?.(folder)}
> >
<span>删除文件夹</span> <span>删除任务集</span>
</Popconfirm> </Popconfirm>
), ),
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
@ -141,7 +141,7 @@ const SidebarFolderRow = ({
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label="在文件夹内新建会话" aria-label="在文件夹内新建会话"
disabled={disableCreateSession} disabled={disableCreateSession}
title="在文件夹内新建会话" title="在任务集内新建任务"
onClick={() => onCreateSession?.(realm, apiFolderId)} onClick={() => onCreateSession?.(realm, apiFolderId)}
/> />
{folderMenuItems.length ? ( {folderMenuItems.length ? (
@ -202,14 +202,14 @@ const SidebarFolderRow = ({
) : null} ) : null}
<Modal <Modal
title="重命名文件夹" title="重命名任务集"
open={renameOpen} open={renameOpen}
okText="保存" okText="保存"
cancelText="取消" cancelText="取消"
onCancel={() => setRenameOpen(false)} onCancel={() => setRenameOpen(false)}
onOk={async () => { onOk={async () => {
if (isVirtualDefault) { if (isVirtualDefault) {
message.warning("默认文件夹不可重命名"); message.warning("默认任务集不可重命名");
setRenameOpen(false); setRenameOpen(false);
return; return;
} }
@ -221,7 +221,7 @@ const SidebarFolderRow = ({
value={renameValue} value={renameValue}
maxLength={40} maxLength={40}
onChange={(event) => setRenameValue(event.target.value)} onChange={(event) => setRenameValue(event.target.value)}
placeholder="请输入文件夹名称" placeholder="请输入任务集名称"
/> />
</Modal> </Modal>
</div> </div>

10
web/client/src/sections/superAgent/components/SuperAgentSidebar.jsx

@ -1,6 +1,6 @@
/** /**
* 文件作用:superAgent 左侧导航与会话树 * 文件作用:superAgent 左侧导航与会话树
* - 任务合集:文件夹 + 拖拽;folder_id=null 归入虚拟「默认文件夹」 * - 任务合集:任务集 + 拖拽;任务必须归属某个任务集
* - 普通对话:扁平列表(后端 conversation 文件夹接口保留,前端暂不启用) * - 普通对话:扁平列表(后端 conversation 文件夹接口保留,前端暂不启用)
*/ */
"use strict"; "use strict";
@ -38,7 +38,7 @@ const menuTipMap = {
task: { task: {
title: "开启新任务(Task)", title: "开启新任务(Task)",
content: content:
"启动超级智能体,支持复杂文件生成、自动对账与Agent协同。任务将沉淀于下方「任务合集」的默认文件夹中。", "启动超级智能体,支持复杂文件生成、自动对账与Agent协同。任务将沉淀于下方「任务合集」中。",
}, },
}; };
@ -265,7 +265,7 @@ const SuperAgentSidebar = ({
<div className="super-section-title"> <div className="super-section-title">
<span>{taskRealm.label || "任务合集"}</span> <span>{taskRealm.label || "任务合集"}</span>
<div className="sidebar-realm-actions"> <div className="sidebar-realm-actions">
<Tooltip title="新建任务文件夹"> <Tooltip title="新建任务集">
<Button <Button
type="text" type="text"
size="small" size="small"
@ -398,7 +398,7 @@ const SuperAgentSidebar = ({
</div> </div>
<Modal <Modal
title="新建任务文件夹" title="新建任务集"
open={createFolderOpen} open={createFolderOpen}
okText="创建" okText="创建"
cancelText="取消" cancelText="取消"
@ -408,7 +408,7 @@ const SuperAgentSidebar = ({
<Input <Input
value={createFolderName} value={createFolderName}
maxLength={40} maxLength={40}
placeholder="请输入文件夹名称" placeholder="请输入任务集名称"
onChange={(event) => setCreateFolderName(event.target.value)} onChange={(event) => setCreateFolderName(event.target.value)}
/> />
</Modal> </Modal>

4
web/client/src/sections/superAgent/constants.js

@ -28,12 +28,12 @@ export const SIDEBAR_SESSION_DRAG_TYPE = "application/x-super-sidebar-session";
/** 虚拟默认文件夹(folder_id = null 的任务归入此处,不落库) */ /** 虚拟默认文件夹(folder_id = null 的任务归入此处,不落库) */
export const DEFAULT_TASK_FOLDER_ID = "__default__"; export const DEFAULT_TASK_FOLDER_ID = "__default__";
export const DEFAULT_TASK_FOLDER_NAME = "默认文件夹"; export const DEFAULT_TASK_FOLDER_NAME = "默认任务集";
export const TASK_STORAGE_KEY = "superAgent_tender_task_list"; export const TASK_STORAGE_KEY = "superAgent_tender_task_list";
export const TASK_SESSION_STORAGE_KEY = "superAgent_tender_session_ids"; export const TASK_SESSION_STORAGE_KEY = "superAgent_tender_session_ids";
export const PENDING_EMPTY_SESSION_STORAGE_KEY = "superAgent_pending_empty_session"; export const PENDING_EMPTY_SESSION_STORAGE_KEY = "superAgent_pending_empty_session";
export const ACTIVE_SESSION_STORAGE_KEY = "superAgent_active_session_id"; export const ACTIVE_SESSION_STORAGE_KEY = "superAgent_v2_active_conversation_id";
export const PHONE_REGEXP = /^1\d{10}$/; export const PHONE_REGEXP = /^1\d{10}$/;
export const CHAT_WELCOME_TITLE = "你好,我是飞小尚 AI 办公助手"; export const CHAT_WELCOME_TITLE = "你好,我是飞小尚 AI 办公助手";

9
web/client/src/sections/superAgent/container/index.jsx

@ -458,6 +458,7 @@ const SuperAgent = () => {
sidebarTree, sidebarTree,
collapsedFolderMap, collapsedFolderMap,
activeSessionId, activeSessionId,
activeConversationId,
isLoadingSessions, isLoadingSessions,
isUploadingFile, isUploadingFile,
isCreatingChat, isCreatingChat,
@ -1904,9 +1905,9 @@ const SuperAgent = () => {
return; return;
} }
if (activeSessionId === cachedSessionId) { if (activeConversationId === cachedSessionId || activeSessionId === cachedSessionId) {
activeSessionRestoredRef.current = true; activeSessionRestoredRef.current = true;
//[页面启动时由 useStream 按 activeSessionId 恢复当前 thread] //[页面启动时由 useStream 按 thread_id 恢复当前 thread]
return; return;
} }
@ -1923,7 +1924,7 @@ const SuperAgent = () => {
return; return;
} }
handleSelectChatByStream(matchedSession.session); handleSelectChatByStream(matchedSession.session);
}, [activeSessionId, currentUserId, isLoadingSessions, sidebarTree]); }, [activeConversationId, activeSessionId, currentUserId, isLoadingSessions, sidebarTree]);
const handleOpenAgentSquare = () => { const handleOpenAgentSquare = () => {
setActiveAgentApp(null); setActiveAgentApp(null);
@ -2429,7 +2430,7 @@ const SuperAgent = () => {
routeType, routeType,
sidebarTree, sidebarTree,
collapsedFolderMap, collapsedFolderMap,
activeSessionId, activeSessionId: activeConversationId || activeSessionId,
isLoadingSessions, isLoadingSessions,
isCreatingChat, isCreatingChat,
isCreatingTask, isCreatingTask,

70
web/client/src/sections/superAgent/hooks/useSuperAgentChat.js

@ -19,6 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { message } from "antd"; import { message } from "antd";
import { import {
cancelSuperAgentSession, cancelSuperAgentSession,
createSuperAgentFolder,
createSuperAgentSession, createSuperAgentSession,
deleteSuperAgentSession, deleteSuperAgentSession,
respondSuperAgentCheckpointStream, respondSuperAgentCheckpointStream,
@ -76,6 +77,7 @@ const ensureActionSuccess = (res, fallbackMessage) => {
}; };
const buildSessionParams = (sessionId) => ({ const buildSessionParams = (sessionId) => ({
conversationId: sessionId,
sessionId, sessionId,
":sessionId": sessionId, ":sessionId": sessionId,
}); });
@ -138,6 +140,7 @@ const useSuperAgentChat = ({
const [taskStage, setTaskStage] = useState(TASK_STAGE.IDLE); const [taskStage, setTaskStage] = useState(TASK_STAGE.IDLE);
const [conversationMessages, setConversationMessages] = useState([]); const [conversationMessages, setConversationMessages] = useState([]);
const [activeSessionId, setActiveSessionId] = useState(""); const [activeSessionId, setActiveSessionId] = useState("");
const [activeConversationId, setActiveConversationId] = useState("");
const isLoadingSessions = isLoadingSidebar; const isLoadingSessions = isLoadingSidebar;
const [isUploadingFile, setIsUploadingFile] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null); const [uploadProgress, setUploadProgress] = useState(null);
@ -181,14 +184,14 @@ const useSuperAgentChat = ({
const findSessionTypeById = (sessionId) => { const findSessionTypeById = (sessionId) => {
if (!sessionId) return ""; if (!sessionId) return "";
const matchedTask = taskList.find((task) => { const matchedTask = taskList.find((task) => {
const taskId = task.id || task.chatId; const ids = [task.id, task.chatId, task.conversationId, task.threadId, task.thread_id];
return taskId === sessionId; return ids.includes(sessionId);
}); });
if (matchedTask) return "task"; if (matchedTask) return "task";
const matchedChat = ordinaryChatList.find((chat) => { const matchedChat = ordinaryChatList.find((chat) => {
const chatId = chat.id || chat.chatId; const ids = [chat.id, chat.chatId, chat.conversationId, chat.threadId, chat.thread_id];
return chatId === sessionId; return ids.includes(sessionId);
}); });
if (matchedChat) return "conversation"; if (matchedChat) return "conversation";
@ -373,9 +376,14 @@ const useSuperAgentChat = ({
}; };
const clearActiveSidebarSession = (sessionId, shouldForce = false) => { const clearActiveSidebarSession = (sessionId, shouldForce = false) => {
if (!shouldForce && activeSessionId !== sessionId) return; if (
!shouldForce &&
activeSessionId !== sessionId &&
activeConversationId !== sessionId
) return;
clearActiveSessionId(sessionId); clearActiveSessionId(sessionId);
setActiveSessionId(""); setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId(""); setActiveTaskId("");
setTaskStage(TASK_STAGE.IDLE); setTaskStage(TASK_STAGE.IDLE);
setConversationMessages([]); setConversationMessages([]);
@ -397,18 +405,31 @@ const useSuperAgentChat = ({
} }
const sessionType = sessionKind || (isTaskSession ? "task" : "conversation"); const sessionType = sessionKind || (isTaskSession ? "task" : "conversation");
const requestBody = { type: sessionType, reuse_empty: true }; const requestBody = { type: sessionType };
const apiFolderId = resolveApiFolderId(folderId); let apiFolderId = resolveApiFolderId(folderId);
if (apiFolderId) requestBody.folder_id = apiFolderId; if (sessionType === "task" && !apiFolderId) {
if (title) requestBody.title = title; const taskRealm = (sidebarTree?.realms || []).find((item) => item.realm === "task");
apiFolderId = (taskRealm?.folders || []).find((folder) => resolveApiFolderId(folder?.id))?.id || "";
if (!apiFolderId) {
const created = await dispatch(createSuperAgentFolder({
body: { name: "默认任务集" },
})).then((res) => ensureActionSuccess(res, "创建任务集失败"));
apiFolderId = created?.id || created?.data?.id || "";
await loadSidebar({ silent: true });
}
if (!apiFolderId) throw new Error("创建任务失败,缺少任务集");
}
if (sessionType === "task") requestBody.taskset_id = apiFolderId;
if (title) requestBody.name = title;
const session = await dispatch(createSuperAgentSession({ const session = await dispatch(createSuperAgentSession({
query: { userId: currentUserId },
body: requestBody, body: requestBody,
})).then((res) => ensureActionSuccess(res, "创建超级智能体会话失败")); })).then((res) => ensureActionSuccess(res, "创建超级智能体会话失败"));
const sessionData = session?.data || session; const sessionData = session?.data || session;
const nextSessionId = sessionData?.session_id || sessionData?.id; const nextSessionId = sessionData?.id || sessionData?.conversation_id;
if (!nextSessionId) throw new Error("创建会话失败,后端未返回 session_id"); const nextThreadId = sessionData?.thread_id || sessionData?.threadId || "";
if (!nextSessionId) throw new Error("创建会话失败,后端未返回 conversation id");
if (!nextThreadId) throw new Error("创建会话失败,后端未返回 thread_id");
const returnedType = const returnedType =
sessionData?.session_kind || sessionData?.session_kind ||
@ -431,8 +452,9 @@ const useSuperAgentChat = ({
message.info(tipText); message.info(tipText);
} }
setActiveSessionId(nextSessionId); setActiveSessionId(nextThreadId);
saveActiveSessionId(nextSessionId); saveActiveSessionId(nextSessionId);
setActiveConversationId(nextSessionId);
setWorkspaceOpen(false); setWorkspaceOpen(false);
setUploadedFileList([]); setUploadedFileList([]);
markPendingEmptySession(nextSessionId, returnedType, apiFolderId); markPendingEmptySession(nextSessionId, returnedType, apiFolderId);
@ -1328,6 +1350,7 @@ const useSuperAgentChat = ({
setRouteType(nextRouteType); setRouteType(nextRouteType);
setWorkspaceOpen(false); setWorkspaceOpen(false);
setActiveSessionId(""); setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId(""); setActiveTaskId("");
clearActiveSessionId(); clearActiveSessionId();
setConversationMessages([]); setConversationMessages([]);
@ -1471,6 +1494,7 @@ const useSuperAgentChat = ({
setRouteType("agents"); setRouteType("agents");
setWorkspaceOpen(false); setWorkspaceOpen(false);
setActiveSessionId(""); setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId(""); setActiveTaskId("");
clearActiveSessionId(); clearActiveSessionId();
}; };
@ -1479,6 +1503,7 @@ const useSuperAgentChat = ({
setRouteType("assets"); setRouteType("assets");
setWorkspaceOpen(false); setWorkspaceOpen(false);
setActiveSessionId(""); setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId(""); setActiveTaskId("");
clearActiveSessionId(); clearActiveSessionId();
}; };
@ -1751,10 +1776,12 @@ const useSuperAgentChat = ({
}; };
const handleSelectTask = async (task) => { const handleSelectTask = async (task) => {
activeSessionIdRef.current = task.id; activeSessionIdRef.current = task.threadId || task.thread_id || task.id;
setActiveTaskId(task.id); setActiveTaskId(task.id || task.conversationId);
setActiveSessionId(task.id); const nextThreadId = task.threadId || task.thread_id || task.id;
saveActiveSessionId(task.id); setActiveSessionId(nextThreadId);
saveActiveSessionId(task.id || task.conversationId);
setActiveConversationId(task.id || task.conversationId);
setRouteType("task"); setRouteType("task");
setTaskStage(task.stage || TASK_STAGE.IDLE); setTaskStage(task.stage || TASK_STAGE.IDLE);
setWorkspaceOpen(false); setWorkspaceOpen(false);
@ -1767,10 +1794,12 @@ const useSuperAgentChat = ({
}; };
const handleSelectChat = async (chat) => { const handleSelectChat = async (chat) => {
const sessionId = chat.id || chat.chatId; const sessionId = chat.id || chat.chatId || chat.conversationId;
activeSessionIdRef.current = sessionId; const nextThreadId = chat.threadId || chat.thread_id || sessionId;
setActiveSessionId(sessionId); activeSessionIdRef.current = nextThreadId;
setActiveSessionId(nextThreadId);
saveActiveSessionId(sessionId); saveActiveSessionId(sessionId);
setActiveConversationId(sessionId);
setActiveTaskId(""); setActiveTaskId("");
setRouteType("chat"); setRouteType("chat");
setTaskStage(TASK_STAGE.IDLE); setTaskStage(TASK_STAGE.IDLE);
@ -1824,6 +1853,7 @@ const useSuperAgentChat = ({
sidebarTree, sidebarTree,
collapsedFolderMap, collapsedFolderMap,
activeSessionId, activeSessionId,
activeConversationId,
isLoadingSessions, isLoadingSessions,
isUploadingFile, isUploadingFile,
isStreamingAnswer, isStreamingAnswer,

62
web/client/src/sections/superAgent/hooks/useSuperAgentSidebar.js

@ -8,14 +8,14 @@ import { message } from "antd";
import { import {
createSuperAgentFolder, createSuperAgentFolder,
deleteSuperAgentFolder, deleteSuperAgentFolder,
getSuperAgentSidebar,
patchSuperAgentSession, patchSuperAgentSession,
updateSuperAgentFolder, updateSuperAgentFolder,
} from "../actions/api"; } from "../actions/api";
import { listAllConversations, listAllTasksets } from "../actions/agentResources";
import { computeSessionDrop } from "../utils/sidebarDrag"; import { computeSessionDrop } from "../utils/sidebarDrag";
import { import {
normalizeSessionTitle, normalizeSessionTitle,
normalizeSidebarTree, buildSidebarTreeFromAgentResources,
getRealmFromSidebarTree, getRealmFromSidebarTree,
flattenRealmSessions, flattenRealmSessions,
removeSessionFromSidebarTree, removeSessionFromSidebarTree,
@ -67,10 +67,11 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
setIsLoadingSidebar(true); setIsLoadingSidebar(true);
} }
try { try {
const data = await dispatch(getSuperAgentSidebar({ const [tasksets, conversations] = await Promise.all([
query: { userId: currentUserId }, listAllTasksets(),
})).then((res) => ensureActionSuccess(res, "获取侧边栏失败")); listAllConversations(),
const nextTree = normalizeSidebarTree(data); ]);
const nextTree = buildSidebarTreeFromAgentResources(tasksets, conversations);
setSidebarTree(nextTree); setSidebarTree(nextTree);
return nextTree; return nextTree;
} catch (error) { } catch (error) {
@ -107,25 +108,24 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
const handleCreateFolder = useCallback(async (realm, name) => { const handleCreateFolder = useCallback(async (realm, name) => {
const folderName = String(name || "").trim(); const folderName = String(name || "").trim();
if (!folderName) { if (!folderName) {
message.warning("文件夹名称不能为空"); message.warning("任务集名称不能为空");
return false; return false;
} }
// 一期仅任务合集启用文件夹;conversation 接口保留,前端不调用 // 一期仅任务合集启用文件夹;conversation 接口保留,前端不调用
if (realm && realm !== "task") { if (realm && realm !== "task") {
message.warning("普通对话暂不支持文件夹"); message.warning("普通对话暂不支持任务集");
return false; return false;
} }
try { try {
await dispatch(createSuperAgentFolder({ await dispatch(createSuperAgentFolder({
query: { userId: currentUserId }, body: { name: folderName },
body: { realm: "task", name: folderName }, })).then((res) => ensureActionSuccess(res, "创建任务集失败"));
})).then((res) => ensureActionSuccess(res, "创建文件夹失败"));
await loadSidebar(); await loadSidebar();
message.success("文件夹已创建"); message.success("任务集已创建");
return true; return true;
} catch (error) { } catch (error) {
message.error(error?.message || "创建文件夹失败"); message.error(error?.message || "创建任务集失败");
return false; return false;
} }
}, [actions, currentUserId, dispatch, loadSidebar]); }, [actions, currentUserId, dispatch, loadSidebar]);
@ -134,7 +134,7 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
const folderName = String(name || "").trim(); const folderName = String(name || "").trim();
if (!folderId || !folderName) return false; if (!folderId || !folderName) return false;
if (isVirtualDefaultFolder(folderId)) { if (isVirtualDefaultFolder(folderId)) {
message.warning("默认文件夹不可重命名"); message.warning("默认任务集不可重命名");
return false; return false;
} }
@ -145,10 +145,10 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
body: { name: folderName }, body: { name: folderName },
}); });
await loadSidebar(); await loadSidebar();
message.success("文件夹已重命名"); message.success("任务集已重命名");
return true; return true;
} catch (error) { } catch (error) {
message.error(error?.message || "重命名文件夹失败"); message.error(error?.message || "重命名任务集失败");
return false; return false;
} }
}, [currentUserId, loadSidebar]); }, [currentUserId, loadSidebar]);
@ -157,25 +157,19 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
const folderId = typeof folderOrId === "string" ? folderOrId : folderOrId?.id; const folderId = typeof folderOrId === "string" ? folderOrId : folderOrId?.id;
if (!folderId) return false; if (!folderId) return false;
if (isVirtualDefaultFolder(folderId) || isVirtualDefaultFolder(folderOrId)) { if (isVirtualDefaultFolder(folderId) || isVirtualDefaultFolder(folderOrId)) {
message.warning("默认文件夹不可删除"); message.warning("默认任务集不可删除");
return false; return false;
} }
try { try {
const result = await dispatch(deleteSuperAgentFolder({ await dispatch(deleteSuperAgentFolder({
params: { folderId, ":folderId": folderId }, params: { tasksetId: folderId, folderId },
query: { userId: currentUserId }, })).then((res) => ensureActionSuccess(res, "删除任务集失败"));
})).then((res) => ensureActionSuccess(res, "删除文件夹失败"));
await loadSidebar(); await loadSidebar();
const movedCount = Number(result?.sessions_moved_to_root || 0); message.success("任务集已删除");
if (movedCount > 0) {
message.success(`文件夹已删除,${movedCount} 个会话已移到默认文件夹`);
} else {
message.success("文件夹已删除");
}
return true; return true;
} catch (error) { } catch (error) {
message.error(error?.message || "删除文件夹失败"); message.error(error?.message || "删除任务集失败");
return false; return false;
} }
}, [actions, currentUserId, dispatch, loadSidebar]); }, [actions, currentUserId, dispatch, loadSidebar]);
@ -187,8 +181,7 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
try { try {
await patchSuperAgentSession({ await patchSuperAgentSession({
sessionId, sessionId,
userId: currentUserId, body: { name: sessionTitle },
body: { title: sessionTitle },
}); });
patchSessionInTree(sessionId, { patchSessionInTree(sessionId, {
title: normalizeSessionTitle(sessionTitle, "新对话"), title: normalizeSessionTitle(sessionTitle, "新对话"),
@ -225,14 +218,13 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
} }
try { try {
const body = { sort_order: dropPlan.sortOrder }; if (!dropPlan.changedFolder || !dropPlan.folderId) {
if (dropPlan.changedFolder) { message.warning("任务必须归属一个任务集");
body.folder_id = dropPlan.folderId ?? null; return false;
} }
await patchSuperAgentSession({ await patchSuperAgentSession({
sessionId: dragSessionId, sessionId: dragSessionId,
userId: currentUserId, body: { taskset_id: dropPlan.folderId },
body,
}); });
await loadSidebar(); await loadSidebar();
return true; return true;

94
web/client/src/sections/superAgent/utils/session.js

@ -12,7 +12,7 @@
* *
* 维护说明: * 维护说明:
* - 当前任务合集由后端 session 加本地 tender session 标记共同识别 * - 当前任务合集由后端 session 加本地 tender session 标记共同识别
* - 任务侧栏展示时 folder_id=null 归入虚拟默认文件夹(不改库) * - 任务侧栏始终展示固定「默认任务集」;后端第一条同名任务集会绑定到该槽位
*/ */
"use strict"; "use strict";
@ -215,12 +215,16 @@ export const isTenderSession = (item = {}) => {
}; };
export const normalizeSidebarSession = (item = {}, realm = "conversation") => { export const normalizeSidebarSession = (item = {}, realm = "conversation") => {
const sessionId = item.id || item.session_id || item.chatId || item.chat_id || ""; const conversationId = item.id || item.conversation_id || item.conversationId || "";
const threadId = item.thread_id || item.threadId || "";
const sessionKind = getSessionKind({ ...item, type: item.type || realm }); const sessionKind = getSessionKind({ ...item, type: item.type || realm });
const isTask = sessionKind === "task"; const isTask = sessionKind === "task";
const tasksetId = item.taskset_id || item.tasksetId || item.folder_id || item.folderId || null;
return { return {
id: sessionId, id: conversationId,
chatId: sessionId, chatId: conversationId,
conversationId,
threadId,
title: normalizeSessionTitle(item.title || item.name || item.summary, isTask ? "未命名任务" : "新对话"), title: normalizeSessionTitle(item.title || item.name || item.summary, isTask ? "未命名任务" : "新对话"),
time: item.updated_at || item.updateTime || item.updatedAt || item.created_at || item.createdAt || Date.now(), time: item.updated_at || item.updateTime || item.updatedAt || item.created_at || item.createdAt || Date.now(),
sessionKind, sessionKind,
@ -228,13 +232,46 @@ export const normalizeSidebarSession = (item = {}, realm = "conversation") => {
taskType: item.task_type || item.taskType || "", taskType: item.task_type || item.taskType || "",
status: item.status || "", status: item.status || "",
isTask, isTask,
folderId: item.folder_id || item.folderId || null, folderId: tasksetId,
tasksetId,
sortOrder: Number(item.sort_order ?? item.sortOrder ?? 0), sortOrder: Number(item.sort_order ?? item.sortOrder ?? 0),
hasActiveTask: Boolean(item.has_active_task ?? item.hasActiveTask), hasActiveTask: Boolean(item.has_active_task ?? item.hasActiveTask),
raw: item, raw: item,
}; };
}; };
export const buildSidebarTreeFromAgentResources = (tasksets = [], conversations = []) => {
const sessions = (conversations || []).map((item) => normalizeSidebarSession(item, item.type));
const tasks = sessions.filter((session) => session.sessionKind === "task");
const chats = sessions.filter((session) => session.sessionKind !== "task");
const folders = (tasksets || []).map((taskset) => ({
id: taskset.id,
name: taskset.name || "未命名任务集",
sortOrder: 0,
updatedAt: taskset.updated_at || taskset.updatedAt || "",
realm: "task",
isVirtualDefault: false,
sessions: tasks.filter((session) => session.tasksetId === taskset.id),
}));
return {
realms: [
{
realm: "task",
label: "任务合集",
folders,
sessions: [],
},
{
realm: "conversation",
label: "普通对话",
folders: [],
sessions: chats,
},
],
};
};
export const normalizeSidebarFolder = (folder = {}, realm = "conversation") => ({ export const normalizeSidebarFolder = (folder = {}, realm = "conversation") => ({
id: folder.id || folder.folder_id || "", id: folder.id || folder.folder_id || "",
name: folder.name || "未命名文件夹", name: folder.name || "未命名文件夹",
@ -255,38 +292,57 @@ export const isVirtualDefaultFolder = (folderOrId) => {
); );
}; };
/** 虚拟默认文件夹 id → API 的 folder_id=null */ const isPlaceholderDefaultFolderId = (folderId) =>
!folderId || folderId === DEFAULT_TASK_FOLDER_ID;
/** 占位默认任务集(__default__)→ null;已绑定真实任务集则返回真实 id */
export const resolveApiFolderId = (folderId) => { export const resolveApiFolderId = (folderId) => {
if (!folderId || isVirtualDefaultFolder(folderId)) return null; if (!folderId) return null;
return folderId; if (typeof folderId === "string") {
return isPlaceholderDefaultFolderId(folderId) ? null : folderId;
}
if (isPlaceholderDefaultFolderId(folderId.id)) return null;
return folderId.id;
}; };
/** /**
* 任务合集展示归一化:根级 sessions(folder_id=null)收进虚拟「默认文件夹」, * 任务合集展示归一化:页面始终保留一个固定「默认任务集」。
* 文件夹外不再单独展示任务。 * 后端第一条同名真实任务集会绑定到该固定槽位,避免空库新建后出现两个默认任务集。
*/ */
export const normalizeTaskRealmForDisplay = (realmItem = {}) => { export const normalizeTaskRealmForDisplay = (realmItem = {}) => {
const rootSessions = Array.isArray(realmItem.sessions) ? realmItem.sessions : []; const rootSessions = Array.isArray(realmItem.sessions) ? realmItem.sessions : [];
const defaultFolderSessions = (realmItem.folders || []) const incomingFolders = Array.isArray(realmItem.folders) ? realmItem.folders : [];
.filter((folder) => isVirtualDefaultFolder(folder)) const placeholderFolders = incomingFolders.filter((folder) =>
.flatMap((folder) => folder.sessions || []); isPlaceholderDefaultFolderId(folder?.id)
const realFolders = (realmItem.folders || []).filter(
(folder) => !isVirtualDefaultFolder(folder)
); );
const realFolders = incomingFolders.filter((folder) =>
!isPlaceholderDefaultFolderId(folder?.id)
);
const boundDefaultFolders = realFolders.filter((folder) =>
folder?.isVirtualDefault || String(folder.name || "").trim() === DEFAULT_TASK_FOLDER_NAME
);
const otherFolders = realFolders.filter((folder) =>
!folder?.isVirtualDefault && String(folder.name || "").trim() !== DEFAULT_TASK_FOLDER_NAME
);
const boundDefaultFolder = boundDefaultFolders[0] || null;
const virtualDefaultFolder = { const virtualDefaultFolder = {
id: DEFAULT_TASK_FOLDER_ID, id: boundDefaultFolder?.id || DEFAULT_TASK_FOLDER_ID,
name: DEFAULT_TASK_FOLDER_NAME, name: DEFAULT_TASK_FOLDER_NAME,
sortOrder: -1000, sortOrder: -1000,
updatedAt: "", updatedAt: boundDefaultFolder?.updatedAt || "",
realm: "task", realm: "task",
isVirtualDefault: true, isVirtualDefault: true,
sessions: [...defaultFolderSessions, ...rootSessions], sessions: [
...placeholderFolders.flatMap((folder) => folder.sessions || []),
...boundDefaultFolders.flatMap((folder) => folder.sessions || []),
...rootSessions,
],
}; };
return { return {
...realmItem, ...realmItem,
realm: "task", realm: "task",
folders: [virtualDefaultFolder, ...realFolders], folders: [virtualDefaultFolder, ...otherFolders],
sessions: [], sessions: [],
}; };
}; };

109
web/tests/superAgentSidebarContract.test.mjs

@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
const root = path.resolve(import.meta.dirname, "..");
const read = (...parts) => fs.readFileSync(path.join(root, ...parts), "utf8");
const sessionSource = read("client", "src", "sections", "superAgent", "utils", "session.js");
const sidebarHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentSidebar.js");
const chatHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentChat.js");
const actions = read("client", "src", "sections", "superAgent", "actions", "api.js");
const router = read("client", "src", "sections", "superAgent", "router.js");
function loadSessionUtils() {
const transformed =
sessionSource
.replace(/^import[\s\S]*?from\s+"[^"]+";\s*/gm, "")
.replace(/export const ([A-Za-z0-9_]+) =/g, "const $1 =") +
"\nconst DEFAULT_TASK_FOLDER_ID = '__default__';\nconst DEFAULT_TASK_FOLDER_NAME = '默认任务集';\nreturn { buildSidebarTreeFromAgentResources, normalizeSidebarSession, normalizeTaskRealmForDisplay };";
return new Function(transformed)();
}
const {
buildSidebarTreeFromAgentResources,
normalizeSidebarSession,
normalizeTaskRealmForDisplay,
} = loadSessionUtils();
test("chat route remains /chat", () => {
assert.match(router, /path: "\/chat"/);
});
test("sidebar session keeps conversation id and thread id separate", () => {
const session = normalizeSidebarSession({
id: "11111111-1111-4111-8111-111111111111",
name: "采购任务",
type: "task",
taskset_id: "22222222-2222-4222-8222-222222222222",
thread_id: "33333333-3333-4333-8333-333333333333",
}, "task");
assert.equal(session.conversationId, "11111111-1111-4111-8111-111111111111");
assert.equal(session.threadId, "33333333-3333-4333-8333-333333333333");
assert.equal(session.tasksetId, "22222222-2222-4222-8222-222222222222");
});
test("tasksets become folders and conversations stay flat", () => {
const tree = buildSidebarTreeFromAgentResources(
[{ id: "ts-1", name: "2026 采购" }],
[
{ id: "c-1", name: "任务A", type: "task", taskset_id: "ts-1", thread_id: "th-1" },
{ id: "c-2", name: "闲聊", type: "conversation", taskset_id: null, thread_id: "th-2" },
],
);
const taskRealm = tree.realms.find((item) => item.realm === "task");
const chatRealm = tree.realms.find((item) => item.realm === "conversation");
assert.equal(taskRealm.folders[0].name, "2026 采购");
assert.equal(taskRealm.folders[0].sessions[0].id, "c-1");
assert.equal(chatRealm.sessions[0].id, "c-2");
assert.equal(chatRealm.folders.length, 0);
});
test("sidebar and CRUD use new resource paths and ids", () => {
assert.match(sidebarHook, /listAllTasksets/);
assert.match(sidebarHook, /listAllConversations/);
assert.match(actions, /params: \{ tasksetId: folderId \}/);
assert.match(actions, /params: \{ conversationId: sessionId \}/);
assert.match(chatHook, /thread_id/);
assert.match(chatHook, /taskset_id/);
assert.match(chatHook, /activeConversationId/);
});
test("first real default taskset binds to the fixed sidebar slot", () => {
const tree = buildSidebarTreeFromAgentResources(
[{ id: "ts-default", name: "默认任务集" }],
[{ id: "c-1", name: "任务A", type: "task", taskset_id: "ts-default", thread_id: "th-1" }],
);
const displayed = normalizeTaskRealmForDisplay(tree.realms.find((item) => item.realm === "task"));
assert.equal(displayed.folders.length, 1);
assert.equal(displayed.folders[0].name, "默认任务集");
assert.equal(displayed.folders[0].id, "ts-default");
assert.equal(displayed.folders[0].isVirtualDefault, true);
assert.equal(displayed.folders[0].sessions[0].id, "c-1");
});
test("empty tasksets still show one placeholder default taskset", () => {
const tree = buildSidebarTreeFromAgentResources([], []);
const displayed = normalizeTaskRealmForDisplay(tree.realms.find((item) => item.realm === "task"));
assert.equal(displayed.folders.length, 1);
assert.equal(displayed.folders[0].id, "__default__");
assert.equal(displayed.folders[0].name, "默认任务集");
assert.equal(displayed.folders[0].isVirtualDefault, true);
});
test("custom tasksets stay beside the bound default taskset", () => {
const tree = buildSidebarTreeFromAgentResources(
[
{ id: "ts-default", name: "默认任务集" },
{ id: "ts-2", name: "2026 采购" },
],
[
{ id: "c-1", name: "任务A", type: "task", taskset_id: "ts-default", thread_id: "th-1" },
{ id: "c-2", name: "任务B", type: "task", taskset_id: "ts-2", thread_id: "th-2" },
],
);
const displayed = normalizeTaskRealmForDisplay(tree.realms.find((item) => item.realm === "task"));
assert.equal(displayed.folders.map((folder) => folder.name).join(","), "默认任务集,2026 采购");
assert.equal(displayed.folders[0].id, "ts-default");
assert.equal(displayed.folders[1].id, "ts-2");
});
Loading…
Cancel
Save