Browse Source

feat: 前端状态、错误与可观测性

master
Senor-Liu 1 month ago
parent
commit
67e1eb3500
  1. 3
      api/.vscode/launch.json
  2. 4
      api/app/controllers/superAgentProtocol.js
  3. 7
      api/config/default.js
  4. 3
      web/client/src/sections/superAgent/components/SuperAgentSidebar.jsx
  5. 16
      web/client/src/sections/superAgent/constants.js
  6. 5
      web/client/src/sections/superAgent/container/index.jsx
  7. 2
      web/client/src/sections/superAgent/hooks/useSuperAgentChat.js
  8. 11
      web/client/src/sections/superAgent/hooks/useSuperAgentSidebar.js
  9. 69
      web/client/src/sections/superAgent/hooks/useSuperAgentStream.js
  10. 11
      web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js
  11. 41
      web/client/src/sections/superAgent/utils/agentApi.js
  12. 27
      web/client/src/sections/superAgent/utils/agentApiParse.js
  13. 10
      web/client/src/sections/superAgent/utils/debugLog.js
  14. 22
      web/client/src/sections/superAgent/utils/session.js
  15. 21
      web/tests/agentApiAdapter.test.mjs
  16. 8
      web/tests/superAgentFlowContract.test.mjs
  17. 8
      web/tests/superAgentSidebarContract.test.mjs

3
api/.vscode/launch.json

@ -115,6 +115,9 @@
"IMAGE_ASSETS_DATABASE_URL":"postgres://postgres:postgres@10.8.16.77:5434/deep_agent_db", "IMAGE_ASSETS_DATABASE_URL":"postgres://postgres:postgres@10.8.16.77:5434/deep_agent_db",
/* 阿里云百炼 API KEY */ /* 阿里云百炼 API KEY */
"DASHSCOPE_API_KEY": "", "DASHSCOPE_API_KEY": "",
"OCR_API_URL": "http://112.4.244.131:8056",
"FS_SUPER_AGENT_BUSINESS_API": "http://localhost:8000",
"FS_SUPER_AGENT_FRAMEWORK_API": "http://localhost:8005",
"AUTH_JWT_VERIFICATION_KEY": "local-development-only-long-random-secret", "AUTH_JWT_VERIFICATION_KEY": "local-development-only-long-random-secret",
} }
} }

4
api/app/controllers/superAgentProtocol.js

@ -55,10 +55,6 @@ const buildTrustedHeaders = (ctx, hasJsonBody = false) => {
Accept: 'application/json, text/event-stream', Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${issued.token}`, Authorization: `Bearer ${issued.token}`,
}; };
const searchToken = String(ctx.fs?.curUser?.token || '').trim();
if (searchToken) {
headers['X-PEP-Token'] = searchToken;
}
if (hasJsonBody) headers['Content-Type'] = 'application/json'; if (hasJsonBody) headers['Content-Type'] = 'application/json';
return headers; return headers;
}; };

7
api/config/default.js

@ -406,7 +406,12 @@ module.exports = {
hostUrl: process.env.XFYUN_SPEECH_HOST_URL || "wss://iat-api.xfyun.cn/v2/iat", hostUrl: process.env.XFYUN_SPEECH_HOST_URL || "wss://iat-api.xfyun.cn/v2/iat",
}, },
superAgent: { superAgent: {
baseUrl: process.env.FS_SUPER_AGENT_API || "http://localhost:8080", baseUrl: process.env.FS_SUPER_AGENT_FRAMEWORK_API || "http://127.0.0.1:8005",
businessBaseUrl:
process.env.FS_SUPER_AGENT_BUSINESS_API ||
process.env.FS_SUPER_AGENT_API ||
"http://127.0.0.1:8000",
frameworkBaseUrl: process.env.FS_SUPER_AGENT_FRAMEWORK_API || "http://127.0.0.1:8005",
jwt: { jwt: {
algorithm: process.env.FS_SUPER_AGENT_JWT_ALGORITHM || "HS256", algorithm: process.env.FS_SUPER_AGENT_JWT_ALGORITHM || "HS256",
secret: process.env.AUTH_JWT_VERIFICATION_KEY || "", secret: process.env.AUTH_JWT_VERIFICATION_KEY || "",

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

@ -73,6 +73,7 @@ const SuperAgentSidebar = ({
collapsedFolderMap, collapsedFolderMap,
activeSessionId, activeSessionId,
isLoadingSessions, isLoadingSessions,
sidebarError,
isCreatingChat, isCreatingChat,
isCreatingTask, isCreatingTask,
isLoggedIn, isLoggedIn,
@ -259,6 +260,8 @@ const SuperAgentSidebar = ({
{isLoadingSessions ? ( {isLoadingSessions ? (
<div className="super-history-empty">正在加载会话...</div> <div className="super-history-empty">正在加载会话...</div>
) : sidebarError ? (
<div className="super-history-empty">{sidebarError.message}</div>
) : ( ) : (
<> <>
<div className="sidebar-realm-block"> <div className="sidebar-realm-block">

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

@ -23,17 +23,25 @@ import NetworkIcon from "@assets/img/newAiChat/icon (11).png";
import ShortcutArticleIcon from "@assets/img/newAiChat/icon (4).png"; import ShortcutArticleIcon from "@assets/img/newAiChat/icon (4).png";
import ShortcutWriteIcon from "@assets/img/newAiChat/icon (7).png"; import ShortcutWriteIcon from "@assets/img/newAiChat/icon (7).png";
export const FOLDER_COLLAPSED_STORAGE_KEY = "superAgent_folder_collapsed"; export const FOLDER_COLLAPSED_STORAGE_KEY = "superAgent_v2_folder_collapsed";
export const SIDEBAR_SESSION_DRAG_TYPE = "application/x-super-sidebar-session"; export const SIDEBAR_SESSION_DRAG_TYPE = "application/x-super-sidebar-session";
/** 虚拟默认文件夹(folder_id = null 的任务归入此处,不落库) */ /** 虚拟默认文件夹(folder_id = null 的任务归入此处,不落库) */
export const DEFAULT_TASK_FOLDER_ID = "__default__"; export const DEFAULT_TASK_FOLDER_ID = "__default__";
export const DEFAULT_TASK_FOLDER_NAME = "默认任务集"; export const DEFAULT_TASK_FOLDER_NAME = "默认任务集";
export const TASK_STORAGE_KEY = "superAgent_tender_task_list"; export const TASK_STORAGE_KEY = "superAgent_v2_tender_task_list";
export const TASK_SESSION_STORAGE_KEY = "superAgent_tender_session_ids"; export const TASK_SESSION_STORAGE_KEY = "superAgent_v2_tender_session_ids";
export const PENDING_EMPTY_SESSION_STORAGE_KEY = "superAgent_pending_empty_session"; export const PENDING_EMPTY_SESSION_STORAGE_KEY = "superAgent_v2_pending_empty_session";
export const ACTIVE_SESSION_STORAGE_KEY = "superAgent_v2_active_conversation_id"; export const ACTIVE_SESSION_STORAGE_KEY = "superAgent_v2_active_conversation_id";
export const LEGACY_SUPER_AGENT_STORAGE_KEYS = [
"superAgent_folder_collapsed",
"superAgent_tender_task_list",
"superAgent_tender_session_ids",
"superAgent_pending_empty_session",
"superAgent_active_session_id",
];
export const PHONE_REGEXP = /^1\d{10}$/; export const PHONE_REGEXP = /^1\d{10}$/;
export const CHAT_WELCOME_TITLE = "你好,我是飞小尚 AI 办公助手"; export const CHAT_WELCOME_TITLE = "你好,我是飞小尚 AI 办公助手";

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

@ -56,6 +56,7 @@ import {
normalizeCheckpoint, normalizeCheckpoint,
replaceCheckpointData, replaceCheckpointData,
} from "../utils/checkpoint"; } from "../utils/checkpoint";
import { getSuperAgentErrorMessage } from "../utils/agentApiParse";
import { shouldShowBodyWorkspaceEntry } from "../utils/workspace"; import { shouldShowBodyWorkspaceEntry } from "../utils/workspace";
import { import {
buildStreamConversationMessages, buildStreamConversationMessages,
@ -457,6 +458,7 @@ const SuperAgent = () => {
activeSessionId, activeSessionId,
activeConversationId, activeConversationId,
isLoadingSessions, isLoadingSessions,
sidebarError,
isUploadingFile, isUploadingFile,
isCreatingChat, isCreatingChat,
isCreatingTask, isCreatingTask,
@ -799,7 +801,7 @@ const SuperAgent = () => {
]); ]);
const handleProtocolError = useCallback((error) => { const handleProtocolError = useCallback((error) => {
message.error(error?.message || "智能体服务暂不可用"); message.error(getSuperAgentErrorMessage(error, "智能体服务暂不可用"));
}, []); }, []);
const stream = useSuperAgentStream({ const stream = useSuperAgentStream({
@ -2444,6 +2446,7 @@ const SuperAgent = () => {
collapsedFolderMap, collapsedFolderMap,
activeSessionId: activeConversationId || activeSessionId, activeSessionId: activeConversationId || activeSessionId,
isLoadingSessions, isLoadingSessions,
sidebarError,
isCreatingChat, isCreatingChat,
isCreatingTask, isCreatingTask,
isLoggedIn, isLoggedIn,

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

@ -110,6 +110,7 @@ const useSuperAgentChat = ({
sidebarTree, sidebarTree,
taskList, taskList,
ordinaryChatList, ordinaryChatList,
sidebarError,
isLoadingSidebar, isLoadingSidebar,
collapsedFolderMap, collapsedFolderMap,
loadSidebar, loadSidebar,
@ -1865,6 +1866,7 @@ const useSuperAgentChat = ({
uploadedFileList, uploadedFileList,
uploadProgress, uploadProgress,
ordinaryChatList, ordinaryChatList,
sidebarError,
sidebarTree, sidebarTree,
collapsedFolderMap, collapsedFolderMap,
activeSessionId, activeSessionId,

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

@ -13,6 +13,7 @@ import {
} from "../actions/api"; } from "../actions/api";
import { listAllConversations, listAllTasksets } from "../actions/agentResources"; import { listAllConversations, listAllTasksets } from "../actions/agentResources";
import { computeSessionDrop } from "../utils/sidebarDrag"; import { computeSessionDrop } from "../utils/sidebarDrag";
import { getSuperAgentErrorMessage } from "../utils/agentApiParse";
import { import {
normalizeSessionTitle, normalizeSessionTitle,
buildSidebarTreeFromAgentResources, buildSidebarTreeFromAgentResources,
@ -44,6 +45,7 @@ const EMPTY_TREE = { realms: [] };
const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => { const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
const [sidebarTree, setSidebarTree] = useState(EMPTY_TREE); const [sidebarTree, setSidebarTree] = useState(EMPTY_TREE);
const [isLoadingSidebar, setIsLoadingSidebar] = useState(false); const [isLoadingSidebar, setIsLoadingSidebar] = useState(false);
const [sidebarError, setSidebarError] = useState(null);
const [collapsedFolderMap, setCollapsedFolderMap] = useState(() => readCollapsedFolderMap()); const [collapsedFolderMap, setCollapsedFolderMap] = useState(() => readCollapsedFolderMap());
const taskList = useMemo( const taskList = useMemo(
@ -73,9 +75,15 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
]); ]);
const nextTree = buildSidebarTreeFromAgentResources(tasksets, conversations); const nextTree = buildSidebarTreeFromAgentResources(tasksets, conversations);
setSidebarTree(nextTree); setSidebarTree(nextTree);
setSidebarError(null);
return nextTree; return nextTree;
} catch (error) { } catch (error) {
message.error(error?.message || "获取侧边栏失败"); const normalizedError = {
...error,
message: getSuperAgentErrorMessage(error, "获取侧边栏失败"),
};
setSidebarError(normalizedError);
message.error(normalizedError.message);
return EMPTY_TREE; return EMPTY_TREE;
} finally { } finally {
if (shouldShowLoading) { if (shouldShowLoading) {
@ -236,6 +244,7 @@ const useSuperAgentSidebar = ({ currentUserId, actions, dispatch }) => {
return { return {
sidebarTree, sidebarTree,
sidebarError,
taskList, taskList,
ordinaryChatList, ordinaryChatList,
isLoadingSidebar, isLoadingSidebar,

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

@ -20,11 +20,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStream } from "@langchain/react"; import { useStream } from "@langchain/react";
import { ProtocolSseTransportAdapter } from "@langchain/langgraph-sdk"; import { ProtocolSseTransportAdapter } from "@langchain/langgraph-sdk";
import { ROOT_PUMP_CHANNELS } from "@langchain/langgraph-sdk/stream"; import { ROOT_PUMP_CHANNELS } from "@langchain/langgraph-sdk/stream";
import { getCompatibleToken } from "../../../utils/authCompat";
import { import {
clearSuperAgentAccessToken,
getSuperAgentFrameworkApiUrl, getSuperAgentFrameworkApiUrl,
withAgentAuthorization, withAgentAuthorization,
} from "../utils/agentApi"; } from "../utils/agentApi";
import { getSuperAgentErrorMessage } from "../utils/agentApiParse";
import { superAgentLog, superAgentWarn } from "../utils/debugLog";
const BUSINESS_CHANNELS = [ const BUSINESS_CHANNELS = [
"custom", "custom",
@ -254,9 +256,16 @@ class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter {
async getState() { async getState() {
const requestVersion = ++this.stateRequestVersion; const requestVersion = ++this.stateRequestVersion;
const requestedThreadId = String(this.threadId || "").trim(); const requestedThreadId = String(this.threadId || "").trim();
// Agent 当前未提供 /threads/:threadId/state;useStream hydrate 只能使用 if (!requestedThreadId) {
// 已从事件流获得的快照,不能为新会话触发一个必然失败的 HTTP 请求。 this.latestState = null;
const state = this.latestState; this.onStateChange?.();
return null;
}
// ProtocolSseTransportAdapter 的 getState() 会请求当前 thread 的
// /state;其 fetchImpl 已绑定 fetchUseStreamTransport,因此会复用
// 8005 代理、Agent JWT 和 401 换票逻辑。
const state = await super.getState();
// 旧 state 快照不能在切换 thread 后覆盖新会话。 // 旧 state 快照不能在切换 thread 后覆盖新会话。
if ( if (
requestVersion === this.stateRequestVersion && requestVersion === this.stateRequestVersion &&
@ -534,14 +543,21 @@ const getEventDedupeKey = (event) => {
const normalizeStreamError = (error) => { const normalizeStreamError = (error) => {
const status = error?.status || error?.response?.status; const status = error?.status || error?.response?.status;
if (status === 401 || status === 403) return new Error("登录状态已失效,请重新登录"); const normalized = error instanceof Error ? error : new Error("智能体协议响应错误");
if (status === 404) return new Error("智能体服务暂不可用"); normalized.status = status || normalized.status || 0;
normalized.code = error?.code || normalized.code || "";
normalized.requestId = error?.requestId || error?.request_id || normalized.requestId || "";
normalized.message = getSuperAgentErrorMessage(normalized, "智能体协议响应错误");
if (status === 404) normalized.message = "智能体服务暂不可用";
const message = String(error?.message || ""); const message = String(error?.message || "");
if (status === 429 || /(?:^|\D)429(?:\D|$)|too many requests|rate limit/i.test(message)) { if (status === 429 || /(?:^|\D)429(?:\D|$)|too many requests|rate limit/i.test(message)) {
return new Error("事件流连接繁忙,正在自动恢复,请稍候"); normalized.message = "事件流连接繁忙,正在自动恢复,请稍候";
}
if (/sse|stream|network|fetch/i.test(message)) normalized.message = "智能体流式连接异常";
if (normalized.requestId && !normalized.message.includes(normalized.requestId)) {
normalized.message = `${normalized.message}(request_id: ${normalized.requestId})`;
} }
if (/sse|stream|network|fetch/i.test(message)) return new Error("智能体流式连接异常"); return normalized;
return error instanceof Error ? error : new Error("智能体协议响应错误");
}; };
const useSuperAgentStream = ({ const useSuperAgentStream = ({
@ -584,16 +600,31 @@ const useSuperAgentStream = ({
const localRunHasSeenLoadingRef = useRef(false); const localRunHasSeenLoadingRef = useRef(false);
const activeSessionIdRef = useRef(""); const activeSessionIdRef = useRef("");
activeSessionIdRef.current = activeSessionId; activeSessionIdRef.current = activeSessionId;
const loginToken = getCompatibleToken(); // Agent 请求只使用短时 Agent JWT;ai-center token 只用于 query api 换票。
// 保持登录态变化时重建 transport;Agent JWT 在 fetch 拦截器中换票。
// 会话切换由 callerOptions 的会话级 fetch 变化触发新 controller。
const defaultHeaders = useMemo( const defaultHeaders = useMemo(
() => ({ () => ({
Accept: "application/json, text/event-stream", Accept: "application/json, text/event-stream",
...(loginToken ? { "X-PEP-Token": loginToken } : {}),
}), }),
[loginToken] []
); );
const fetchAuthorizedStream = useCallback(async (input, requestInit) => {
let authorizedInit = await withAgentAuthorization(requestInit);
let response = await fetchEventStreamWithRecovery(input, authorizedInit);
if (response.status !== 401 || requestInit?.signal?.aborted) return response;
superAgentWarn("stream.auth", "agent_token_refresh", {
endpoint: String(input?.url || input || "").split("?")[0],
status: response.status,
});
clearSuperAgentAccessToken();
authorizedInit = await withAgentAuthorization(requestInit);
response = await fetchEventStreamWithRecovery(input, authorizedInit);
superAgentLog("stream.auth", "agent_token_refresh_result", {
endpoint: String(input?.url || input || "").split("?")[0],
status: response.status,
});
return response;
}, []);
const fetchUseStreamTransport = useCallback(async (...args) => { const fetchUseStreamTransport = useCallback(async (...args) => {
const [input, init = {}] = args; const [input, init = {}] = args;
if (isUseStreamHistoryDiscoveryRequest(input)) { if (isUseStreamHistoryDiscoveryRequest(input)) {
@ -629,9 +660,8 @@ const useSuperAgentStream = ({
}; };
} }
const authorizedInit = await withAgentAuthorization(requestInit); return fetchAuthorizedStream(input, requestInit);
return fetchEventStreamWithRecovery(input, authorizedInit); }, [fetchAuthorizedStream]);
}, []);
const eventTransport = useMemo( const eventTransport = useMemo(
() => { () => {
const transport = new SingleSubscriberSseTransportAdapter({ const transport = new SingleSubscriberSseTransportAdapter({
@ -898,6 +928,11 @@ const useSuperAgentStream = ({
// 切换会话后旧 Thread 的监听器可能在清理前再收到一个事件, // 切换会话后旧 Thread 的监听器可能在清理前再收到一个事件,
// 不能把旧会话的事件写入新会话的流式消息状态。 // 不能把旧会话的事件写入新会话的流式消息状态。
if (activeSessionIdRef.current !== listenerSessionId) return; if (activeSessionIdRef.current !== listenerSessionId) return;
superAgentLog("stream.event", "received", {
threadId: listenerSessionId,
eventType: event?.method || "unknown",
lifecycleType: event?.params?.data?.event || "",
});
handleMessageEvent(event); handleMessageEvent(event);
const eventPayload = getEventPayload(event) || {}; const eventPayload = getEventPayload(event) || {};
if (shouldRefreshRuntimeState({ if (shouldRefreshRuntimeState({

11
web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js

@ -58,6 +58,7 @@ import {
isSupportedCheckpointUiType, isSupportedCheckpointUiType,
normalizeCheckpoint, normalizeCheckpoint,
} from "../utils/checkpoint"; } from "../utils/checkpoint";
import { getSuperAgentErrorMessage } from "../utils/agentApiParse";
import { renderSuperAgentMarkdown } from "../utils/tenderMarkdown"; import { renderSuperAgentMarkdown } from "../utils/tenderMarkdown";
const buildSectionParams = (sessionId, sectionId) => ({ const buildSectionParams = (sessionId, sectionId) => ({
@ -647,7 +648,7 @@ const useSuperAgentWorkspace = ({
error, error,
}); });
if (!silent) { if (!silent) {
message.error(error?.message || "刷新正文工作区失败"); message.error(getSuperAgentErrorMessage(error, "刷新正文工作区失败"));
} }
return false; return false;
} }
@ -1332,7 +1333,7 @@ const useSuperAgentWorkspace = ({
workspaceType: workspaceData.type, workspaceType: workspaceData.type,
error, error,
}); });
message.error(error?.message || "保存修改失败"); message.error(getSuperAgentErrorMessage(error, "保存修改失败"));
} }
}; };
@ -1662,7 +1663,7 @@ const useSuperAgentWorkspace = ({
await refreshSectionAfterConflict(sessionId, { id: sectionId }); await refreshSectionAfterConflict(sessionId, { id: sectionId });
message.warning("章节已被智能体更新,请刷新后重新编辑"); message.warning("章节已被智能体更新,请刷新后重新编辑");
} else { } else {
message.error(error?.message || "章节保存失败,请稍后重试"); message.error(getSuperAgentErrorMessage(error, "章节保存失败,请稍后重试"));
} }
return false; return false;
} }
@ -1716,7 +1717,7 @@ const useSuperAgentWorkspace = ({
sessionId, sessionId,
error, error,
}); });
message.error(error?.message || "导出失败"); message.error(getSuperAgentErrorMessage(error, "导出失败"));
} }
}; };
@ -1802,7 +1803,7 @@ const useSuperAgentWorkspace = ({
error, error,
}); });
if (!silent) { if (!silent) {
message.error(error?.message || "获取正文章节失败"); message.error(getSuperAgentErrorMessage(error, "获取正文章节失败"));
} }
return false; return false;
} }

41
web/client/src/sections/superAgent/utils/agentApi.js

@ -25,6 +25,7 @@ import {
parseAgentErrorPayload, parseAgentErrorPayload,
shouldSendIdempotencyKey, shouldSendIdempotencyKey,
} from "./agentApiParse"; } from "./agentApiParse";
import { superAgentLog, superAgentWarn } from "./debugLog";
const ACCESS_TOKEN_PATH = "/_api/super-agent/access-token"; const ACCESS_TOKEN_PATH = "/_api/super-agent/access-token";
const TOKEN_REFRESH_SKEW_MS = 30 * 1000; const TOKEN_REFRESH_SKEW_MS = 30 * 1000;
@ -125,7 +126,15 @@ export const issueSuperAgentAccessToken = async ({ force = false } = {}) => {
const data = await readJsonSafe(response); const data = await readJsonSafe(response);
if (!response.ok || !data?.token) { if (!response.ok || !data?.token) {
clearSuperAgentAccessToken(); clearSuperAgentAccessToken();
throw parseAgentErrorPayload(data, response.status, "签发 Agent 访问令牌失败"); const error = parseAgentErrorPayload(data, response.status, "签发 Agent 访问令牌失败");
error.requestId =
error.requestId ||
response.headers.get("x-request-id") ||
response.headers.get("request-id") ||
"";
error.code = "AGENT_TOKEN_ISSUE_FAILED";
error.message = "Agent JWT 签发失败,请刷新页面重试";
throw error;
} }
const expiresAt = data.expires_at const expiresAt = data.expires_at
@ -155,13 +164,21 @@ const parseAgentResponse = async (response, fallbackMessage, returnMeta = false)
} }
const data = await readJsonSafe(response); const data = await readJsonSafe(response);
if (!response.ok) { if (!response.ok) {
throw parseAgentErrorPayload(data, response.status, fallbackMessage); const error = parseAgentErrorPayload(data, response.status, fallbackMessage);
error.requestId =
error.requestId ||
response.headers.get("x-request-id") ||
response.headers.get("request-id") ||
"";
throw error;
} }
return returnMeta return returnMeta
? { data, headers: response.headers, status: response.status } ? { data, headers: response.headers, status: response.status }
: data; : data;
}; };
const getRequestPath = (path, params = {}) => buildSuperAgentPath(path, params);
export const requestAgentJson = async ({ export const requestAgentJson = async ({
method, method,
path, path,
@ -206,7 +223,19 @@ export const requestAgentJson = async ({
} }
); );
superAgentLog("api.request", "response", {
endpoint: getRequestPath(path, params),
service,
method: methodName,
status: response.status,
});
if (response.status === 401 && retryOnUnauthorized) { if (response.status === 401 && retryOnUnauthorized) {
superAgentWarn("api.auth", "agent_token_refresh", {
endpoint: getRequestPath(path, params),
service,
status: response.status,
});
clearSuperAgentAccessToken(); clearSuperAgentAccessToken();
return requestAgentJson({ return requestAgentJson({
method: methodName, method: methodName,
@ -224,7 +253,13 @@ export const requestAgentJson = async ({
}); });
} }
return parseAgentResponse(response, fallbackMessage, returnMeta); return parseAgentResponse(
response,
response.status === 401
? "Agent 访问令牌已失效,换票失败,请刷新页面重试"
: fallbackMessage,
returnMeta
);
}; };
export const withAgentAuthorization = async (init = {}) => { export const withAgentAuthorization = async (init = {}) => {

27
web/client/src/sections/superAgent/utils/agentApiParse.js

@ -63,6 +63,33 @@ export const parseAgentErrorPayload = (data, status, fallbackMessage) => {
}); });
}; };
export const getSuperAgentErrorMessage = (error, fallbackMessage = "超级智能体请求失败") => {
const status = Number(error?.status || error?.response?.status || 0);
const code = String(error?.code || "").toUpperCase();
let message = String(error?.message || fallbackMessage);
if (code === "AGENT_TOKEN_ISSUE_FAILED") {
message = "Agent JWT 签发失败,请刷新页面重试";
} else if (code === "AUTHENTICATION_REQUIRED") {
message = "请先登录后再使用飞小尚超级智能体";
} else if (status === 401 || code === "UNAUTHORIZED") {
message = "Agent 访问令牌已失效,换票失败,请刷新页面重试";
} else if (status === 403) {
message = "当前账号没有执行此操作的权限";
} else if (status === 409) {
message = "请求与当前运行冲突,请稍后重试";
} else if (status === 412) {
message = "内容已被更新,请刷新后重试";
} else if (status === 422) {
message = message || "请求参数校验失败";
} else if (!message || /failed to fetch|networkerror/i.test(message)) {
message = "网络连接异常,请检查网络后重试";
}
const requestId = String(error?.requestId || error?.request_id || "").trim();
return requestId ? `${message}(request_id: ${requestId})` : message;
};
export const createIdempotencyKey = () => { export const createIdempotencyKey = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID(); return crypto.randomUUID();

10
web/client/src/sections/superAgent/utils/debugLog.js

@ -5,7 +5,6 @@
*/ */
"use strict"; "use strict";
const MAX_PREVIEW = 160;
const MAX_KEYS = 14; const MAX_KEYS = 14;
const getWindowEnv = () => { const getWindowEnv = () => {
@ -33,7 +32,6 @@ const summarizeString = (value) => {
return { return {
type: "string", type: "string",
len: text.length, len: text.length,
preview: text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW)}...` : text,
}; };
}; };
@ -44,8 +42,10 @@ export const summarizeSuperAgentPayload = (value, depth = 0) => {
if (value instanceof Error) { if (value instanceof Error) {
return { return {
name: value.name, name: value.name,
message: value.message, message: summarizeString(value.message),
stack: value.stack ? summarizeString(value.stack) : undefined, status: value.status || value.response?.status || 0,
code: value.code || "",
requestId: value.requestId || "",
}; };
} }
if (typeof File !== "undefined" && value instanceof File) { if (typeof File !== "undefined" && value instanceof File) {
@ -71,7 +71,7 @@ export const summarizeSuperAgentPayload = (value, depth = 0) => {
}; };
if (depth >= 2) return out; if (depth >= 2) return out;
keys.slice(0, MAX_KEYS).forEach((key) => { keys.slice(0, MAX_KEYS).forEach((key) => {
if (["content", "text", "message", "raw", "html", "markdown"].includes(key)) { if (/(?:token|authorization|secret|password|content|text|message|raw|html|markdown|input|body|file)/i.test(key)) {
out[key] = summarizeString(value[key]); out[key] = summarizeString(value[key]);
} else if (["payload", "data", "checkpoint", "task", "edits", "sections", "chapters"].includes(key)) { } else if (["payload", "data", "checkpoint", "task", "edits", "sections", "chapters"].includes(key)) {
out[key] = summarizeSuperAgentPayload(value[key], depth + 1); out[key] = summarizeSuperAgentPayload(value[key], depth + 1);

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

@ -23,10 +23,19 @@ import {
ACTIVE_SESSION_STORAGE_KEY, ACTIVE_SESSION_STORAGE_KEY,
DEFAULT_TASK_FOLDER_ID, DEFAULT_TASK_FOLDER_ID,
DEFAULT_TASK_FOLDER_NAME, DEFAULT_TASK_FOLDER_NAME,
LEGACY_SUPER_AGENT_STORAGE_KEYS,
TASK_SESSION_STORAGE_KEY, TASK_SESSION_STORAGE_KEY,
TASK_STORAGE_KEY, TASK_STORAGE_KEY,
} from "../constants"; } from "../constants";
let hasClearedLegacyStorage = false;
const ensureVersionedStorage = () => {
if (hasClearedLegacyStorage || typeof window === "undefined") return;
LEGACY_SUPER_AGENT_STORAGE_KEYS.forEach((key) => window.localStorage.removeItem(key));
hasClearedLegacyStorage = true;
};
export const normalizeSessionTitle = (title, fallbackTitle) => { export const normalizeSessionTitle = (title, fallbackTitle) => {
const sessionTitle = String(title || "").trim(); const sessionTitle = String(title || "").trim();
if (sessionTitle.toLowerCase() === "new session") return "新对话"; if (sessionTitle.toLowerCase() === "new session") return "新对话";
@ -35,6 +44,7 @@ export const normalizeSessionTitle = (title, fallbackTitle) => {
}; };
export const readTaskListFromStorage = () => { export const readTaskListFromStorage = () => {
ensureVersionedStorage();
try { try {
const rawValue = window.localStorage.getItem(TASK_STORAGE_KEY); const rawValue = window.localStorage.getItem(TASK_STORAGE_KEY);
const parsedValue = JSON.parse(rawValue || "[]"); const parsedValue = JSON.parse(rawValue || "[]");
@ -49,10 +59,12 @@ export const readTaskListFromStorage = () => {
}; };
export const saveTaskListToStorage = (taskList) => { export const saveTaskListToStorage = (taskList) => {
ensureVersionedStorage();
window.localStorage.setItem(TASK_STORAGE_KEY, JSON.stringify(taskList)); window.localStorage.setItem(TASK_STORAGE_KEY, JSON.stringify(taskList));
}; };
export const readTenderSessionIds = () => { export const readTenderSessionIds = () => {
ensureVersionedStorage();
try { try {
const rawValue = window.localStorage.getItem(TASK_SESSION_STORAGE_KEY); const rawValue = window.localStorage.getItem(TASK_SESSION_STORAGE_KEY);
const parsedValue = JSON.parse(rawValue || "[]"); const parsedValue = JSON.parse(rawValue || "[]");
@ -65,12 +77,14 @@ export const readTenderSessionIds = () => {
}; };
export const markTenderSessionId = (sessionId) => { export const markTenderSessionId = (sessionId) => {
ensureVersionedStorage();
if (!sessionId) return; if (!sessionId) return;
const nextIds = Array.from(new Set([String(sessionId), ...readTenderSessionIds()])); const nextIds = Array.from(new Set([String(sessionId), ...readTenderSessionIds()]));
window.localStorage.setItem(TASK_SESSION_STORAGE_KEY, JSON.stringify(nextIds)); window.localStorage.setItem(TASK_SESSION_STORAGE_KEY, JSON.stringify(nextIds));
}; };
export const removeTenderSessionId = (sessionId) => { export const removeTenderSessionId = (sessionId) => {
ensureVersionedStorage();
if (!sessionId) return; if (!sessionId) return;
const nextIds = readTenderSessionIds().filter((item) => item !== String(sessionId)); const nextIds = readTenderSessionIds().filter((item) => item !== String(sessionId));
window.localStorage.setItem(TASK_SESSION_STORAGE_KEY, JSON.stringify(nextIds)); window.localStorage.setItem(TASK_SESSION_STORAGE_KEY, JSON.stringify(nextIds));
@ -101,6 +115,7 @@ const normalizePendingEmptySessionMap = (parsedValue = {}) => {
}; };
export const readPendingEmptySessionMap = () => { export const readPendingEmptySessionMap = () => {
ensureVersionedStorage();
try { try {
const rawValue = window.localStorage.getItem(PENDING_EMPTY_SESSION_STORAGE_KEY); const rawValue = window.localStorage.getItem(PENDING_EMPTY_SESSION_STORAGE_KEY);
const parsedValue = JSON.parse(rawValue || "{}"); const parsedValue = JSON.parse(rawValue || "{}");
@ -117,6 +132,7 @@ export const readPendingEmptySession = (type = "") => {
}; };
export const markPendingEmptySession = (sessionId, type, folderId = null) => { export const markPendingEmptySession = (sessionId, type, folderId = null) => {
ensureVersionedStorage();
if (!sessionId || !type) return; if (!sessionId || !type) return;
const sessionMap = readPendingEmptySessionMap(); const sessionMap = readPendingEmptySessionMap();
const nextSessionMap = { const nextSessionMap = {
@ -134,6 +150,7 @@ export const markPendingEmptySession = (sessionId, type, folderId = null) => {
}; };
export const clearPendingEmptySession = (sessionId = "") => { export const clearPendingEmptySession = (sessionId = "") => {
ensureVersionedStorage();
if (!sessionId) { if (!sessionId) {
window.localStorage.removeItem(PENDING_EMPTY_SESSION_STORAGE_KEY); window.localStorage.removeItem(PENDING_EMPTY_SESSION_STORAGE_KEY);
return; return;
@ -159,6 +176,7 @@ export const clearPendingEmptySession = (sessionId = "") => {
}; };
export const readActiveSessionId = () => { export const readActiveSessionId = () => {
ensureVersionedStorage();
try { try {
return String(window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY) || "").trim(); return String(window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY) || "").trim();
} catch (error) { } catch (error) {
@ -167,11 +185,13 @@ export const readActiveSessionId = () => {
}; };
export const saveActiveSessionId = (sessionId) => { export const saveActiveSessionId = (sessionId) => {
ensureVersionedStorage();
if (!sessionId) return; if (!sessionId) return;
window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, String(sessionId)); window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, String(sessionId));
}; };
export const clearActiveSessionId = (sessionId = "") => { export const clearActiveSessionId = (sessionId = "") => {
ensureVersionedStorage();
if (!sessionId) { if (!sessionId) {
window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY); window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY);
return; return;
@ -183,6 +203,7 @@ export const clearActiveSessionId = (sessionId = "") => {
}; };
export const readCollapsedFolderMap = () => { export const readCollapsedFolderMap = () => {
ensureVersionedStorage();
try { try {
const rawValue = window.localStorage.getItem(FOLDER_COLLAPSED_STORAGE_KEY); const rawValue = window.localStorage.getItem(FOLDER_COLLAPSED_STORAGE_KEY);
const parsedValue = JSON.parse(rawValue || "{}"); const parsedValue = JSON.parse(rawValue || "{}");
@ -193,6 +214,7 @@ export const readCollapsedFolderMap = () => {
}; };
export const saveCollapsedFolderMap = (folderMap) => { export const saveCollapsedFolderMap = (folderMap) => {
ensureVersionedStorage();
window.localStorage.setItem(FOLDER_COLLAPSED_STORAGE_KEY, JSON.stringify(folderMap || {})); window.localStorage.setItem(FOLDER_COLLAPSED_STORAGE_KEY, JSON.stringify(folderMap || {}));
}; };

21
web/tests/agentApiAdapter.test.mjs

@ -12,6 +12,7 @@ const {
isAgentUuid, isAgentUuid,
parseAgentErrorPayload, parseAgentErrorPayload,
parseAgentPage, parseAgentPage,
getSuperAgentErrorMessage,
shouldSendIdempotencyKey, shouldSendIdempotencyKey,
} = await import("../client/src/sections/superAgent/utils/agentApiParse.js"); } = await import("../client/src/sections/superAgent/utils/agentApiParse.js");
const { const {
@ -81,6 +82,16 @@ test("attachment upload intent follows the v2 three-stage payload", () => {
assert.equal(isUploadIntentExpired(intent, Date.parse("2031-01-01T00:00:00Z")), true); assert.equal(isUploadIntentExpired(intent, Date.parse("2031-01-01T00:00:00Z")), true);
}); });
test("agent errors keep request_id without exposing credentials", () => {
const error = parseAgentErrorPayload(
{ code: "UNAUTHORIZED", message: "expired", request_id: "req_auth_1" },
401,
"fallback",
);
assert.match(getSuperAgentErrorMessage(error), /换票失败/);
assert.match(getSuperAgentErrorMessage(error), /req_auth_1/);
});
test("adapter source exchanges login token for agent jwt and omits identity headers", () => { test("adapter source exchanges login token for agent jwt and omits identity headers", () => {
const table = read("client", "src", "utils", "superAgentApiTable.js"); const table = read("client", "src", "utils", "superAgentApiTable.js");
const agentApi = read("client", "src", "sections", "superAgent", "utils", "agentApi.js"); const agentApi = read("client", "src", "sections", "superAgent", "utils", "agentApi.js");
@ -113,6 +124,9 @@ test("adapter source exchanges login token for agent jwt and omits identity head
assert.match(agentApi, /buildSuperAgentServiceUrl = \(path = "", service = superAgentBusinessService\)/); assert.match(agentApi, /buildSuperAgentServiceUrl = \(path = "", service = superAgentBusinessService\)/);
assert.match(agentApi, /superAgentFrameworkProxyMatch/); assert.match(agentApi, /superAgentFrameworkProxyMatch/);
assert.match(agentApi, /getSuperAgentFrameworkApiUrl/); assert.match(agentApi, /getSuperAgentFrameworkApiUrl/);
assert.match(agentApi, /response\.status === 401 && retryOnUnauthorized/);
assert.match(streamHook, /fetchAuthorizedStream/);
assert.doesNotMatch(streamHook, /X-PEP-Token/);
assert.match(resources, /completeAttachmentUpload/); assert.match(resources, /completeAttachmentUpload/);
assert.match(resources, /listAttachments/); assert.match(resources, /listAttachments/);
assert.match(resources, /getArtifactChapter/); assert.match(resources, /getArtifactChapter/);
@ -133,3 +147,10 @@ test("adapter source exchanges login token for agent jwt and omits identity head
assert.match(uploadUtils, /xhr\.send\(file\)/); assert.match(uploadUtils, /xhr\.send\(file\)/);
assert.match(resources, /cancelThreadRun/); assert.match(resources, /cancelThreadRun/);
}); });
test("superAgent diagnostics redact message and file payload text", () => {
const debugLog = read("client", "src", "sections", "superAgent", "utils", "debugLog.js");
assert.doesNotMatch(debugLog, /preview:/);
assert.match(debugLog, /token|authorization/);
assert.match(debugLog, /requestId/);
});

8
web/tests/superAgentFlowContract.test.mjs

@ -32,7 +32,8 @@ assert.match(streamHook, /stream\.submit\(/);
assert.match(streamHook, /stream\.respond\(/); assert.match(streamHook, /stream\.respond\(/);
assert.match(streamHook, /role:\s*"user"/); assert.match(streamHook, /role:\s*"user"/);
assert.doesNotMatch(streamHook, /type:\s*"human"/); assert.doesNotMatch(streamHook, /type:\s*"human"/);
assert.doesNotMatch(streamHook, /super\.getState\(\)/); assert.match(streamHook, /const state = await super\.getState\(\)/);
assert.match(streamHook, /hydratedState\?\.values\?\.messages/);
assert.match(streamHook, /const stopRun =/); assert.match(streamHook, /const stopRun =/);
assert.match(streamHook, /await stream\.stop\(\)/); assert.match(streamHook, /await stream\.stop\(\)/);
assert.match(streamHook, /disconnect:\s*stream\.disconnect/); assert.match(streamHook, /disconnect:\s*stream\.disconnect/);
@ -42,6 +43,11 @@ assert.match(streamHook, /status: 204/);
assert.match(streamHook, /currentThread\.close/); assert.match(streamHook, /currentThread\.close/);
assert.match(streamHook, /event\?\.method === "input\.requested"/); assert.match(streamHook, /event\?\.method === "input\.requested"/);
assert.match(streamHook, /runtimeInterrupts/); assert.match(streamHook, /runtimeInterrupts/);
assert.match(streamHook, /setRunPhase\("failed"\)/);
assert.match(streamHook, /setIsBusinessRunning\(false\)/);
assert.match(streamHook, /eventType: event\?\.method/);
assert.match(streamHook, /requestId/);
assert.match(container, /getSuperAgentErrorMessage/);
assert.doesNotMatch(container, /useSuperAgentChat/); assert.doesNotMatch(container, /useSuperAgentChat/);
assert.doesNotMatch(container, /respondSuperAgentCheckpoint/); assert.doesNotMatch(container, /respondSuperAgentCheckpoint/);
assert.doesNotMatch(container, /sendSuperAgentChat/); assert.doesNotMatch(container, /sendSuperAgentChat/);

8
web/tests/superAgentSidebarContract.test.mjs

@ -10,6 +10,7 @@ const sidebarHook = read("client", "src", "sections", "superAgent", "hooks", "us
const chatHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentChat.js"); const chatHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentChat.js");
const actions = read("client", "src", "sections", "superAgent", "actions", "api.js"); const actions = read("client", "src", "sections", "superAgent", "actions", "api.js");
const router = read("client", "src", "sections", "superAgent", "router.js"); const router = read("client", "src", "sections", "superAgent", "router.js");
const constants = read("client", "src", "sections", "superAgent", "constants.js");
function loadSessionUtils() { function loadSessionUtils() {
const transformed = const transformed =
@ -30,6 +31,13 @@ test("chat route remains /chat", () => {
assert.match(router, /path: "\/chat"/); assert.match(router, /path: "\/chat"/);
}); });
test("superAgent storage keys are versioned and legacy ids are cleared", () => {
assert.match(constants, /superAgent_v2_active_conversation_id/);
assert.match(constants, /LEGACY_SUPER_AGENT_STORAGE_KEYS/);
assert.match(sessionSource, /ensureVersionedStorage/);
assert.match(sessionSource, /removeItem\(key\)/);
});
test("sidebar session keeps conversation id and thread id separate", () => { test("sidebar session keeps conversation id and thread id separate", () => {
const session = normalizeSidebarSession({ const session = normalizeSidebarSession({
id: "11111111-1111-4111-8111-111111111111", id: "11111111-1111-4111-8111-111111111111",

Loading…
Cancel
Save