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 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 = {}) =>
jsonAction({
method: "GET",
@ -52,6 +67,8 @@ export const deleteTaskset = (tasksetId) =>
fallbackMessage: "删除任务集失败",
});
export const listAllTasksets = (query = {}) => listAllPages(listTasksets, query);
export const listConversations = (query = {}) =>
jsonAction({
method: "GET",
@ -60,6 +77,8 @@ export const listConversations = (query = {}) =>
fallbackMessage: "获取会话失败",
}).then(parseAgentPage);
export const listAllConversations = (query = {}) => listAllPages(listConversations, query);
export const createConversation = (body, options = {}) =>
jsonAction({
method: "POST",

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

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

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

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

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

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

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

@ -458,6 +458,7 @@ const SuperAgent = () => {
sidebarTree,
collapsedFolderMap,
activeSessionId,
activeConversationId,
isLoadingSessions,
isUploadingFile,
isCreatingChat,
@ -1904,9 +1905,9 @@ const SuperAgent = () => {
return;
}
if (activeSessionId === cachedSessionId) {
if (activeConversationId === cachedSessionId || activeSessionId === cachedSessionId) {
activeSessionRestoredRef.current = true;
//[页面启动时由 useStream 按 activeSessionId 恢复当前 thread]
//[页面启动时由 useStream 按 thread_id 恢复当前 thread]
return;
}
@ -1923,7 +1924,7 @@ const SuperAgent = () => {
return;
}
handleSelectChatByStream(matchedSession.session);
}, [activeSessionId, currentUserId, isLoadingSessions, sidebarTree]);
}, [activeConversationId, activeSessionId, currentUserId, isLoadingSessions, sidebarTree]);
const handleOpenAgentSquare = () => {
setActiveAgentApp(null);
@ -2429,7 +2430,7 @@ const SuperAgent = () => {
routeType,
sidebarTree,
collapsedFolderMap,
activeSessionId,
activeSessionId: activeConversationId || activeSessionId,
isLoadingSessions,
isCreatingChat,
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 {
cancelSuperAgentSession,
createSuperAgentFolder,
createSuperAgentSession,
deleteSuperAgentSession,
respondSuperAgentCheckpointStream,
@ -76,6 +77,7 @@ const ensureActionSuccess = (res, fallbackMessage) => {
};
const buildSessionParams = (sessionId) => ({
conversationId: sessionId,
sessionId,
":sessionId": sessionId,
});
@ -138,6 +140,7 @@ const useSuperAgentChat = ({
const [taskStage, setTaskStage] = useState(TASK_STAGE.IDLE);
const [conversationMessages, setConversationMessages] = useState([]);
const [activeSessionId, setActiveSessionId] = useState("");
const [activeConversationId, setActiveConversationId] = useState("");
const isLoadingSessions = isLoadingSidebar;
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null);
@ -181,14 +184,14 @@ const useSuperAgentChat = ({
const findSessionTypeById = (sessionId) => {
if (!sessionId) return "";
const matchedTask = taskList.find((task) => {
const taskId = task.id || task.chatId;
return taskId === sessionId;
const ids = [task.id, task.chatId, task.conversationId, task.threadId, task.thread_id];
return ids.includes(sessionId);
});
if (matchedTask) return "task";
const matchedChat = ordinaryChatList.find((chat) => {
const chatId = chat.id || chat.chatId;
return chatId === sessionId;
const ids = [chat.id, chat.chatId, chat.conversationId, chat.threadId, chat.thread_id];
return ids.includes(sessionId);
});
if (matchedChat) return "conversation";
@ -373,9 +376,14 @@ const useSuperAgentChat = ({
};
const clearActiveSidebarSession = (sessionId, shouldForce = false) => {
if (!shouldForce && activeSessionId !== sessionId) return;
if (
!shouldForce &&
activeSessionId !== sessionId &&
activeConversationId !== sessionId
) return;
clearActiveSessionId(sessionId);
setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId("");
setTaskStage(TASK_STAGE.IDLE);
setConversationMessages([]);
@ -397,18 +405,31 @@ const useSuperAgentChat = ({
}
const sessionType = sessionKind || (isTaskSession ? "task" : "conversation");
const requestBody = { type: sessionType, reuse_empty: true };
const apiFolderId = resolveApiFolderId(folderId);
if (apiFolderId) requestBody.folder_id = apiFolderId;
if (title) requestBody.title = title;
const requestBody = { type: sessionType };
let apiFolderId = resolveApiFolderId(folderId);
if (sessionType === "task" && !apiFolderId) {
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({
query: { userId: currentUserId },
body: requestBody,
})).then((res) => ensureActionSuccess(res, "创建超级智能体会话失败"));
const sessionData = session?.data || session;
const nextSessionId = sessionData?.session_id || sessionData?.id;
if (!nextSessionId) throw new Error("创建会话失败,后端未返回 session_id");
const nextSessionId = sessionData?.id || sessionData?.conversation_id;
const nextThreadId = sessionData?.thread_id || sessionData?.threadId || "";
if (!nextSessionId) throw new Error("创建会话失败,后端未返回 conversation id");
if (!nextThreadId) throw new Error("创建会话失败,后端未返回 thread_id");
const returnedType =
sessionData?.session_kind ||
@ -431,8 +452,9 @@ const useSuperAgentChat = ({
message.info(tipText);
}
setActiveSessionId(nextSessionId);
setActiveSessionId(nextThreadId);
saveActiveSessionId(nextSessionId);
setActiveConversationId(nextSessionId);
setWorkspaceOpen(false);
setUploadedFileList([]);
markPendingEmptySession(nextSessionId, returnedType, apiFolderId);
@ -1328,6 +1350,7 @@ const useSuperAgentChat = ({
setRouteType(nextRouteType);
setWorkspaceOpen(false);
setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId("");
clearActiveSessionId();
setConversationMessages([]);
@ -1471,6 +1494,7 @@ const useSuperAgentChat = ({
setRouteType("agents");
setWorkspaceOpen(false);
setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId("");
clearActiveSessionId();
};
@ -1479,6 +1503,7 @@ const useSuperAgentChat = ({
setRouteType("assets");
setWorkspaceOpen(false);
setActiveSessionId("");
setActiveConversationId("");
setActiveTaskId("");
clearActiveSessionId();
};
@ -1751,10 +1776,12 @@ const useSuperAgentChat = ({
};
const handleSelectTask = async (task) => {
activeSessionIdRef.current = task.id;
setActiveTaskId(task.id);
setActiveSessionId(task.id);
saveActiveSessionId(task.id);
activeSessionIdRef.current = task.threadId || task.thread_id || task.id;
setActiveTaskId(task.id || task.conversationId);
const nextThreadId = task.threadId || task.thread_id || task.id;
setActiveSessionId(nextThreadId);
saveActiveSessionId(task.id || task.conversationId);
setActiveConversationId(task.id || task.conversationId);
setRouteType("task");
setTaskStage(task.stage || TASK_STAGE.IDLE);
setWorkspaceOpen(false);
@ -1767,10 +1794,12 @@ const useSuperAgentChat = ({
};
const handleSelectChat = async (chat) => {
const sessionId = chat.id || chat.chatId;
activeSessionIdRef.current = sessionId;
setActiveSessionId(sessionId);
const sessionId = chat.id || chat.chatId || chat.conversationId;
const nextThreadId = chat.threadId || chat.thread_id || sessionId;
activeSessionIdRef.current = nextThreadId;
setActiveSessionId(nextThreadId);
saveActiveSessionId(sessionId);
setActiveConversationId(sessionId);
setActiveTaskId("");
setRouteType("chat");
setTaskStage(TASK_STAGE.IDLE);
@ -1824,6 +1853,7 @@ const useSuperAgentChat = ({
sidebarTree,
collapsedFolderMap,
activeSessionId,
activeConversationId,
isLoadingSessions,
isUploadingFile,
isStreamingAnswer,

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

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

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

@ -12,7 +12,7 @@
*
* 维护说明:
* - 当前任务合集由后端 session 加本地 tender session 标记共同识别
* - 任务侧栏展示时 folder_id=null 归入虚拟默认文件夹(不改库)
* - 任务侧栏始终展示固定「默认任务集」;后端第一条同名任务集会绑定到该槽位
*/
"use strict";
@ -215,12 +215,16 @@ export const isTenderSession = (item = {}) => {
};
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 isTask = sessionKind === "task";
const tasksetId = item.taskset_id || item.tasksetId || item.folder_id || item.folderId || null;
return {
id: sessionId,
chatId: sessionId,
id: conversationId,
chatId: conversationId,
conversationId,
threadId,
title: normalizeSessionTitle(item.title || item.name || item.summary, isTask ? "未命名任务" : "新对话"),
time: item.updated_at || item.updateTime || item.updatedAt || item.created_at || item.createdAt || Date.now(),
sessionKind,
@ -228,13 +232,46 @@ export const normalizeSidebarSession = (item = {}, realm = "conversation") => {
taskType: item.task_type || item.taskType || "",
status: item.status || "",
isTask,
folderId: item.folder_id || item.folderId || null,
folderId: tasksetId,
tasksetId,
sortOrder: Number(item.sort_order ?? item.sortOrder ?? 0),
hasActiveTask: Boolean(item.has_active_task ?? item.hasActiveTask),
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") => ({
id: folder.id || folder.folder_id || "",
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) => {
if (!folderId || isVirtualDefaultFolder(folderId)) return null;
return folderId;
if (!folderId) return null;
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 = {}) => {
const rootSessions = Array.isArray(realmItem.sessions) ? realmItem.sessions : [];
const defaultFolderSessions = (realmItem.folders || [])
.filter((folder) => isVirtualDefaultFolder(folder))
.flatMap((folder) => folder.sessions || []);
const realFolders = (realmItem.folders || []).filter(
(folder) => !isVirtualDefaultFolder(folder)
const incomingFolders = Array.isArray(realmItem.folders) ? realmItem.folders : [];
const placeholderFolders = incomingFolders.filter((folder) =>
isPlaceholderDefaultFolderId(folder?.id)
);
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 = {
id: DEFAULT_TASK_FOLDER_ID,
id: boundDefaultFolder?.id || DEFAULT_TASK_FOLDER_ID,
name: DEFAULT_TASK_FOLDER_NAME,
sortOrder: -1000,
updatedAt: "",
updatedAt: boundDefaultFolder?.updatedAt || "",
realm: "task",
isVirtualDefault: true,
sessions: [...defaultFolderSessions, ...rootSessions],
sessions: [
...placeholderFolders.flatMap((folder) => folder.sessions || []),
...boundDefaultFolders.flatMap((folder) => folder.sessions || []),
...rootSessions,
],
};
return {
...realmItem,
realm: "task",
folders: [virtualDefaultFolder, ...realFolders],
folders: [virtualDefaultFolder, ...otherFolders],
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