Browse Source

merge: 合并新代码

master
Senor-Liu 7 hours ago
parent
commit
82fcc433c9
  1. 13
      api/package-lock.json
  2. 2
      web/client/src/sections/aichat/router.js
  3. 16
      web/client/src/sections/superAgent/actions/api.js
  4. 54
      web/client/src/sections/superAgent/components/SuperAgentConversation.jsx
  5. 5
      web/client/src/sections/superAgent/components/TaskProgressCard.jsx
  6. 2
      web/client/src/sections/superAgent/constants.js
  7. 804
      web/client/src/sections/superAgent/container/index.jsx
  8. 251
      web/client/src/sections/superAgent/hooks/useSuperAgentChat.js
  9. 7
      web/client/src/sections/superAgent/hooks/useSuperAgentSession.js
  10. 745
      web/client/src/sections/superAgent/hooks/useSuperAgentStream.js
  11. 2
      web/client/src/sections/superAgent/router.js
  12. 6
      web/client/src/sections/superAgent/utils/streamMessage.js
  13. 3781
      web/package-lock.json

13
api/package-lock.json

@ -15,6 +15,7 @@
"@modelcontextprotocol/sdk": "^1.17.2", "@modelcontextprotocol/sdk": "^1.17.2",
"@peace/node_server": "^2.0.1", "@peace/node_server": "^2.0.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"dotenv": "^17.4.2",
"html-docx-js": "^0.3.1", "html-docx-js": "^0.3.1",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"image-size": "^2.0.2", "image-size": "^2.0.2",
@ -1614,6 +1615,18 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dottie": { "node_modules/dottie": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://nexus.ngaiot.com/repository/fs-npm/dottie/-/dottie-2.0.6.tgz", "resolved": "https://nexus.ngaiot.com/repository/fs-npm/dottie/-/dottie-2.0.6.tgz",

2
web/client/src/sections/aichat/router.js

@ -4,7 +4,7 @@ export default {
type: 'outer', type: 'outer',
route: { route: {
key: 'aichat', key: 'aichat',
path: "/aichat", path: "/aichatv1",
component: AiChat component: AiChat
} }
}; };

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

@ -1,7 +1,7 @@
/** /**
* 文件作用superAgent 超级智能体模块文件 * 文件作用superAgent 超级智能体模块文件
* 职责范围 * 职责范围
* 1. 承载 superAgent 业务 sessionworkspace附件以及保留的旧聊天能力 * 1. 承载 superAgent 业务 sessionworkspace 附件能力
* 2. 在不影响 newAiChat 的前提下复用现有组件和接口 * 2. 在不影响 newAiChat 的前提下复用现有组件和接口
* 3. 支撑标书任务工作区的前端编排 * 3. 支撑标书任务工作区的前端编排
* *
@ -11,7 +11,7 @@
* - 封装 v2 useStream 协议新版流由 useSuperAgentStream 直接交给 SDK 管理 * - 封装 v2 useStream 协议新版流由 useSuperAgentStream 直接交给 SDK 管理
* *
* 维护说明 * 维护说明
* - sendChatcheckpoint 和旧 history action 为回滚能力不得由新版容器调用 * - 不负责消息流thread 快照或 interrupt 恢复这些能力统一由 useStream 管理
*/ */
"use strict"; "use strict";
@ -84,18 +84,6 @@ export const createSuperAgentSession = (options = {}) => async () => {
return { payload: { data } }; return { payload: { data } };
}; };
export const getSuperAgentSessionHistory = createDirectJsonAction({
method: "GET",
path: superAgentServiceApiTable.sessionHistory,
fallbackMessage: "获取超级智能体历史消息失败",
});
export const getSuperAgentSessionState = createDirectJsonAction({
method: "GET",
path: superAgentServiceApiTable.sessionState,
fallbackMessage: "获取超级智能体会话状态失败",
});
export const getSuperAgentSessionSections = createDirectJsonAction({ export const getSuperAgentSessionSections = createDirectJsonAction({
method: "GET", method: "GET",
path: superAgentServiceApiTable.sessionSections, path: superAgentServiceApiTable.sessionSections,

54
web/client/src/sections/superAgent/components/SuperAgentConversation.jsx

@ -213,6 +213,7 @@ const SuperAgentConversation = ({
chatWelcomeContent, chatWelcomeContent,
taskWelcomeTitle, taskWelcomeTitle,
taskWelcomeContent, taskWelcomeContent,
isWaitingForInterrupt = false,
agentCategory, agentCategory,
setAgentCategory, setAgentCategory,
agentKeyword, agentKeyword,
@ -535,12 +536,22 @@ const SuperAgentConversation = ({
const checkpoint = normalizeCheckpoint(item); const checkpoint = normalizeCheckpoint(item);
const progressTask = latestProgressTask || item.task; const progressTask = latestProgressTask || item.task;
if (isBodyProgressCheckpoint(item, progressTask)) return null; //[LangGraph interrupt suspended ]
if (isStaleCatalogCheckpoint(item, progressTask)) return null; const checkpointProgressTask =
isWaitingForInterrupt &&
String(progressTask?.status || "").toLowerCase() === "suspended"
? {
...progressTask,
status: "waiting_interrupt",
session_status: "waiting_interrupt",
}
: progressTask;
if (isBodyProgressCheckpoint(item, checkpointProgressTask)) return null;
if (isStaleCatalogCheckpoint(item, checkpointProgressTask)) return null;
return ( return (
<CheckpointHost <CheckpointHost
checkpoint={checkpoint} checkpoint={checkpoint}
progressTask={progressTask} progressTask={checkpointProgressTask}
submitting={item.checkpointStatus === "submitting"} submitting={item.checkpointStatus === "submitting"}
onAction={(action) => handleCheckpointAction(item, action)} onAction={(action) => handleCheckpointAction(item, action)}
/> />
@ -629,7 +640,8 @@ const SuperAgentConversation = ({
(Array.isArray(item.runLog?.entries) && (Array.isArray(item.runLog?.entries) &&
item.runLog.entries.some((entry) => entry?.status === "running")); item.runLog.entries.some((entry) => entry?.status === "running"));
const shouldShowActiveLoading = const shouldShowActiveLoading =
!hasStructuredCard && (showLoadingIndicator || hasRunningTool); !hasStructuredCard &&
(showLoadingIndicator || (isStreamingAnswer && hasRunningTool));
const shouldShowLoading = const shouldShowLoading =
!hasStructuredCard && !hasStructuredCard &&
!item.content && !item.content &&
@ -686,9 +698,20 @@ const SuperAgentConversation = ({
const renderConversationMessages = () => { const renderConversationMessages = () => {
const messageNodeList = []; const messageNodeList = [];
const displayConversationMessages = mergeDisplayConversationMessages(conversationMessages); const displayConversationMessages = mergeDisplayConversationMessages(conversationMessages);
const latestAssistantIndex = [...displayConversationMessages] const latestAssistantIndex = displayConversationMessages.reduce(
.map((item) => item.role) (latestIndex, item, index) => {
.lastIndexOf("assistant"); const isRenderableAssistantMessage =
item.role === "assistant" &&
!["taskProgress", "checkpoint", "content"].includes(item.type);
return isRenderableAssistantMessage ? index : latestIndex;
},
-1
);
const lastConversationMessage =
displayConversationMessages[displayConversationMessages.length - 1];
const hasTrailingLoadingMessage =
isStreamingAnswer &&
(lastConversationMessage?.role === "user" || latestAssistantIndex < 0);
let messageIndex = 0; let messageIndex = 0;
while (messageIndex < displayConversationMessages.length) { while (messageIndex < displayConversationMessages.length) {
@ -701,12 +724,27 @@ const SuperAgentConversation = ({
messageNodeList.push( messageNodeList.push(
renderSingleConversationMessage(currentMessage, { renderSingleConversationMessage(currentMessage, {
showLoadingIndicator: showLoadingIndicator:
isStreamingAnswer && messageIndex === latestAssistantIndex, isStreamingAnswer &&
!hasTrailingLoadingMessage &&
messageIndex === latestAssistantIndex,
}) })
); );
messageIndex += 1; messageIndex += 1;
} }
if (hasTrailingLoadingMessage) {
messageNodeList.push(
renderSingleConversationMessage(
{
key: "stream-loading-placeholder",
role: "assistant",
content: "",
},
{ showLoadingIndicator: true }
)
);
}
return messageNodeList; return messageNodeList;
}; };

5
web/client/src/sections/superAgent/components/TaskProgressCard.jsx

@ -18,6 +18,7 @@ import React from "react";
import { Button } from "antd"; import { Button } from "antd";
import { import {
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined,
DownloadOutlined, DownloadOutlined,
FileWordOutlined, FileWordOutlined,
LoadingOutlined, LoadingOutlined,
@ -61,7 +62,7 @@ const TaskProgressCard = ({ item = {}, onOpenWorkspace, onDownloadFile }) => {
? "已生成" ? "已生成"
: "正在生成"; : "正在生成";
const statusIcon = isFailed const statusIcon = isFailed
? null ? <CloseCircleOutlined />
: isSuspended : isSuspended
? <PauseCircleOutlined /> ? <PauseCircleOutlined />
: isCompleted : isCompleted
@ -96,7 +97,7 @@ const TaskProgressCard = ({ item = {}, onOpenWorkspace, onDownloadFile }) => {
<div className="super-body-artifact-title" title={fileName}> <div className="super-body-artifact-title" title={fileName}>
{fileName} {fileName}
</div> </div>
<div className={`super-body-artifact-status is-${isCompleted ? "completed" : isFailed ? "failed" : isSuspended ? "suspended" : "running"}`}> <div className={`super-body-artifact-status is-${isFailed ? "failed" : isSuspended ? "suspended" : isCompleted ? "completed" : "running"}`}>
{statusIcon} {statusIcon}
<span>{statusText}</span> <span>{statusText}</span>
</div> </div>

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

@ -192,7 +192,7 @@ export const quickTaskList = [
key: "legal", key: "legal",
title: "法务评估", title: "法务评估",
desc: "归集项目和材料,生成评估报告和起诉状。", desc: "归集项目和材料,生成评估报告和起诉状。",
enabled: true, enabled: false,
icon: KnowledgeIcon, icon: KnowledgeIcon,
}, },
{ {

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

File diff suppressed because it is too large

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

@ -21,7 +21,6 @@ import {
cancelSuperAgentSession, cancelSuperAgentSession,
createSuperAgentSession, createSuperAgentSession,
deleteSuperAgentSession, deleteSuperAgentSession,
getSuperAgentSessionHistory,
respondSuperAgentCheckpointStream, respondSuperAgentCheckpointStream,
sendSuperAgentChat, sendSuperAgentChat,
uploadSuperAgentFile, uploadSuperAgentFile,
@ -46,7 +45,6 @@ import {
isSuperAgentRunLogTool, isSuperAgentRunLogTool,
markTenderSessionId, markTenderSessionId,
markPendingEmptySession, markPendingEmptySession,
normalizeHistoryMessages,
removeTenderSessionId, removeTenderSessionId,
resolveApiFolderId, resolveApiFolderId,
saveActiveSessionId, saveActiveSessionId,
@ -117,7 +115,6 @@ const useSuperAgentChat = ({
setWorkspaceOpen, setWorkspaceOpen,
actions, actions,
dispatch, dispatch,
protocolMode = "legacy",
}) => { }) => {
const [routeType, setRouteType] = useState("chat"); const [routeType, setRouteType] = useState("chat");
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
@ -158,7 +155,6 @@ const useSuperAgentChat = ({
const chatRunActiveRef = useRef(false); const chatRunActiveRef = useRef(false);
const creatingSessionRef = useRef(false); const creatingSessionRef = useRef(false);
const sidebarRefreshTimerRef = useRef(null); const sidebarRefreshTimerRef = useRef(null);
const historyRefreshTimerRef = useRef(null);
const artifactProgressSessionRef = useRef(""); const artifactProgressSessionRef = useRef("");
const taskProgressUpsertAtRef = useRef(0); const taskProgressUpsertAtRef = useRef(0);
const taskProgressPendingRef = useRef(null); const taskProgressPendingRef = useRef(null);
@ -287,24 +283,6 @@ const useSuperAgentChat = ({
} }
setConversationMessages((prev) => { setConversationMessages((prev) => {
if (messageData.type === "content") {
const hasCurrentContentEntry = prev.some(
(item) => item.key === messageKey && item.type === "content"
);
const hasHistoryContentWorkspace = prev.some((item) => {
const checkpoint = item?.checkpoint?.checkpoint || item?.checkpoint || {};
const checkpointType = checkpoint.checkpoint_type || checkpoint.type || "";
const title = checkpoint.title || item.title || "";
return (
item.type === "checkpoint" &&
(checkpointType === "content_edit" ||
checkpointType === "progress_view" ||
title === "正文工作区")
);
});
if (!hasCurrentContentEntry && hasHistoryContentWorkspace) return prev;
}
const hasMessage = prev.some((item) => item.key === messageKey); const hasMessage = prev.some((item) => item.key === messageKey);
if (!hasMessage) { if (!hasMessage) {
return [ return [
@ -371,147 +349,6 @@ const useSuperAgentChat = ({
setConversationMessages((prev) => prev.filter((item) => item.key !== messageKey)); setConversationMessages((prev) => prev.filter((item) => item.key !== messageKey));
}; };
//[生成稳定签名,用于轮询 history 时判断消息是否真的变化]
const buildConversationMessageSignature = (item = {}) => {
const runLogEntries = Array.isArray(item.runLog?.entries)
? item.runLog.entries.map((entry) => ({
id: entry.id || "",
name: entry.name || "",
kind: entry.kind || "",
status: entry.status || "",
arguments: entry.arguments || "",
result: entry.result || "",
}))
: [];
return JSON.stringify({
key: item.key || "",
role: item.role || "",
type: item.type || "",
title: item.title || "",
content: item.content || "",
loadingText: item.loadingText || "",
checkpointStatus: item.checkpointStatus || "",
checkpoint: item.checkpoint || null,
task: item.task || null,
files: item.files || [],
runLogEntries,
});
};
//[按 key 做 history diff,未变化的消息沿用原对象,减少轮询导致的重绘]
const mergeHistoryMessagesByDiff = (currentMessages = [], historyMessages = []) => {
const currentMap = new Map(currentMessages.map((item) => [item.key, item]));
const historyKeySet = new Set(historyMessages.map((item) => item.key));
const shouldPreserveLocalRuntimeMessage = (item = {}) => {
if (!item.key || historyKeySet.has(item.key)) return false;
if (item.type === "taskProgress") return true;
return String(item.key).startsWith("session_running_");
};
const nextHistoryMessages = historyMessages.map((historyItem) => {
const currentItem = currentMap.get(historyItem.key);
if (
currentItem &&
buildConversationMessageSignature(currentItem) ===
buildConversationMessageSignature(historyItem)
) {
return currentItem;
}
return historyItem;
});
const localRuntimeMessages = currentMessages.filter(shouldPreserveLocalRuntimeMessage);
const nextMessages = [...nextHistoryMessages, ...localRuntimeMessages];
const currentSignature = currentMessages.map(buildConversationMessageSignature).join("\n");
const nextSignature = nextMessages.map(buildConversationMessageSignature).join("\n");
if (currentSignature === nextSignature) return currentMessages;
return nextMessages;
};
/**
* 功能重新读取当前会话 history并用持久化消息更新对话区
* 使用场景正文生成完成后恢复后端写入的正文工作区 checkpoint
*
* 入参sessionId 当前正文任务会话 ID
* 返回是否成功更新对话消息
*
* 注意 history 或用户已切换会话时保留当前消息避免异步响应覆盖其他会话
*/
const refreshSessionHistoryMessages = useCallback(async (
sessionId,
{ requireBodyWorkspace = false } = {}
) => {
if (!sessionId || activeSessionIdRef.current !== sessionId) return false;
try {
const data = await dispatch(getSuperAgentSessionHistory({
params: buildSessionParams(sessionId),
query: { userId: currentUserId },
})).then((res) => ensureActionSuccess(res, "获取任务历史失败"));
const historyData = data?.data || data || {};
const historyMessages = normalizeHistoryMessages(
historyData?.messages || [],
sessionId
);
if (
!historyMessages.length ||
activeSessionIdRef.current !== sessionId
) {
return false;
}
const hasBodyWorkspace = historyMessages.some((item) => {
const checkpoint = item?.checkpoint?.checkpoint || item?.checkpoint || {};
const checkpointType = checkpoint.checkpoint_type || checkpoint.type || "";
const title = checkpoint.title || item.title || "";
return (
checkpointType === "content_edit" ||
checkpointType === "progress_view" ||
title === "正文工作区"
);
});
if (requireBodyWorkspace && !hasBodyWorkspace) return false;
setConversationMessages((prev) => mergeHistoryMessagesByDiff(prev, historyMessages));
return true;
} catch (error) {
superAgentWarn("chat.history", "refresh_after_artifact_failed", {
sessionId,
error,
});
return false;
}
}, [actions, currentUserId, dispatch, protocolMode]);
//[正文完成后以 SSE 生命周期为准刷新 history,不依赖可能被 checkpoint 替换的进度卡]
const scheduleArtifactHistoryRefresh = useCallback((sessionId, attempt = 0) => {
if (!sessionId || activeSessionIdRef.current !== sessionId) return;
if (historyRefreshTimerRef.current) {
window.clearTimeout(historyRefreshTimerRef.current);
}
historyRefreshTimerRef.current = window.setTimeout(async () => {
historyRefreshTimerRef.current = null;
superAgentLog("chat.history", "refresh_after_artifact_start", {
sessionId,
attempt,
});
const refreshed = await refreshSessionHistoryMessages(sessionId, {
requireBodyWorkspace: true,
});
if (refreshed) {
superAgentLog("chat.history", "refresh_after_artifact_done", {
sessionId,
attempt,
});
return;
}
if (attempt < 2 && activeSessionIdRef.current === sessionId) {
scheduleArtifactHistoryRefresh(sessionId, attempt + 1);
}
}, attempt === 0 ? 800 : 1200);
}, [refreshSessionHistoryMessages]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (chatAbortControllerRef.current) { if (chatAbortControllerRef.current) {
@ -520,9 +357,6 @@ const useSuperAgentChat = ({
if (sidebarRefreshTimerRef.current) { if (sidebarRefreshTimerRef.current) {
window.clearTimeout(sidebarRefreshTimerRef.current); window.clearTimeout(sidebarRefreshTimerRef.current);
} }
if (historyRefreshTimerRef.current) {
window.clearTimeout(historyRefreshTimerRef.current);
}
if (taskProgressTimerRef.current) { if (taskProgressTimerRef.current) {
window.clearTimeout(taskProgressTimerRef.current); window.clearTimeout(taskProgressTimerRef.current);
} }
@ -914,7 +748,6 @@ const useSuperAgentChat = ({
artifactProgressSessionRef.current = options.sessionId; artifactProgressSessionRef.current = options.sessionId;
} }
if (isArtifactCompleted) { if (isArtifactCompleted) {
scheduleArtifactHistoryRefresh(options.sessionId);
} }
updateConversationMessage(assistantMessageKey, (item) => ({ updateConversationMessage(assistantMessageKey, (item) => ({
title: progress >= 100 ? "正文生成完成" : `正在生成:${sectionTitle}`, title: progress >= 100 ? "正文生成完成" : `正在生成:${sectionTitle}`,
@ -1056,7 +889,6 @@ const useSuperAgentChat = ({
if (eventName === "done") { if (eventName === "done") {
if (artifactProgressSessionRef.current === options.sessionId) { if (artifactProgressSessionRef.current === options.sessionId) {
artifactProgressSessionRef.current = ""; artifactProgressSessionRef.current = "";
scheduleArtifactHistoryRefresh(options.sessionId);
} }
clearWaitingNoticeMessages(); clearWaitingNoticeMessages();
updateConversationMessage(assistantMessageKey, (item) => { updateConversationMessage(assistantMessageKey, (item) => {
@ -1226,10 +1058,9 @@ const useSuperAgentChat = ({
}, },
}); });
//[部分服务不会发送 done 事件;流结束后仍需按本轮 artifact 进度刷新 history] //[部分服务不会发送 done 事件;本轮进度仍由 useStream 业务事件维护]
if (artifactProgressSessionRef.current === sessionId) { if (artifactProgressSessionRef.current === sessionId) {
artifactProgressSessionRef.current = ""; artifactProgressSessionRef.current = "";
scheduleArtifactHistoryRefresh(sessionId);
} }
superAgentLog("chat.send", "run.done", { superAgentLog("chat.send", "run.done", {
@ -1406,7 +1237,6 @@ const useSuperAgentChat = ({
if (artifactProgressSessionRef.current === sessionId) { if (artifactProgressSessionRef.current === sessionId) {
artifactProgressSessionRef.current = ""; artifactProgressSessionRef.current = "";
scheduleArtifactHistoryRefresh(sessionId);
} }
superAgentLog("chat.checkpoint", "respond.done", { superAgentLog("chat.checkpoint", "respond.done", {
@ -1915,8 +1745,6 @@ const useSuperAgentChat = ({
}; };
const handleSelectTask = async (task) => { const handleSelectTask = async (task) => {
//[切换会话时中止当前SSE并重置流式状态,避免running状态泄漏到新会话]
handleStopAnswer();
activeSessionIdRef.current = task.id; activeSessionIdRef.current = task.id;
setActiveTaskId(task.id); setActiveTaskId(task.id);
setActiveSessionId(task.id); setActiveSessionId(task.id);
@ -1927,51 +1755,12 @@ const useSuperAgentChat = ({
setUploadedFileList([]); setUploadedFileList([]);
setSelectedTaskType(null); setSelectedTaskType(null);
setConversationMessages([]); setConversationMessages([]);
if (protocolMode === "v2") { //[useStream 是唯一会话恢复来源,stream.messages 负责在当前 thread 完成快照加载后还原消息]
return { success: true, isEmptyHistory: false }; setConversationMessages([buildEmptyTaskStartMessage()]);
} return { success: true };
try {
const data = await dispatch(getSuperAgentSessionHistory({
params: buildSessionParams(task.id),
query: { userId: currentUserId },
})).then((res) => ensureActionSuccess(res, "获取任务历史失败"));
//[陈旧会话检查:异步操作完成后确认当前激活的会话ID未改变]
if (activeSessionIdRef.current !== task.id) {
return { success: false, isEmptyHistory: false, reason: "stale_session" };
}
const historyData = data?.data || data || {};
const rawHistoryMessages = Array.isArray(historyData?.messages)
? historyData.messages
: [];
const historyMessages = normalizeHistoryMessages(
rawHistoryMessages,
task.id
);
//[空任务会话:展示任务大厅,而非直接进入标书引导]
if (!rawHistoryMessages.length) {
setConversationMessages([buildEmptyTaskStartMessage()]);
return { success: true, isEmptyHistory: true };
} else {
setSelectedTaskType("tender");
setConversationMessages(historyMessages);
}
return { success: true, isEmptyHistory: false };
} catch (error) {
const isSessionMissing = /404|not found|不存在/i.test(String(error?.message || ""));
if (isSessionMissing) {
removeSidebarSessionLocally(task.id, "task");
clearActiveSidebarSession(task.id, true);
message.warning("该任务记录已不存在,已从列表移除");
return { success: false, isEmptyHistory: false };
}
message.error(error?.message || "获取任务历史失败");
return { success: false, isEmptyHistory: false };
}
}; };
const handleSelectChat = async (chat) => { const handleSelectChat = async (chat) => {
//[切换会话时中止当前SSE并重置流式状态,避免running状态泄漏到新会话]
handleStopAnswer();
const sessionId = chat.id || chat.chatId; const sessionId = chat.id || chat.chatId;
activeSessionIdRef.current = sessionId; activeSessionIdRef.current = sessionId;
setActiveSessionId(sessionId); setActiveSessionId(sessionId);
@ -1983,35 +1772,8 @@ const useSuperAgentChat = ({
setUploadedFileList([]); setUploadedFileList([]);
setSelectedTaskType(null); setSelectedTaskType(null);
setConversationMessages([]); setConversationMessages([]);
if (protocolMode === "v2") { //[useStream 是唯一会话恢复来源,stream.messages 负责在当前 thread 完成快照加载后还原消息]
return { success: true, isEmptyHistory: false }; return { success: true };
}
try {
const data = await dispatch(getSuperAgentSessionHistory({
params: buildSessionParams(sessionId),
query: { userId: currentUserId },
})).then((res) => ensureActionSuccess(res, "获取对话历史失败"));
//[陈旧会话检查:异步操作完成后确认当前激活的会话ID未改变]
if (activeSessionIdRef.current !== sessionId) {
return { success: false, isEmptyHistory: false, reason: "stale_session" };
}
const historyData = data?.data || data || {};
const rawHistoryMessages = Array.isArray(historyData?.messages)
? historyData.messages
: [];
setConversationMessages(normalizeHistoryMessages(rawHistoryMessages, sessionId));
return { success: true, isEmptyHistory: rawHistoryMessages.length === 0 };
} catch (error) {
const isSessionMissing = /404|not found|不存在/i.test(String(error?.message || ""));
if (isSessionMissing) {
removeSidebarSessionLocally(sessionId, "chat");
clearActiveSidebarSession(sessionId, true);
message.warning("该对话记录已不存在,已从列表移除");
return { success: false, isEmptyHistory: false };
}
message.error(error?.message || "获取对话历史失败");
return { success: false, isEmptyHistory: false };
}
}; };
const handleDeleteSidebarSession = async (item, itemType) => { const handleDeleteSidebarSession = async (item, itemType) => {
@ -2076,7 +1838,6 @@ const useSuperAgentChat = ({
updateActiveTask, updateActiveTask,
updateConversationMessage, updateConversationMessage,
removeConversationMessage, removeConversationMessage,
refreshSessionHistoryMessages,
createAndActivateSession, createAndActivateSession,
sendMessageToAgent, sendMessageToAgent,
respondCheckpointToAgent, respondCheckpointToAgent,

7
web/client/src/sections/superAgent/hooks/useSuperAgentSession.js

@ -4,21 +4,20 @@
* *
* 职责 * 职责
* 1. 复用成熟的 session侧栏文件上传和业务页面状态 * 1. 复用成熟的 session侧栏文件上传和业务页面状态
* 2. 固定启用 v2 模式禁止旧 history 在任务打开切换和刷新时发起请求 * 2. 会话切换只设置 useStream active thread不主动读取消息或任务快照
* *
* 不负责 * 不负责
* 1. 消息流interrupttool call停止运行或 SSE 生命周期 * 1. 消息流interrupttool call停止运行或 SSE 生命周期
* *
* 维护说明 * 维护说明
* v2 协议能力统一由 useSuperAgentStream 承担此适配层不可暴露旧发送和 checkpoint 方法给容器 * useStream 协议能力统一由 useSuperAgentStream 承担此适配层只保留 session/sidebar 能力
*/ */
"use strict"; "use strict";
import useSuperAgentChat from "./useSuperAgentChat"; import useSuperAgentChat from "./useSuperAgentChat";
const useSuperAgentSession = (options) => { const useSuperAgentSession = (options) => {
const legacySession = useSuperAgentChat({ ...options, protocolMode: "v2" }); const session = { ...useSuperAgentChat(options) };
const session = { ...legacySession };
[ [
"sendMessageToAgent", "sendMessageToAgent",
"respondCheckpointToAgent", "respondCheckpointToAgent",

745
web/client/src/sections/superAgent/hooks/useSuperAgentStream.js

@ -8,8 +8,8 @@
* 3. 订阅业务 custom channel并通知容器刷新 workspace/sections * 3. 订阅业务 custom channel并通知容器刷新 workspace/sections
* *
* 不负责 * 不负责
* 1. 创建业务 session加载 workspace/sections 兼容旧聊天协议 * 1. 创建业务 session加载 workspace/sections 管理业务会话
* 2. 手写 SSE调用旧 history/checkpoint 或管理业务会话 * 2. 手写 SSE调用旧聊天协议或维护独立恢复接口
* *
* 维护说明 * 维护说明
* Agent v2 不可用时只暴露错误调用方不得回退 /api/chat runs/stream * Agent v2 不可用时只暴露错误调用方不得回退 /api/chat runs/stream
@ -43,6 +43,21 @@ const TERMINAL_INTERRUPT_STATUSES = new Set([
"canceled", "canceled",
]); ]);
const ACTIVE_TASK_STATUSES = new Set([
"queued",
"starting",
"started",
"running",
"pending",
"processing",
"generating",
"writing",
"active",
"streaming",
"resuming",
"awaiting_interrupt",
]);
const getInterruptStatus = (item = {}) => { const getInterruptStatus = (item = {}) => {
const payload = item?.value || item?.payload || {}; const payload = item?.value || item?.payload || {};
return String( return String(
@ -63,6 +78,11 @@ const isSuperAgentEventRequest = (input) => {
return /\/threads\/[^/]+\/stream\/events(?:\?|$)/.test(requestUrl); return /\/threads\/[^/]+\/stream\/events(?:\?|$)/.test(requestUrl);
}; };
const isUseStreamHistoryDiscoveryRequest = (input) => {
const requestUrl = String(input?.url || input || "");
return /\/(?:threads|tasks)\/[^/]+\/history(?:\?|$)/.test(requestUrl);
};
const isLifecycleWatcherRequest = (input, init = {}) => { const isLifecycleWatcherRequest = (input, init = {}) => {
if (!isSuperAgentEventRequest(input) || typeof init.body !== "string") { if (!isSuperAgentEventRequest(input) || typeof init.body !== "string") {
return false; return false;
@ -182,14 +202,32 @@ const fetchEventStreamWithRecovery = async (input, init) => {
class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter { class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter {
activeEventStream = null; activeEventStream = null;
eventHandles = new Set(); eventHandles = new Set();
latestState = null;
onStateChange = null;
stateRequestVersion = 0;
async getState() {
const requestVersion = ++this.stateRequestVersion;
const requestedThreadId = String(this.threadId || "").trim();
const state = await super.getState();
// getState 可能在切换 thread 后才返回;旧请求不能覆盖新 thread 的快照。
if (
requestVersion === this.stateRequestVersion &&
String(this.threadId || "").trim() === requestedThreadId
) {
this.latestState = state;
this.onStateChange?.();
}
return state;
}
openEventStream(params) { openEventStream(params) {
const isLifecycleWatcher = isLifecycleWatcherFilter(params); const isLifecycleWatcher = isLifecycleWatcherFilter(params);
const threadId = String(this.threadId || "").trim(); const threadId = String(this.threadId || "").trim();
if (isLifecycleWatcher) { if (isLifecycleWatcher) {
// fetchWithHistoryCompatibility 会把这个 watcher 转成本地空流, // useStream 的 root pump 已经覆盖 lifecycle/input,额外 watcher
// 不要让它参与主事件流的连接排他和释放等待。 // 不参与主事件流的连接排他和释放等待。
return super.openEventStream(params); return super.openEventStream(params);
} }
@ -310,131 +348,6 @@ const getEventPayload = (event) =>
event?.params?.data ?? event?.params?.data ??
event; event;
//[兼容历史消息]旧会话中的 tool 消息可能缺少 tool_call_id,LangChain 无法将其转换为 ToolMessage。
const isInvalidSerializedToolMessage = (message) =>
message?.type === "tool" && !message.tool_call_id && !message.toolCallId;
//[兼容历史消息]仅清理无法被 SDK 转换的 tool 消息,保留其他状态字段和可用消息。
const sanitizeLangGraphPayload = (value, parentKey = "") => {
if (Array.isArray(value)) {
return value
.filter((item) => parentKey !== "messages" || !isInvalidSerializedToolMessage(item))
.map((item) => sanitizeLangGraphPayload(item, parentKey));
}
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, sanitizeLangGraphPayload(item, key)])
);
};
//[保留同一会话的最近有效消息,避免 hydrate 短暂返回不完整快照时丢失流式内容]
const mergeStreamMessageSnapshots = (previousMessages = [], currentMessages = []) => {
const previous = Array.isArray(previousMessages) ? previousMessages : [];
const current = Array.isArray(currentMessages) ? currentMessages : [];
if (!previous.length) return current;
if (!current.length) return previous;
const currentById = new Map(
current
.filter((message) => message?.id)
.map((message) => [String(message.id), message])
);
const merged = [];
const usedIds = new Set();
previous.forEach((message) => {
const messageId = String(message?.id || "");
if (!messageId) return;
merged.push(currentById.get(messageId) || message);
usedIds.add(messageId);
});
current.forEach((message) => {
const messageId = String(message?.id || "");
if (!messageId || usedIds.has(messageId)) return;
merged.push(message);
});
return merged;
};
//[直接消费 root messages 事件,避免 history hydrate 覆盖正在生成的增量消息]
const applyMessageEventToState = (currentState, event) => {
const data = event?.params?.data || {};
const eventType = String(data.event || "");
const nextMessages = new Map(currentState.messages);
let activeMessageId = currentState.activeMessageId;
const messageId = String(data.id || activeMessageId || "").trim();
if (!messageId) return currentState;
const previousMessage = nextMessages.get(messageId) || {
id: messageId,
type: "ai",
role: "ai",
content: "",
tool_calls: [],
};
const nextMessage = {
...previousMessage,
tool_calls: Array.isArray(previousMessage.tool_calls)
? [...previousMessage.tool_calls]
: [],
};
if (eventType === "message-start") {
activeMessageId = messageId;
nextMessage.role = data.role || nextMessage.role || "ai";
nextMessage.type = nextMessage.role;
}
if (eventType === "content-block-delta") {
const delta = data.delta || {};
if (delta.type === "text-delta") {
nextMessage.content = `${String(nextMessage.content || "")}${String(delta.text || "")}`;
}
if (delta.type === "tool-call-delta") {
const toolCallIndex = Number(data.index || 0);
const previousToolCall = nextMessage.tool_calls[toolCallIndex] || {};
const deltaArgs = delta.args;
const nextArgs =
previousToolCall.args &&
typeof previousToolCall.args === "object" &&
deltaArgs &&
typeof deltaArgs === "object"
? { ...previousToolCall.args, ...deltaArgs }
: deltaArgs ?? previousToolCall.args;
nextMessage.tool_calls[toolCallIndex] = {
...previousToolCall,
args: nextArgs,
status: "running",
};
}
}
if (eventType === "content-block-finish") {
const content = data.content || {};
if (content.type === "text") {
nextMessage.content = content.text || nextMessage.content || "";
}
if (content.type === "tool_call") {
const toolCallIndex = Number(data.index || 0);
nextMessage.tool_calls[toolCallIndex] = {
...(nextMessage.tool_calls[toolCallIndex] || {}),
...content,
status: "success",
};
}
}
nextMessages.set(messageId, nextMessage);
return {
messages: nextMessages,
activeMessageId,
};
};
const getEventDedupeKey = (event) => { const getEventDedupeKey = (event) => {
if (event?.event_id) return String(event.event_id); if (event?.event_id) return String(event.event_id);
const payload = getEventPayload(event) || {}; const payload = getEventPayload(event) || {};
@ -474,20 +387,22 @@ const useSuperAgentStream = ({
const pendingInterruptRef = useRef(false); const pendingInterruptRef = useRef(false);
const awaitingInterruptRef = useRef(false); const awaitingInterruptRef = useRef(false);
const [runPhase, setRunPhase] = useState("idle"); const [runPhase, setRunPhase] = useState("idle");
const [isLocalRunActive, setIsLocalRunActive] = useState(false);
const [isRespondingInterrupt, setIsRespondingInterrupt] = useState(false); const [isRespondingInterrupt, setIsRespondingInterrupt] = useState(false);
const [isBusinessRunning, setIsBusinessRunning] = useState(false); const [isBusinessRunning, setIsBusinessRunning] = useState(false);
const [runtimeCheckpointDeliveries, setRuntimeCheckpointDeliveries] = useState([]); const [runtimeCheckpointDeliveries, setRuntimeCheckpointDeliveries] = useState([]);
const [runtimeInterrupts, setRuntimeInterrupts] = useState([]); const [runtimeInterrupts, setRuntimeInterrupts] = useState([]);
const [dismissedInterruptIds, setDismissedInterruptIds] = useState(new Set()); const [dismissedInterruptIds, setDismissedInterruptIds] = useState(new Set());
const resolvedInterruptIdsRef = useRef(new Set());
const [currentInterruptId, setCurrentInterruptId] = useState(""); const [currentInterruptId, setCurrentInterruptId] = useState("");
const [hydratedThreadId, setHydratedThreadId] = useState("");
const [transportStateRevision, setTransportStateRevision] = useState(0);
const eventRequestControllersRef = useRef(new Map()); const eventRequestControllersRef = useRef(new Map());
const streamSnapshotCacheRef = useRef(new Map()); const pendingSubmitThreadIdRef = useRef("");
const eventMessageStateRef = useRef({ const hydrationSessionRef = useRef({
sessionId: "", sessionId: "",
activeMessageId: "", hasSeenLoading: false,
messages: new Map(),
}); });
const [eventStreamMessages, setEventStreamMessages] = useState([]);
const localRunActiveRef = useRef(false); const localRunActiveRef = useRef(false);
const localRunHasSeenLoadingRef = useRef(false); const localRunHasSeenLoadingRef = useRef(false);
const activeSessionIdRef = useRef(""); const activeSessionIdRef = useRef("");
@ -504,8 +419,17 @@ const useSuperAgentStream = ({
}), }),
[token, userId] [token, userId]
); );
const fetchWithHistoryCompatibility = useCallback(async (...args) => { const fetchUseStreamTransport = useCallback(async (...args) => {
const [input, init = {}] = args; const [input, init = {}] = args;
if (isUseStreamHistoryDiscoveryRequest(input)) {
// 当前 SDK hydrate 会为 namespace discovery 自动读取 history。
// SuperAgent 只以 thread state 和 event stream 为准,禁止该
// 非业务读取落到服务端,避免 history 再次参与会话恢复。
return new Response("[]", {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (isLifecycleWatcherRequest(input, init)) { if (isLifecycleWatcherRequest(input, init)) {
// Agent 服务每个 thread 只允许一个 events subscriber。useStream // Agent 服务每个 thread 只允许一个 events subscriber。useStream
// 的 root pump 已覆盖 lifecycle/input,额外 watcher 会稳定触发 429。 // 的 root pump 已覆盖 lifecycle/input,额外 watcher 会稳定触发 429。
@ -515,7 +439,9 @@ const useSuperAgentStream = ({
}); });
} }
let requestInit = init; let requestInit = init;
const sessionKey = String(activeSessionIdRef.current || "").trim(); const sessionKey = String(
pendingSubmitThreadIdRef.current || activeSessionIdRef.current || ""
).trim();
if (sessionKey && isSuperAgentEventRequest(input)) { if (sessionKey && isSuperAgentEventRequest(input)) {
let sessionController = eventRequestControllersRef.current.get(sessionKey); let sessionController = eventRequestControllersRef.current.get(sessionKey);
if (!sessionController) { if (!sessionController) {
@ -528,40 +454,31 @@ const useSuperAgentStream = ({
}; };
} }
const response = await fetchEventStreamWithRecovery(input, requestInit); return fetchEventStreamWithRecovery(input, requestInit);
const contentType = response.headers.get("content-type") || "";
if (!/json/i.test(contentType)) return response;
try {
const payload = await response.clone().json();
const sanitizedPayload = sanitizeLangGraphPayload(payload);
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(JSON.stringify(sanitizedPayload), {
status: response.status,
statusText: response.statusText,
headers,
});
} catch {
return response;
}
}, []); }, []);
const eventTransport = useMemo( const eventTransport = useMemo(
() => () => {
new SingleSubscriberSseTransportAdapter({ const transport = new SingleSubscriberSseTransportAdapter({
apiUrl: getSuperAgentApiUrl(), apiUrl: getSuperAgentApiUrl(),
defaultHeaders, defaultHeaders,
fetch: fetchWithHistoryCompatibility, fetch: fetchUseStreamTransport,
maxReconnectAttempts: 5, maxReconnectAttempts: 5,
}), });
[defaultHeaders, fetchWithHistoryCompatibility] transport.onStateChange = () => {
setTransportStateRevision((revision) => revision + 1);
};
return transport;
},
[activeSessionId, defaultHeaders, fetchUseStreamTransport]
); );
// StreamController 在 hydrate 时会先通过 transport.getState() 取新 thread; // StreamController 在 hydrate 时会先通过 transport.getState() 取新 thread;
// 自定义 adapter 需在该 effect 运行前同步绑定目标 thread,避免读到旧会话。 // 自定义 adapter 需在该 effect 运行前同步绑定目标 thread,避免读到旧会话。
eventTransport.setThreadId(activeSessionId || ""); eventTransport.setThreadId(
activeSessionId || pendingSubmitThreadIdRef.current || ""
);
const callerOptions = useMemo( const callerOptions = useMemo(
() => ({ fetch: fetchWithHistoryCompatibility }), () => ({ fetch: fetchUseStreamTransport }),
[fetchWithHistoryCompatibility] [fetchUseStreamTransport]
); );
const stream = useStream({ const stream = useStream({
assistantId: "freesun_agent", assistantId: "freesun_agent",
@ -616,6 +533,7 @@ const useSuperAgentStream = ({
const handleStreamError = useCallback((error) => { const handleStreamError = useCallback((error) => {
localRunActiveRef.current = false; localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false }; interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false; pendingInterruptRef.current = false;
@ -641,14 +559,16 @@ const useSuperAgentStream = ({
}, [activeSessionId, eventTransport]); }, [activeSessionId, eventTransport]);
useEffect(() => { useEffect(() => {
eventMessageStateRef.current = { if (pendingSubmitThreadIdRef.current === String(activeSessionId || "")) {
pendingSubmitThreadIdRef.current = "";
}
hydrationSessionRef.current = {
sessionId: String(activeSessionId || ""), sessionId: String(activeSessionId || ""),
activeMessageId: "", hasSeenLoading: false,
messages: new Map(),
}; };
setEventStreamMessages([]);
handledEventKeysRef.current.clear(); handledEventKeysRef.current.clear();
localRunActiveRef.current = false; localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false }; interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false; pendingInterruptRef.current = false;
@ -659,41 +579,58 @@ const useSuperAgentStream = ({
setRuntimeCheckpointDeliveries([]); setRuntimeCheckpointDeliveries([]);
setRuntimeInterrupts([]); setRuntimeInterrupts([]);
setDismissedInterruptIds(new Set()); setDismissedInterruptIds(new Set());
resolvedInterruptIdsRef.current.clear();
setCurrentInterruptId(""); setCurrentInterruptId("");
setHydratedThreadId("");
}, [activeSessionId]); }, [activeSessionId]);
const handleMessageEvent = useCallback((event) => { useEffect(() => {
if (event?.method !== "messages") return; const sessionId = String(activeSessionId || "");
const namespace = event?.params?.namespace; const threadId = String(stream.threadId || "");
if (Array.isArray(namespace) && namespace.length > 0) return; if (!sessionId || sessionId !== threadId) return undefined;
const sessionId = String(activeSessionIdRef.current || "").trim(); let cancelled = false;
if (!sessionId) return; // isThreadLoading 在 useStream hydrate 切换的中间阶段可能仍是 false,
if (eventMessageStateRef.current.sessionId !== sessionId) { // 不能用它作为快照完成标志;hydrationPromise 才代表当前 thread 的 state
eventMessageStateRef.current = { // 已经写入 root store。
sessionId, Promise.resolve(stream.hydrationPromise)
activeMessageId: "", .then(() => {
messages: new Map(), if (cancelled || activeSessionIdRef.current !== sessionId) return;
}; setHydratedThreadId(sessionId);
} })
.catch(() => undefined);
const nextState = applyMessageEventToState(eventMessageStateRef.current, event); return () => {
if (nextState === eventMessageStateRef.current) return; cancelled = true;
eventMessageStateRef.current = nextState; };
setEventStreamMessages([...nextState.messages.values()]); }, [
}, []); activeSessionId,
stream.hydrationPromise,
stream.threadId,
transportStateRevision,
]);
const checkpointDeliveries = useMemo(() => { const checkpointDeliveries = useMemo(() => {
const deliveries = new Map(); const deliveries = new Map();
const hydratedDeliveries = Array.isArray(stream.values?.checkpoint_deliveries) const hydratedDeliveries = [
? stream.values.checkpoint_deliveries ...(Array.isArray(stream.values?.checkpoint_deliveries)
: []; ? stream.values.checkpoint_deliveries
: []),
...(Array.isArray(eventTransport.latestState?.values?.checkpoint_deliveries)
? eventTransport.latestState.values.checkpoint_deliveries
: []),
];
[...hydratedDeliveries, ...runtimeCheckpointDeliveries].forEach((item) => { [...hydratedDeliveries, ...runtimeCheckpointDeliveries].forEach((item) => {
if (!item?.checkpoint_id) return; if (!item?.checkpoint_id) return;
deliveries.set(item.checkpoint_id, item); deliveries.set(item.checkpoint_id, item);
}); });
return [...deliveries.values()]; return [...deliveries.values()];
}, [runtimeCheckpointDeliveries, stream.values?.checkpoint_deliveries]); }, [
eventTransport.latestState,
runtimeCheckpointDeliveries,
stream.values?.checkpoint_deliveries,
transportStateRevision,
]);
useEffect(() => { useEffect(() => {
if (stream.error) handleStreamError(stream.error); if (stream.error) handleStreamError(stream.error);
@ -709,6 +646,7 @@ const useSuperAgentStream = ({
localRunActiveRef.current = false; localRunActiveRef.current = false;
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
setIsLocalRunActive(false);
setRunPhase((currentPhase) => setRunPhase((currentPhase) =>
["running", "resuming"].includes(currentPhase) ? "completed" : currentPhase ["running", "resuming"].includes(currentPhase) ? "completed" : currentPhase
); );
@ -722,23 +660,27 @@ const useSuperAgentStream = ({
String(stream.threadId || "") === String(activeSessionId || ""); String(stream.threadId || "") === String(activeSessionId || "");
if (!isCurrentThread) return undefined; if (!isCurrentThread) return undefined;
const listenerSessionId = String(activeSessionId || "");
const currentThread = stream.getThread?.(); const currentThread = stream.getThread?.();
if (!currentThread) return undefined; if (!currentThread) return undefined;
return currentThread.onEvent((event) => { return currentThread.onEvent((event) => {
handleMessageEvent(event); // 切换会话后旧 Thread 的监听器可能在清理前再收到一个事件,
// 不能把旧会话的事件写入新会话的流式消息状态。
if (activeSessionIdRef.current !== listenerSessionId) return;
if (event?.method === "input.requested") { if (event?.method === "input.requested") {
const data = event.params?.data || {}; const data = event.params?.data || {};
const interruptId = String(data.interrupt_id || "").trim(); const interruptId = String(data.interrupt_id || "").trim();
if (interruptId) { if (interruptId && !resolvedInterruptIdsRef.current.has(interruptId)) {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
setCurrentInterruptId(interruptId); setCurrentInterruptId(interruptId);
pendingInterruptRef.current = true; setRuntimeInterrupts((currentInterrupts) => {
awaitingInterruptRef.current = false; const nextInterrupts = currentInterrupts.filter(
setRunPhase("waiting_interrupt"); (item) => String(item?.id || "") !== interruptId
setRuntimeInterrupts((current) => { );
if (current.some((item) => item.id === interruptId)) return current;
return [ return [
...current, ...nextInterrupts,
{ {
id: interruptId, id: interruptId,
value: data.payload, value: data.payload,
@ -748,6 +690,9 @@ const useSuperAgentStream = ({
}, },
]; ];
}); });
pendingInterruptRef.current = true;
awaitingInterruptRef.current = false;
setRunPhase("waiting_interrupt");
} }
} }
if (event?.method === "lifecycle" && Array.isArray(event?.params?.namespace) && event.params.namespace.length === 0) { if (event?.method === "lifecycle" && Array.isArray(event?.params?.namespace) && event.params.namespace.length === 0) {
@ -767,6 +712,8 @@ const useSuperAgentStream = ({
stream.interrupts.length > 0 || stream.interrupts.length > 0 ||
threadInterrupts.length > 0; threadInterrupts.length > 0;
if (hasRealInterrupt) { if (hasRealInterrupt) {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
awaitingInterruptRef.current = false; awaitingInterruptRef.current = false;
setRunPhase("waiting_interrupt"); setRunPhase("waiting_interrupt");
} else { } else {
@ -780,6 +727,8 @@ const useSuperAgentStream = ({
setRunPhase("waiting_interrupt"); setRunPhase("waiting_interrupt");
} else { } else {
awaitingInterruptRef.current = false; awaitingInterruptRef.current = false;
localRunActiveRef.current = false;
setIsLocalRunActive(false);
// aborted 表示本轮已结束,不能让 started 留下的 running 状态继续生效。 // aborted 表示本轮已结束,不能让 started 留下的 running 状态继续生效。
setRunPhase(lifecycleEvent === "aborted" ? "completed" : lifecycleEvent); setRunPhase(lifecycleEvent === "aborted" ? "completed" : lifecycleEvent);
} }
@ -793,10 +742,18 @@ const useSuperAgentStream = ({
if (!BUSINESS_CHANNELS.includes(event?.method)) return; if (!BUSINESS_CHANNELS.includes(event?.method)) return;
handleBusinessEvent(event); handleBusinessEvent(event);
}); });
}, [activeSessionId, handleBusinessEvent, handleMessageEvent, stream.isThreadLoading, stream.threadId]); }, [activeSessionId, handleBusinessEvent, stream.threadId]);
const prepareForSessionSwitch = useCallback(() => { const prepareForSessionSwitch = useCallback(() => {
// 先让旧 thread 的事件监听失效。activeSessionId 在 React 下一次渲染
// 前仍可能是旧值,单靠监听器里的 sessionId 判断不够。
activeSessionIdRef.current = "";
// pendingSubmitThreadId 只服务于“创建会话后首次发送”这一瞬间;
// 切换会话时必须清掉,否则新 transport 会继续绑定旧 thread。
pendingSubmitThreadIdRef.current = "";
setHydratedThreadId("");
localRunActiveRef.current = false; localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false }; interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false; pendingInterruptRef.current = false;
@ -804,18 +761,21 @@ const useSuperAgentStream = ({
setRunPhase("idle"); setRunPhase("idle");
setIsRespondingInterrupt(false); setIsRespondingInterrupt(false);
setIsBusinessRunning(false); setIsBusinessRunning(false);
setRuntimeInterrupts([]);
resolvedInterruptIdsRef.current.clear();
if (stream.isLoading) { // 会话切换只断开当前浏览器订阅,不能发送 stop/cancel 命令。
void stream.stop().catch(() => undefined); // stopRun 只由用户点击停止按钮触发,否则后端会把原会话改为 suspended。
}
// useStream 切换 threadId 时会等待旧 root pump 清理;直接关闭当前 // useStream 切换 threadId 时会等待旧 root pump 清理;直接关闭当前
// ThreadStream 可以立即中止旧 /events 请求,避免阻塞新会话接管。 // ThreadStream 可以立即中止旧 /events 请求,避免阻塞新会话接管。
const currentThread = stream.getThread?.(); const currentThread = stream.getThread?.();
if (currentThread) { eventTransport.close();
void currentThread.close().catch(() => undefined);
} return Promise.allSettled([
}, [stream]); stream.disconnect(),
currentThread ? currentThread.close() : Promise.resolve(),
]);
}, [eventTransport, stream]);
const submitMessage = useCallback(async ({ const submitMessage = useCallback(async ({
content, content,
@ -828,32 +788,55 @@ const useSuperAgentStream = ({
}) => { }) => {
const targetThreadId = String(threadId || activeSessionId || "").trim(); const targetThreadId = String(threadId || activeSessionId || "").trim();
if (!targetThreadId) throw new Error("请先创建任务"); if (!targetThreadId) throw new Error("请先创建任务");
const isBlockedByInterrupt = isRespondingInterrupt && !allowDuringInterruptResponse; const isBlockedByInterrupt = isRespondingInterrupt && !allowDuringInterruptResponse;
const isSwitchingThread = String(stream.threadId || "") !== targetThreadId; const isSwitchingThread = String(stream.threadId || "") !== targetThreadId;
if ((stream.isThreadLoading && !isSwitchingThread) || isBlockedByInterrupt) { // isThreadLoading 只表示当前 thread 的快照还在 hydrate,不能阻止首条消息提交。
// 真正表示本轮任务仍在运行的是 isLoading;否则新建会话会先创建成功,
// 但首条消息被这里提前拒绝,页面最终只剩空会话。
if ((stream.isLoading && !isSwitchingThread) || isBlockedByInterrupt) {
throw new Error("任务正在处理,请稍后再试"); throw new Error("任务正在处理,请稍后再试");
}
if (stream.isThreadLoading && !isSwitchingThread) {
// 新建会话的 state 可能还未写入 root store。等待 hydrate 完成后再提交,
// 避免 optimistic 消息被空快照或旧事件回放覆盖。
await Promise.resolve(stream.hydrationPromise).catch(() => undefined);
}
// activeSessionId 的 React 状态更新可能晚于首次提交;先绑定 transport
// 和事件请求所属会话,避免首轮响应仍落到旧 thread。
if (hydrationSessionRef.current.sessionId === targetThreadId) {
hydrationSessionRef.current.hasSeenLoading = true;
} }
pendingSubmitThreadIdRef.current = targetThreadId;
eventTransport.setThreadId(targetThreadId);
localRunActiveRef.current = true; localRunActiveRef.current = true;
setIsLocalRunActive(true);
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
setRunPhase("running"); setRunPhase("running");
await stream.submit({ try {
messages: [{ await stream.submit({
type: "human", messages: [{
id: createClientMessageId(), type: "human",
content: String(content || ""), id: createClientMessageId(),
}], content: String(content || ""),
display_content: String(displayContent ?? content ?? ""), }],
attachment_ids: Array.isArray(attachmentIds) ? attachmentIds : [], display_content: String(displayContent ?? content ?? ""),
}, { attachment_ids: Array.isArray(attachmentIds) ? attachmentIds : [],
threadId: targetThreadId, }, {
multitaskStrategy, threadId: targetThreadId,
metadata: { source: "super-agent-web" }, multitaskStrategy,
}); metadata: { source: "super-agent-web" },
}, [activeSessionId, isRespondingInterrupt, stream]); });
} catch (error) {
localRunActiveRef.current = false;
setIsLocalRunActive(false);
throw error;
}
}, [activeSessionId, eventTransport, isRespondingInterrupt, stream]);
const respondInterrupt = useCallback((response, options = {}) => { const respondInterrupt = useCallback((response, options = {}) => {
const interruptId = String(options.interruptId || "").trim(); const interruptId = String(options.interruptId || "").trim();
localRunActiveRef.current = true; localRunActiveRef.current = true;
setIsLocalRunActive(true);
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: true, running: false }; interruptResumeRef.current = { active: true, running: false };
pendingInterruptRef.current = false; pendingInterruptRef.current = false;
@ -878,8 +861,11 @@ const useSuperAgentStream = ({
namespace: options.namespace, namespace: options.namespace,
}).then((result) => { }).then((result) => {
if (interruptId) { if (interruptId) {
setRuntimeInterrupts((current) => resolvedInterruptIdsRef.current.add(interruptId);
current.filter((item) => item.id !== interruptId) setRuntimeInterrupts((currentInterrupts) =>
currentInterrupts.filter(
(item) => String(item?.id || "") !== interruptId
)
); );
} }
// respond 只代表恢复命令已提交,不代表后端本轮运行完成。 // respond 只代表恢复命令已提交,不代表后端本轮运行完成。
@ -888,6 +874,7 @@ const useSuperAgentStream = ({
}).catch((error) => { }).catch((error) => {
if (interruptId) { if (interruptId) {
// 恢复失败时撤销临时隐藏,让用户可以重新提交。 // 恢复失败时撤销临时隐藏,让用户可以重新提交。
resolvedInterruptIdsRef.current.delete(interruptId);
setDismissedInterruptIds((current) => { setDismissedInterruptIds((current) => {
const next = new Set(current); const next = new Set(current);
next.delete(interruptId); next.delete(interruptId);
@ -896,6 +883,8 @@ const useSuperAgentStream = ({
setCurrentInterruptId((currentId) => currentId || interruptId); setCurrentInterruptId((currentId) => currentId || interruptId);
} }
interruptResumeRef.current = { active: false, running: false }; interruptResumeRef.current = { active: false, running: false };
localRunActiveRef.current = false;
setIsLocalRunActive(false);
pendingInterruptRef.current = true; pendingInterruptRef.current = true;
setRunPhase("waiting_interrupt"); setRunPhase("waiting_interrupt");
setIsRespondingInterrupt(false); setIsRespondingInterrupt(false);
@ -905,6 +894,7 @@ const useSuperAgentStream = ({
const stopRun = useCallback(async () => { const stopRun = useCallback(async () => {
localRunActiveRef.current = false; localRunActiveRef.current = false;
setIsLocalRunActive(false);
localRunHasSeenLoadingRef.current = false; localRunHasSeenLoadingRef.current = false;
interruptResumeRef.current = { active: false, running: false }; interruptResumeRef.current = { active: false, running: false };
pendingInterruptRef.current = false; pendingInterruptRef.current = false;
@ -915,50 +905,213 @@ const useSuperAgentStream = ({
await stream.stop(); await stream.stop();
}, [stream]); }, [stream]);
const currentSessionId = String(activeSessionId || ""); const currentSessionId = String(activeSessionId || "");
const currentThreadId = String(stream.threadId || ""); const currentThreadId = String(stream.threadId || "");
const isThreadBound = const projectedThreadId = String(
Boolean(currentSessionId) && currentSessionId === currentThreadId; pendingSubmitThreadIdRef.current || currentThreadId
const cachedSnapshot = streamSnapshotCacheRef.current.get(currentSessionId) || { );
messages: [], const isThreadBound =
toolCalls: [], Boolean(currentSessionId) && currentSessionId === currentThreadId;
values: {}, const isProjectedThreadBound =
}; Boolean(currentSessionId) && currentSessionId === projectedThreadId;
const currentValues = isThreadBound && stream.values ? stream.values : {}; if (hydrationSessionRef.current.sessionId !== currentSessionId) {
const currentValueMessages = Array.isArray(currentValues.messages) hydrationSessionRef.current = {
? currentValues.messages sessionId: currentSessionId,
: []; hasSeenLoading: false,
const currentMessages = isThreadBound ? stream.messages : []; };
const hydratedMessages = mergeStreamMessageSnapshots( }
mergeStreamMessageSnapshots(cachedSnapshot.messages, currentValueMessages), if (isThreadBound && stream.isThreadLoading) {
currentMessages hydrationSessionRef.current.hasSeenLoading = true;
); }
const nextMessages = mergeStreamMessageSnapshots( const isThreadReady =
hydratedMessages, isThreadBound &&
isThreadBound ? eventStreamMessages : [] !stream.isThreadLoading &&
); hydratedThreadId === currentSessionId;
const nextToolCalls = isThreadBound && stream.toolCalls.length const isPendingSubmitThread =
? stream.toolCalls isProjectedThreadBound &&
: cachedSnapshot.toolCalls; pendingSubmitThreadIdRef.current === currentSessionId;
const nextValues = isThreadBound const isStreamProjectionReady =
? { ...cachedSnapshot.values, ...currentValues } isThreadReady || isPendingSubmitThread;
: cachedSnapshot.values; const isInterruptProjectionReady =
if (isThreadBound && (nextMessages.length || nextToolCalls.length || Object.keys(nextValues).length)) { isThreadBound && runtimeInterrupts.length > 0;
streamSnapshotCacheRef.current.set(currentSessionId, { const isCurrentThread = isThreadReady;
messages: nextMessages, const hydratedStateThreadId = String(
toolCalls: nextToolCalls, eventTransport.latestState?.checkpoint?.thread_id ||
values: { eventTransport.latestState?.checkpoint?.threadId ||
...nextValues, eventTransport.latestState?.values?.task?.task_id ||
...(nextMessages.length ? { messages: nextMessages } : {}), eventTransport.latestState?.values?.task?.taskId ||
}, ""
}); );
} const hydratedState =
const isCurrentThread = isThreadBound && !stream.isThreadLoading; isThreadReady &&
const streamMessages = isThreadBound ? nextMessages : []; hydratedStateThreadId === currentSessionId
const streamToolCalls = isThreadBound ? nextToolCalls : []; ? eventTransport.latestState
const streamValues = isThreadBound ? nextValues : {}; : null;
const rawStreamMessages = isStreamProjectionReady ? stream.messages : [];
const rawStreamValues = isStreamProjectionReady ? stream.values || {} : {};
const hydratedMessages = Array.isArray(hydratedState?.values?.messages)
? hydratedState.values.messages
: [];
// 只有 hydrate 真正拿到消息后,才能解除首次提交的 thread 投影保护。
// 仅有 task 快照不代表消息已完成落库,否则 task 先到时会让当前消息再次被隐藏。
const hasHydratedSessionContent = hydratedMessages.length > 0;
useEffect(() => {
if (!isThreadReady || !isPendingSubmitThread || !hasHydratedSessionContent) return;
pendingSubmitThreadIdRef.current = "";
}, [
hasHydratedSessionContent,
isPendingSubmitThread,
isThreadReady,
]);
const hydratedTailIds = hydratedMessages
.slice(-3)
.map((item) => String(item?.id || "").trim())
.filter(Boolean);
const rawStreamMessageIds = new Set(
rawStreamMessages
.map((item) => String(item?.id || "").trim())
.filter(Boolean)
);
const hydratedTaskStatus = String(
hydratedState?.values?.status ||
hydratedState?.values?.session_status ||
hydratedState?.values?.sessionStatus ||
hydratedState?.values?.task?.session_status ||
hydratedState?.values?.task?.sessionStatus ||
hydratedState?.values?.task?.status ||
""
).toLowerCase();
const rawTaskStatus = String(
rawStreamValues.status ||
rawStreamValues.session_status ||
rawStreamValues.sessionStatus ||
rawStreamValues.task?.session_status ||
rawStreamValues.task?.sessionStatus ||
rawStreamValues.task?.status ||
""
).toLowerCase();
const hydratedCheckpointDeliveryCount = Array.isArray(
hydratedState?.values?.checkpoint_deliveries
)
? hydratedState.values.checkpoint_deliveries.length
: 0;
const rawCheckpointDeliveryCount = Array.isArray(rawStreamValues.checkpoint_deliveries)
? rawStreamValues.checkpoint_deliveries.length
: 0;
const shouldPreferHydratedRuntimeState =
Boolean(hydratedState) &&
Boolean(hydratedTaskStatus || hydratedCheckpointDeliveryCount) &&
(!rawTaskStatus && !rawCheckpointDeliveryCount ||
hydratedTaskStatus !== rawTaskStatus ||
hydratedCheckpointDeliveryCount > rawCheckpointDeliveryCount);
//[state 是当前 thread 的最新快照,SSE 重连可能先重放更早的 values]
// 消息和 task/value 的权威来源不能共用一个切换条件。
// hydrate 返回空 messages 时,仍可能带有较新的 task 状态;此时只能更新
// task/value,不能让空快照覆盖事件流已经收到的消息。
const shouldPreferHydratedMessages =
hydratedMessages.length > 0 &&
(rawStreamMessages.length < hydratedMessages.length ||
(hydratedTailIds.length > 0 &&
!hydratedTailIds.every((messageId) => rawStreamMessageIds.has(messageId))));
const shouldPreferHydratedState =
shouldPreferHydratedRuntimeState || shouldPreferHydratedMessages;
const streamMessages = shouldPreferHydratedMessages
? hydratedMessages
: rawStreamMessages;
const streamToolCalls = isStreamProjectionReady ? stream.toolCalls : [];
const streamValues = shouldPreferHydratedState
? hydratedState?.values || {}
: rawStreamValues;
const streamSessionStatus = String(
streamValues.status ||
streamValues.session_status ||
streamValues.sessionStatus ||
streamValues.task?.session_status ||
streamValues.task?.sessionStatus ||
""
).toLowerCase();
const streamTask = streamValues?.task
? {
...streamValues.task,
...(streamSessionStatus
? { session_status: streamSessionStatus }
: {}),
}
: null;
const persistedTaskStatus = String(
streamSessionStatus || streamTask?.status || ""
).toLowerCase();
const isPersistedTaskActive = ACTIVE_TASK_STATUSES.has(persistedTaskStatus);
const hasPersistedNextNodes =
Array.isArray(hydratedState?.next) && hydratedState.next.length > 0;
const hydratedInterrupts = [
...(Array.isArray(hydratedState?.interrupts)
? hydratedState.interrupts
: []),
...(Array.isArray(hydratedState?.values?.__interrupt__)
? hydratedState.values.__interrupt__.map((item) => ({
...item,
id: item?.id || item?.interruptId,
value: item?.value ?? item?.payload,
namespace: Array.isArray(item?.namespace) ? item.namespace : [],
}))
: []),
];
const hasPersistedInterrupts = hydratedInterrupts.length > 0;
const persistedInterrupts =
hydratedState
? hydratedInterrupts
: isStreamProjectionReady
? stream.interrupts
: [];
const streamInterrupts = [
...(Array.isArray(persistedInterrupts) ? persistedInterrupts : []),
...runtimeInterrupts,
];
const shouldResetStaleWaitingPhase =
Boolean(hydratedState) &&
!hasPersistedInterrupts &&
["waiting_interrupt", "awaiting_interrupt"].includes(runPhase);
const isPersistedThreadActive =
isPersistedTaskActive || hasPersistedNextNodes || hasPersistedInterrupts;
const isPersistedExecutionActive =
isPersistedThreadActive &&
![
"suspended",
"waiting_user",
"awaiting_interrupt",
"waiting_interrupt",
].includes(persistedTaskStatus) &&
!hasPersistedInterrupts;
const hasRuntimeExecutionSignal =
localRunActiveRef.current ||
isLocalRunActive ||
isBusinessRunning ||
["running", "resuming"].includes(runPhase);
const isPersistedSuspended =
persistedTaskStatus === "suspended" &&
!hasPersistedInterrupts &&
!hasRuntimeExecutionSignal;
const isTerminalRunPhase = [
"completed",
"aborted",
"failed",
"cancelled",
"canceled",
].includes(runPhase);
const effectiveRunPhase =
isPersistedSuspended
? "idle"
: shouldResetStaleWaitingPhase
? "running"
: isPersistedExecutionActive && (runPhase === "idle" || isTerminalRunPhase)
? "running"
: runPhase;
const effectiveIsLocalRunActive = isPersistedSuspended ? false : isLocalRunActive;
const effectiveIsBusinessRunning = isPersistedSuspended ? false : isBusinessRunning;
const visibleInterrupts = useMemo(() => { const visibleInterrupts = useMemo(() => {
if (!isThreadBound) return []; if (!isStreamProjectionReady && !isInterruptProjectionReady) return [];
const merged = new Map(); const merged = new Map();
const appendInterrupt = (item, fallback = {}) => { const appendInterrupt = (item, fallback = {}) => {
@ -977,35 +1130,61 @@ const useSuperAgentStream = ({
}); });
}; };
stream.interrupts.forEach((item) => appendInterrupt(item)); streamInterrupts.forEach((item) => appendInterrupt(item));
runtimeInterrupts.forEach((item) => appendInterrupt(item));
const threadInterrupts = stream.getThread?.()?.interrupts || [];
threadInterrupts.forEach((item) => appendInterrupt(item));
return [...merged.values()].filter( return [...merged.values()].filter(
(item) => (item) =>
!dismissedInterruptIds.has(String(item.id || "")) && !dismissedInterruptIds.has(String(item.id || "")) &&
!isTerminalInterrupt(item) !isTerminalInterrupt(item)
); );
}, [dismissedInterruptIds, isCurrentThread, isThreadBound, runtimeInterrupts, stream]); }, [
dismissedInterruptIds,
const isRunPhaseActive = ["running", "awaiting_interrupt", "resuming"].includes(runPhase); isInterruptProjectionReady,
isStreamProjectionReady,
streamInterrupts,
]);
const isRunPhaseActive = ["running", "awaiting_interrupt", "resuming"].includes(
effectiveRunPhase
);
const effectiveIsLoading =
isPersistedSuspended
? isRespondingInterrupt
: stream.isLoading ||
isRespondingInterrupt ||
isBusinessRunning ||
isRunPhaseActive ||
isPersistedExecutionActive;
return { return {
messages: streamMessages, messages: streamMessages,
toolCalls: streamToolCalls, toolCalls: streamToolCalls,
interrupt: visibleInterrupts[0] || null,
interrupts: visibleInterrupts, interrupts: visibleInterrupts,
values: streamValues, values: streamValues,
checkpointDeliveries: isCurrentThread ? checkpointDeliveries : [], checkpointDeliveries: isStreamProjectionReady ? checkpointDeliveries : [],
task: streamValues?.task || null, task: streamTask,
isLoading: stream.isLoading || isRespondingInterrupt || isBusinessRunning || isRunPhaseActive, sessionStatus: streamSessionStatus,
runPhase, isLoading: effectiveIsLoading,
currentInterruptId, isLocalRunActive: effectiveIsLocalRunActive,
isWaitingForInterrupt: runPhase === "waiting_interrupt" || visibleInterrupts.length > 0, runPhase: effectiveRunPhase,
isAwaitingInterrupt: runPhase === "awaiting_interrupt", isPersistedThreadActive,
isBusinessRunning, currentInterruptId:
hydratedState &&
hydratedInterrupts.length === 0 &&
runtimeInterrupts.length === 0
? ""
: currentInterruptId,
isWaitingForInterrupt:
visibleInterrupts.length > 0 ||
(effectiveRunPhase === "waiting_interrupt" && !isPersistedThreadActive),
isAwaitingInterrupt: effectiveRunPhase === "awaiting_interrupt",
isBusinessRunning: effectiveIsBusinessRunning,
isThreadLoading: stream.isThreadLoading, isThreadLoading: stream.isThreadLoading,
isThreadReady,
isThreadProjectionReady:
isStreamProjectionReady || isInterruptProjectionReady,
error: isCurrentThread && stream.error ? normalizeStreamError(stream.error) : null, error: isCurrentThread && stream.error ? normalizeStreamError(stream.error) : null,
threadId: stream.threadId, threadId: projectedThreadId,
submitMessage, submitMessage,
respondInterrupt, respondInterrupt,
respondAllInterrupts: stream.respondAll, respondAllInterrupts: stream.respondAll,

2
web/client/src/sections/superAgent/router.js

@ -18,7 +18,7 @@ export default {
type: "outer", type: "outer",
route: { route: {
key: "superAgent", key: "superAgent",
path: "/superAgent", path: "/aichat",
component: SuperAgent, component: SuperAgent,
nav: false, nav: false,
}, },

6
web/client/src/sections/superAgent/utils/streamMessage.js

@ -47,8 +47,12 @@ const normalizeMessageContent = (content) => {
const text = readTextContent(content); const text = readTextContent(content);
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
const fileUrls = lines.filter(isUploadedFileUrl); const fileUrls = lines.filter(isUploadedFileUrl);
const fileNames = fileUrls.map(getUploadedFileName).filter(Boolean);
const messageText = lines.filter((line) => !isUploadedFileUrl(line)).join("\n").trim();
return { return {
content: lines.filter((line) => !isUploadedFileUrl(line)).join("\n").trim(), // URL 继续放入 files 供附件 tag 使用;正文文本只展示解码后的文件名,
// 避免把带 token 的上传地址直接展示给用户。
content: [...fileNames, messageText].filter(Boolean).join("\n"),
files: fileUrls.map((url) => ({ files: fileUrls.map((url) => ({
id: url, id: url,
name: getUploadedFileName(url), name: getUploadedFileName(url),

3781
web/package-lock.json

File diff suppressed because it is too large
Loading…
Cancel
Save