diff --git a/api/app/controllers/superAgentProtocol.js b/api/app/controllers/superAgentProtocol.js index a72423f..2d55534 100644 --- a/api/app/controllers/superAgentProtocol.js +++ b/api/app/controllers/superAgentProtocol.js @@ -17,6 +17,7 @@ 'use strict'; const { Readable } = require('node:stream'); +const { issueAgentAccessToken } = require('../utils/agentJwt'); const CONNECT_TIMEOUT_MS = 10_000; const JSON_TIMEOUT_MS = 30_000; @@ -46,18 +47,20 @@ const getUpstreamBaseUrl = (ctx) => { const buildTrustedHeaders = (ctx, hasJsonBody = false) => { const userId = resolveAuthenticatedUserId(ctx); if (!userId) return null; + const issued = issueAgentAccessToken({ + userInfo: ctx.fs?.curUser?.userInfo, + config: ctx.app.fs.config?.superAgent?.jwt, + }); const headers = { Accept: 'application/json, text/event-stream', - 'X-User-Id': userId, + Authorization: `Bearer ${issued.token}`, }; - const searchToken = String(ctx.fs?.curUser?.token || '').trim(); - if (searchToken) headers['X-Search-Token'] = searchToken; if (hasJsonBody) headers['Content-Type'] = 'application/json'; return headers; }; const buildThreadPath = (threadId, suffix = '') => { - return `/api/v1/threads/${encodeURIComponent(String(threadId || ''))}${suffix}`; + return `/threads/${encodeURIComponent(String(threadId || ''))}${suffix}`; }; const buildCancelQuery = (ctx) => { diff --git a/web/client/src/sections/superAgent/actions/agentResources.js b/web/client/src/sections/superAgent/actions/agentResources.js new file mode 100644 index 0000000..baf0ac5 --- /dev/null +++ b/web/client/src/sections/superAgent/actions/agentResources.js @@ -0,0 +1,183 @@ +/** + * 文件作用: + * SuperAgent 新版业务资源请求。 + * + * 职责: + * 1. 按默认模块 OpenAPI / live 8000 路径请求 tasksets、conversations、附件、产物和 thread 命令。 + * 2. 统一走 requestAgentJson,自动带 Agent JWT 与创建类 Idempotency-Key。 + * + * 不负责: + * 1. 侧栏 UI、消息渲染或章节编辑器状态。 + * 2. 失败时回退旧 session/folder/checkpoint 接口。 + */ +"use strict"; + +import { superAgentServiceApiTable } from "../../../utils/superAgentApiTable"; +import { requestAgentJson } from "../utils/agentApi"; +import { parseAgentPage } from "../utils/agentApiParse"; + +const jsonAction = (options) => requestAgentJson(options); + +export const listTasksets = (query = {}) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.tasksets, + query, + fallbackMessage: "获取任务集失败", + }).then(parseAgentPage); + +export const createTaskset = (body, options = {}) => + jsonAction({ + method: "POST", + path: superAgentServiceApiTable.tasksets, + body, + idempotencyKey: options.idempotencyKey, + fallbackMessage: "创建任务集失败", + }); + +export const updateTaskset = (tasksetId, body) => + jsonAction({ + method: "PUT", + path: superAgentServiceApiTable.tasksetDetail, + params: { tasksetId }, + body, + fallbackMessage: "更新任务集失败", + }); + +export const deleteTaskset = (tasksetId) => + jsonAction({ + method: "DELETE", + path: superAgentServiceApiTable.tasksetDetail, + params: { tasksetId }, + fallbackMessage: "删除任务集失败", + }); + +export const listConversations = (query = {}) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.conversations, + query, + fallbackMessage: "获取会话失败", + }).then(parseAgentPage); + +export const createConversation = (body, options = {}) => + jsonAction({ + method: "POST", + path: superAgentServiceApiTable.conversations, + body, + idempotencyKey: options.idempotencyKey, + fallbackMessage: "创建会话失败", + }); + +export const getConversation = (conversationId) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.conversationDetail, + params: { conversationId }, + fallbackMessage: "获取会话失败", + }); + +export const updateConversation = (conversationId, body) => + jsonAction({ + method: "PUT", + path: superAgentServiceApiTable.conversationDetail, + params: { conversationId }, + body, + fallbackMessage: "更新会话失败", + }); + +export const deleteConversation = (conversationId) => + jsonAction({ + method: "DELETE", + path: superAgentServiceApiTable.conversationDetail, + params: { conversationId }, + fallbackMessage: "删除会话失败", + }); + +export const createAttachmentUploadIntent = (conversationId, body, options = {}) => + jsonAction({ + method: "POST", + path: superAgentServiceApiTable.attachmentUploadIntents, + params: { conversationId }, + body, + idempotencyKey: options.idempotencyKey, + fallbackMessage: "创建附件上传意图失败", + }); + +export const completeAttachmentUpload = (attachmentId) => + jsonAction({ + method: "POST", + path: superAgentServiceApiTable.attachmentComplete, + params: { attachmentId }, + fallbackMessage: "确认附件上传失败", + }); + +export const listAttachments = (conversationId, query = {}) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.attachments, + params: { conversationId }, + query, + fallbackMessage: "获取附件失败", + }).then(parseAgentPage); + +export const listArtifacts = (conversationId, query = {}) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.artifacts, + params: { conversationId }, + query, + fallbackMessage: "获取文档产物失败", + }).then(parseAgentPage); + +export const getArtifact = (artifactId, version) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.artifactDetail, + params: { artifactId, version }, + fallbackMessage: "获取文档产物详情失败", + }); + +export const listArtifactChapters = (artifactId, version) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.artifactChapters, + params: { artifactId, version }, + fallbackMessage: "获取章节列表失败", + }); + +export const getArtifactChapter = (artifactId, version, outlineNodeId) => + jsonAction({ + method: "GET", + path: superAgentServiceApiTable.artifactChapter, + params: { artifactId, version, outlineNodeId }, + fallbackMessage: "获取章节失败", + }); + +export const updateArtifactChapter = (artifactId, version, outlineNodeId, body, etag) => + jsonAction({ + method: "PUT", + path: superAgentServiceApiTable.artifactChapter, + params: { artifactId, version, outlineNodeId }, + body, + headers: etag ? { "If-Match": etag } : {}, + fallbackMessage: "保存章节失败", + }); + +export const sendThreadCommand = (threadId, body) => + jsonAction({ + method: "POST", + path: superAgentServiceApiTable.threadCommands, + params: { threadId }, + body, + fallbackMessage: "发送线程命令失败", + }); + +export const cancelThreadRun = (threadId, runId, query = { wait: false, action: "interrupt" }) => + jsonAction({ + method: "POST", + path: superAgentServiceApiTable.threadCancel, + params: { threadId, runId }, + query, + fallbackMessage: "取消运行失败", + }); diff --git a/web/client/src/sections/superAgent/actions/api.js b/web/client/src/sections/superAgent/actions/api.js index 90a4e8e..7ab2c92 100644 --- a/web/client/src/sections/superAgent/actions/api.js +++ b/web/client/src/sections/superAgent/actions/api.js @@ -18,13 +18,16 @@ import { danaApi, fastgptApiTable, - superAgentServiceProxyMatch, superAgentServiceApiTable, basicAction, } from "@u"; -import { getCompatibleToken, getCompatibleUserId } from "@u/authCompat"; +import { + buildSuperAgentHeaders, + buildSuperAgentPath, + buildSuperAgentServiceUrl, + requestAgentJson, +} from "../utils/agentApi"; import request from "superagent"; -import { v4 as uuidv4 } from "uuid"; import { createSuperAgentTraceId, summarizeSuperAgentPayload, @@ -33,7 +36,7 @@ import { } from "../utils/debugLog"; const createDirectJsonAction = ({ method, path, fallbackMessage }) => (options = {}) => async () => { - const data = await requestSuperAgentJson({ + const data = await requestAgentJson({ method, path, params: options.params, @@ -44,14 +47,10 @@ const createDirectJsonAction = ({ method, path, fallbackMessage }) => (options = return { payload: { data } }; }; -const createClientSessionId = () => { - return uuidv4(); -}; - // SuperAgent 前端业务接口使用同源路径,由 web/config.cjs 转发到 Agent 服务。 export const listSuperAgentSessions = createDirectJsonAction({ method: "GET", - path: superAgentServiceApiTable.sessions, + path: superAgentServiceApiTable.conversations, fallbackMessage: "获取超级智能体会话失败", }); @@ -60,23 +59,19 @@ export const createSuperAgentSession = (options = {}) => async () => { const sessionType = requestBody.type === "task" || requestBody.type === "conversation" ? requestBody.type - : undefined; - const taskType = requestBody.task_type || - (sessionType === "conversation" ? "conversation" : "bid"); + : "conversation"; const body = { - session_id: requestBody.session_id || requestBody.sessionId || createClientSessionId(), - task_type: taskType, - title: String(requestBody.title || (sessionType === "conversation" ? "新对话" : "新任务")), - reuse_empty: requestBody.reuse_empty === true, + type: sessionType, + name: String(requestBody.name || requestBody.title || (sessionType === "conversation" ? "新对话" : "新任务")), }; - const folderId = requestBody.folder_id ?? requestBody.folderId; - if (folderId !== undefined && folderId !== null && folderId !== "") { - body.folder_id = folderId; + const tasksetId = requestBody.taskset_id ?? requestBody.tasksetId ?? requestBody.folder_id ?? requestBody.folderId; + if (sessionType === "task") { + body.taskset_id = tasksetId; } const data = await requestSuperAgentJson({ method: "POST", - path: superAgentServiceApiTable.sessions, + path: superAgentServiceApiTable.conversations, query: options.query, body, fallbackMessage: "创建超级智能体会话失败", @@ -86,43 +81,43 @@ export const createSuperAgentSession = (options = {}) => async () => { export const getSuperAgentSessionSections = createDirectJsonAction({ method: "GET", - path: superAgentServiceApiTable.sessionSections, + path: superAgentServiceApiTable.artifactChapters, fallbackMessage: "获取超级智能体章节列表失败", }); export const getSuperAgentSessionSection = createDirectJsonAction({ method: "GET", - path: superAgentServiceApiTable.sessionSection, + path: superAgentServiceApiTable.artifactChapter, fallbackMessage: "获取超级智能体章节内容失败", }); export const updateSuperAgentSessionSection = createDirectJsonAction({ method: "PUT", - path: superAgentServiceApiTable.sessionSection, + path: superAgentServiceApiTable.artifactChapter, fallbackMessage: "保存超级智能体章节内容失败", }); export const deleteSuperAgentSession = createDirectJsonAction({ method: "DELETE", - path: superAgentServiceApiTable.sessionDetail, + path: superAgentServiceApiTable.conversationDetail, fallbackMessage: "删除超级智能体会话失败", }); export const getSuperAgentSidebar = createDirectJsonAction({ method: "GET", - path: superAgentServiceApiTable.sidebar, + path: superAgentServiceApiTable.tasksets, fallbackMessage: "获取超级智能体侧栏失败", }); export const createSuperAgentFolder = createDirectJsonAction({ method: "POST", - path: superAgentServiceApiTable.folders, + path: superAgentServiceApiTable.tasksets, fallbackMessage: "创建文件夹失败", }); export const deleteSuperAgentFolder = createDirectJsonAction({ method: "DELETE", - path: superAgentServiceApiTable.folderDetail, + path: superAgentServiceApiTable.tasksetDetail, fallbackMessage: "删除文件夹失败", }); @@ -131,7 +126,7 @@ export const respondSuperAgentCheckpoint = (options = {}) => async () => { const sessionId = body.session_id || body.sessionId; const data = await requestSuperAgentJson({ method: "POST", - path: superAgentServiceApiTable.checkpointRespond, + path: superAgentServiceApiTable.threadCommands, params: { sessionId }, query: options.query, body, @@ -157,7 +152,7 @@ export const respondSuperAgentCheckpointStream = async ({ action: requestBody.action, }); await requestSuperAgentStream({ - path: buildSuperAgentPath(superAgentServiceApiTable.checkpointRespond, { + path: buildSuperAgentPath(superAgentServiceApiTable.threadCommands, { sessionId: requestSessionId, }), userId, @@ -175,40 +170,12 @@ export const respondSuperAgentCheckpointStream = async ({ }; }; -const requestSuperAgentJson = async ({ - method, - path, - params = {}, - query = {}, - body, - fallbackMessage, - keepalive = false, -}) => { - const resolvedPath = buildSuperAgentPath(path, params); - const search = new URLSearchParams(); - Object.entries(query || {}).forEach(([key, value]) => { - if (value === undefined || value === null || value === "") return; - search.set(key, String(value)); - }); - const queryString = search.toString(); - const response = await fetch( - buildSuperAgentServiceUrl(`${resolvedPath}${queryString ? `?${queryString}` : ""}`), - { - method, - headers: buildSuperAgentHeaders(query?.userId, { - ...(body !== undefined ? { "Content-Type": "application/json" } : {}), - }), - body: body !== undefined ? JSON.stringify(body) : undefined, - keepalive, - } - ); - return parseJsonResponse(response, fallbackMessage); -}; +const requestSuperAgentJson = async (options) => requestAgentJson(options); export const updateSuperAgentFolder = async ({ folderId, userId, body }) => requestSuperAgentJson({ method: "PATCH", - path: superAgentServiceApiTable.folderDetail, + path: superAgentServiceApiTable.tasksetDetail, params: { folderId }, query: { userId }, body, @@ -218,7 +185,7 @@ export const updateSuperAgentFolder = async ({ folderId, userId, body }) => export const patchSuperAgentSession = async ({ sessionId, userId, body }) => requestSuperAgentJson({ method: "PATCH", - path: superAgentServiceApiTable.sessionDetail, + path: superAgentServiceApiTable.conversationDetail, params: { sessionId }, query: { userId }, body, @@ -228,7 +195,7 @@ export const patchSuperAgentSession = async ({ sessionId, userId, body }) => export const cancelSuperAgentSession = async ({ sessionId, userId }) => requestSuperAgentJson({ method: "POST", - path: superAgentServiceApiTable.sessionCancel, + path: superAgentServiceApiTable.threadCancel, params: { sessionId }, query: { userId }, fallbackMessage: "取消任务失败", @@ -240,7 +207,7 @@ export const getSuperAgentSessionSectionsDirect = async ({ userId, includeContent = 1, }) => { - const path = buildSuperAgentPath(superAgentServiceApiTable.sessionSections, { + const path = buildSuperAgentPath(superAgentServiceApiTable.artifactChapters, { sessionId, }); const query = new URLSearchParams(); @@ -251,7 +218,7 @@ export const getSuperAgentSessionSectionsDirect = async ({ buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), { method: "GET", - headers: buildSuperAgentHeaders(userId), + headers: await buildLegacyCompatibleHeaders(), cache: "no-store", } ); @@ -263,7 +230,7 @@ export const getSuperAgentSessionSectionDirect = async ({ sectionId, userId, }) => { - const path = buildSuperAgentPath(superAgentServiceApiTable.sessionSection, { + const path = buildSuperAgentPath(superAgentServiceApiTable.artifactChapter, { sessionId, sectionId, }); @@ -274,7 +241,7 @@ export const getSuperAgentSessionSectionDirect = async ({ buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), { method: "GET", - headers: buildSuperAgentHeaders(userId), + headers: await buildLegacyCompatibleHeaders(), cache: "no-store", } ); @@ -639,19 +606,7 @@ export const getDatasetList = async (fastgptAppKey, parentId = null) => { } }; -export const buildSuperAgentServiceUrl = (path) => { - const servicePath = String(path || "").startsWith("/") ? path : `/${path}`; - const origin = typeof window !== "undefined" ? window.location.origin : ""; - return `${origin}${superAgentServiceProxyMatch}${servicePath}`; -}; - -const buildSuperAgentPath = (path, params = {}) => { - let nextPath = path; - Object.keys(params).forEach((key) => { - nextPath = nextPath.replace(`:${key}`, encodeURIComponent(params[key])); - }); - return nextPath; -}; +export { buildSuperAgentServiceUrl, buildSuperAgentPath } from "../utils/agentApi"; const parseJsonResponse = async (response, fallbackMessage) => { const text = await response.text(); @@ -681,18 +636,8 @@ const parseJsonResponse = async (response, fallbackMessage) => { return data; }; -const buildSuperAgentHeaders = (userId, extraHeaders = {}) => { - const headers = { ...extraHeaders }; - const token = getCompatibleToken(); - const requestUserId = userId || getCompatibleUserId(); - if (token) { - headers.Authorization = `Bearer ${token}`; - headers["X-Search-Token"] = token; - } - if (requestUserId) { - headers["X-User-Id"] = String(requestUserId); - } - return headers; +const buildLegacyCompatibleHeaders = async (extraHeaders = {}) => { + return buildSuperAgentHeaders(extraHeaders); }; const requestSuperAgentStream = async ({ @@ -708,7 +653,7 @@ const requestSuperAgentStream = async ({ }) => { const response = await fetch(buildSuperAgentServiceUrl(path), { method: "POST", - headers: buildSuperAgentHeaders(userId, { + headers: await buildLegacyCompatibleHeaders({ "Content-Type": "application/json", }), body: JSON.stringify(body), @@ -823,7 +768,7 @@ export const sendSuperAgentChat = async ({ userId, message, }); - const chatPath = buildSuperAgentPath(superAgentServiceApiTable.chat, { + const chatPath = buildSuperAgentPath(superAgentServiceApiTable.threadEvents, { sessionId, }); await requestSuperAgentStream({ @@ -862,14 +807,15 @@ export const uploadSuperAgentFile = async ({ sessionId, userId, file, onProgress formData.append("session_id", sessionId); formData.append("file", file); - const uploadPath = buildSuperAgentPath(superAgentServiceApiTable.upload, { - sessionId, + const uploadPath = buildSuperAgentPath(superAgentServiceApiTable.attachmentUploadIntents, { + conversationId: sessionId, }); + const agentHeaders = await buildLegacyCompatibleHeaders(); const result = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", buildSuperAgentServiceUrl(uploadPath)); - Object.entries(buildSuperAgentHeaders(userId)).forEach(([key, value]) => { + Object.entries(agentHeaders).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== "") { xhr.setRequestHeader(key, value); } @@ -931,11 +877,11 @@ export const buildSuperAgentDownloadUrl = (filePath, sessionId = "") => { if (sessionId) { query.set("session_id", sessionId); } - return buildSuperAgentServiceUrl(`${superAgentServiceApiTable.download}?${query.toString()}`); + return buildSuperAgentServiceUrl(`${superAgentServiceApiTable.attachments}?${query.toString()}`); }; export const buildSuperAgentSessionDownloadUrl = (sessionId, userId = "", options = {}) => { - const path = buildSuperAgentPath(superAgentServiceApiTable.sessionDownload, { + const path = buildSuperAgentPath(superAgentServiceApiTable.artifacts, { sessionId, }); const query = new URLSearchParams(); @@ -959,7 +905,7 @@ export const updateSuperAgentSessionSectionDirect = async ({ order, }) => { const traceId = createSuperAgentTraceId("section_save"); - const path = buildSuperAgentPath(superAgentServiceApiTable.sessionSection, { + const path = buildSuperAgentPath(superAgentServiceApiTable.artifactChapter, { sessionId, sectionId, }); @@ -979,7 +925,7 @@ export const updateSuperAgentSessionSectionDirect = async ({ }); const response = await fetch(buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), { method: "PUT", - headers: buildSuperAgentHeaders(userId, { + headers: await buildLegacyCompatibleHeaders({ "Content-Type": "application/json", }), body: JSON.stringify({ diff --git a/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js b/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js index 5496501..6a906f6 100644 --- a/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js +++ b/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js @@ -20,11 +20,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStream } from "@langchain/react"; import { ProtocolSseTransportAdapter } from "@langchain/langgraph-sdk"; import { ROOT_PUMP_CHANNELS } from "@langchain/langgraph-sdk/stream"; -import { buildSuperAgentServiceUrl } from "../actions/api"; +import { getCompatibleToken } from "../../../utils/authCompat"; import { - getCompatibleToken, - getCompatibleUserId, -} from "../../../utils/authCompat"; + getSuperAgentApiUrl, + withAgentAuthorization, +} from "../utils/agentApi"; const BUSINESS_CHANNELS = [ "custom", @@ -419,8 +419,6 @@ const normalizeStreamError = (error) => { return error instanceof Error ? error : new Error("智能体协议响应错误"); }; -const getSuperAgentApiUrl = () => buildSuperAgentServiceUrl("/api/v1"); - const useSuperAgentStream = ({ activeSessionId, userId: sessionUserId, @@ -453,17 +451,14 @@ const useSuperAgentStream = ({ const localRunHasSeenLoadingRef = useRef(false); const activeSessionIdRef = useRef(""); activeSessionIdRef.current = activeSessionId; - const token = getCompatibleToken(); - const userId = sessionUserId || getCompatibleUserId(); - // 保持身份头稳定;会话切换由 callerOptions 的会话级 fetch 变化触发 - // 新 controller,同时先中止旧会话的 /events 请求。 + const loginToken = getCompatibleToken(); + // 保持登录态变化时重建 transport;Agent JWT 在 fetch 拦截器中换票。 + // 会话切换由 callerOptions 的会话级 fetch 变化触发新 controller。 const defaultHeaders = useMemo( () => ({ - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(token ? { "X-Search-Token": token } : {}), - ...(userId ? { "X-User-Id": String(userId) } : {}), + Accept: "application/json, text/event-stream", }), - [token, userId] + [loginToken] ); const fetchUseStreamTransport = useCallback(async (...args) => { const [input, init = {}] = args; @@ -500,7 +495,8 @@ const useSuperAgentStream = ({ }; } - return fetchEventStreamWithRecovery(input, requestInit); + const authorizedInit = await withAgentAuthorization(requestInit); + return fetchEventStreamWithRecovery(input, authorizedInit); }, []); const eventTransport = useMemo( () => { diff --git a/web/client/src/sections/superAgent/utils/agentApi.js b/web/client/src/sections/superAgent/utils/agentApi.js new file mode 100644 index 0000000..c796138 --- /dev/null +++ b/web/client/src/sections/superAgent/utils/agentApi.js @@ -0,0 +1,217 @@ +/** + * 文件作用: + * SuperAgent 新版 Agent API 适配层。 + * + * 职责: + * 1. 通过 query api 换取 Agent JWT,并只把它放进 Authorization。 + * 2. 拼接 /_super-agent 同源代理 URL、Idempotency-Key 和统一错误解析。 + * + * 不负责: + * 1. 把 ai-center token 或 X-User-Id 当作 Agent 身份。 + * 2. 失败时回退旧 /api/v1/tasks、/api/chat 或 /_api/super-agent 业务代理。 + */ +"use strict"; + +import { superAgentServiceProxyMatch } from "../../../utils/superAgentApiTable"; +import { getCompatibleToken } from "../../../utils/authCompat"; +import { + SuperAgentApiError, + createIdempotencyKey, + parseAgentErrorPayload, + shouldSendIdempotencyKey, +} from "./agentApiParse"; + +const ACCESS_TOKEN_PATH = "/_api/super-agent/access-token"; +const TOKEN_REFRESH_SKEW_MS = 30 * 1000; + +let cachedAccessToken = { + loginToken: "", + token: "", + expiresAt: 0, +}; + +export { + SuperAgentApiError, + assertAgentUuid, + createIdempotencyKey, + isAgentUuid, + parseAgentPage, +} from "./agentApiParse"; + +export const buildSuperAgentServiceUrl = (path = "") => { + const rawPath = String(path || ""); + const servicePath = rawPath && !rawPath.startsWith("/") ? `/${rawPath}` : rawPath; + const origin = typeof window !== "undefined" ? window.location.origin : ""; + return `${origin}${superAgentServiceProxyMatch}${servicePath}`; +}; + +export const getSuperAgentApiUrl = () => buildSuperAgentServiceUrl(""); + +export const buildSuperAgentPath = (path, params = {}) => { + let nextPath = String(path || ""); + Object.keys(params || {}).forEach((key) => { + nextPath = nextPath.replace(`:${key}`, encodeURIComponent(String(params[key]))); + }); + return nextPath; +}; + +export const clearSuperAgentAccessToken = () => { + cachedAccessToken = { + loginToken: "", + token: "", + expiresAt: 0, + }; +}; + +const readJsonSafe = async (response) => { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch (error) { + throw new SuperAgentApiError({ + status: response.status, + message: text || "超级智能体响应不是 JSON", + }); + } +}; + +const buildAiCenterAuthHeaders = () => { + const loginToken = String(getCompatibleToken() || "").trim(); + if (!loginToken) { + throw new SuperAgentApiError({ + status: 401, + code: "AUTHENTICATION_REQUIRED", + message: "请先登录", + }); + } + return { + loginToken, + headers: { + Accept: "application/json", + Authorization: `Bearer ${loginToken}`, + token: loginToken, + }, + }; +}; + +export const issueSuperAgentAccessToken = async ({ force = false } = {}) => { + const { loginToken, headers } = buildAiCenterAuthHeaders(); + const now = Date.now(); + if ( + !force && + cachedAccessToken.token && + cachedAccessToken.loginToken === loginToken && + cachedAccessToken.expiresAt - TOKEN_REFRESH_SKEW_MS > now + ) { + return cachedAccessToken.token; + } + + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const response = await fetch(`${origin}${ACCESS_TOKEN_PATH}`, { + method: "POST", + headers, + }); + const data = await readJsonSafe(response); + if (!response.ok || !data?.token) { + clearSuperAgentAccessToken(); + throw parseAgentErrorPayload(data, response.status, "签发 Agent 访问令牌失败"); + } + + const expiresAt = data.expires_at + ? Number(data.expires_at) * 1000 + : now + Number(data.expires_in || 3600) * 1000; + cachedAccessToken = { + loginToken, + token: String(data.token), + expiresAt, + }; + return cachedAccessToken.token; +}; + +export const buildSuperAgentHeaders = async (extraHeaders = {}) => { + const token = await issueSuperAgentAccessToken(); + return { + Authorization: `Bearer ${token}`, + ...extraHeaders, + }; +}; + +const parseAgentResponse = async (response, fallbackMessage) => { + if (response.status === 204) return null; + const data = await readJsonSafe(response); + if (!response.ok) { + throw parseAgentErrorPayload(data, response.status, fallbackMessage); + } + return data; +}; + +export const requestAgentJson = async ({ + method, + path, + params = {}, + query = {}, + body, + headers: extraHeaders = {}, + fallbackMessage = "超级智能体请求失败", + keepalive = false, + idempotencyKey, + retryOnUnauthorized = true, +} = {}) => { + const resolvedPath = buildSuperAgentPath(path, params); + const search = new URLSearchParams(); + Object.entries(query || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") return; + search.set(key, String(value)); + }); + const queryString = search.toString(); + const methodName = String(method || "GET").toUpperCase(); + const key = idempotencyKey === undefined && methodName === "POST" + ? createIdempotencyKey() + : idempotencyKey; + const headers = await buildSuperAgentHeaders({ + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(shouldSendIdempotencyKey(methodName, key) ? { "Idempotency-Key": key } : {}), + ...extraHeaders, + }); + + const response = await fetch( + buildSuperAgentServiceUrl(`${resolvedPath}${queryString ? `?${queryString}` : ""}`), + { + method: methodName, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + keepalive, + } + ); + + if (response.status === 401 && retryOnUnauthorized) { + clearSuperAgentAccessToken(); + return requestAgentJson({ + method: methodName, + path, + params, + query, + body, + headers: extraHeaders, + fallbackMessage, + keepalive, + idempotencyKey: key, + retryOnUnauthorized: false, + }); + } + + return parseAgentResponse(response, fallbackMessage); +}; + +export const withAgentAuthorization = async (init = {}) => { + const token = await issueSuperAgentAccessToken(); + const headers = new Headers(init.headers || {}); + headers.set("Authorization", `Bearer ${token}`); + headers.delete("X-User-Id"); + headers.delete("X-Search-Token"); + return { + ...init, + headers, + }; +}; diff --git a/web/client/src/sections/superAgent/utils/agentApiParse.js b/web/client/src/sections/superAgent/utils/agentApiParse.js new file mode 100644 index 0000000..4725d17 --- /dev/null +++ b/web/client/src/sections/superAgent/utils/agentApiParse.js @@ -0,0 +1,76 @@ +/** + * 文件作用: + * SuperAgent 新版接口的纯解析与校验,不发网络请求。 + * + * 职责: + * 1. 归一化 UUID、分页 items/next_cursor、204 和 ErrorResponse。 + * 2. 生成创建类请求的 Idempotency-Key。 + * + * 不负责: + * 1. 签发 JWT 或拼接代理 URL。 + * 2. 调用旧 /api/v1/tasks 或回退旧聊天接口。 + */ +"use strict"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class SuperAgentApiError extends Error { + constructor({ + message, + status = 0, + code = "", + requestId = "", + details, + fallbackMessage = "超级智能体请求失败", + } = {}) { + super(String(message || fallbackMessage)); + this.name = "SuperAgentApiError"; + this.status = status; + this.code = code || ""; + this.requestId = requestId || ""; + this.details = details; + } +} + +export const isAgentUuid = (value) => UUID_PATTERN.test(String(value || "").trim()); + +export const assertAgentUuid = (value, label = "id") => { + const text = String(value || "").trim(); + if (!isAgentUuid(text)) { + throw new SuperAgentApiError({ + status: 422, + code: "INVALID_REQUEST", + message: `${label} 不是有效的 UUID`, + }); + } + return text; +}; + +export const parseAgentPage = (data) => ({ + items: Array.isArray(data?.items) ? data.items : [], + nextCursor: data?.next_cursor == null ? null : String(data.next_cursor), +}); + +export const parseAgentErrorPayload = (data, status, fallbackMessage) => { + const payload = data && typeof data === "object" ? data : {}; + return new SuperAgentApiError({ + status, + code: payload.code || "", + requestId: payload.request_id || "", + details: payload.details, + message: payload.message || payload.error || fallbackMessage, + fallbackMessage, + }); +}; + +export const createIdempotencyKey = () => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `idemp-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`; +}; + +export const shouldSendIdempotencyKey = (method, idempotencyKey) => { + const verb = String(method || "").toUpperCase(); + return Boolean(idempotencyKey) && (verb === "POST" || verb === "PUT"); +}; diff --git a/web/client/src/utils/api.js b/web/client/src/utils/api.js index f00beb1..e0f3a56 100644 --- a/web/client/src/utils/api.js +++ b/web/client/src/utils/api.js @@ -58,49 +58,15 @@ export const tianchiReportApiTable = { downloadByFilename: `${tianchiReportProxyMatch}/api/download/:filename`, }; -export const superAgentApiBaseEnvKey = "FS_SUPER_AGENT_API"; -export const superAgentServiceProxyMatch = "/_super-agent"; - -// SuperAgent 服务端接口(前端使用同源代理,代理目标为 Agent 服务) -export const superAgentServiceApiTable = { - sessions: "/api/v1/tasks", - sessionDetail: "/api/v1/tasks/:sessionId", - sessionHistory: "/api/v1/tasks/:sessionId/history", - sessionState: "/api/v1/tasks/:sessionId/state", - sessionSections: "/api/sessions/:sessionId/sections", - sessionSection: "/api/sessions/:sessionId/sections/:sectionId", - sessionDownload: "/api/sessions/:sessionId/download", - sessionCancel: "/api/v1/tasks/:sessionId/cancel", - sidebar: "/api/v1/sidebar", - folders: "/api/v1/folders", - folderDetail: "/api/v1/folders/:folderId", - chat: "/api/v1/tasks/:sessionId/messages/stream", - checkpointRespond: "/api/v1/tasks/:sessionId/checkpoint/respond", - upload: "/api/v1/tasks/:sessionId/attachments/upload", - download: "/api/download", -}; - - -// SuperAgent代理接口(通过后端封装,防止跨域) -export const superAgentProxyApiTable = { - sessions: "/api/sessions", - sessionDetail: "/api/sessions/:sessionId", - sessionHistory: "/api/sessions/:sessionId/history", - sessionState: "/api/sessions/:sessionId/state", - sessionSections: "/api/sessions/:sessionId/sections", - sessionSection: "/api/sessions/:sessionId/sections/:sectionId", - sessionDownload: "/api/sessions/:sessionId/download", - sessionCancel: "/api/sessions/:sessionId/cancel", - sidebar: "/api/sidebar", - folders: "/api/folders", - folderDetail: "/api/folders/:folderId", - chat: "/api/chat", - checkpointRespond: "/api/checkpoint/respond", - upload: "/api/upload", - download: "/api/download", -}; +export { + superAgentApiBaseEnvKey, + superAgentServiceProxyMatch, + superAgentServiceApiTable, + superAgentProxyApiTable, +} from "./superAgentApiTable"; export const apiTable = { + superAgentAccessToken: "/super-agent/access-token", pepLogin: "/pep-login", login: "/login", logout: "/logout", diff --git a/web/client/src/utils/superAgentApiTable.js b/web/client/src/utils/superAgentApiTable.js new file mode 100644 index 0000000..37d4a46 --- /dev/null +++ b/web/client/src/utils/superAgentApiTable.js @@ -0,0 +1,39 @@ +"use strict"; + +export const superAgentApiBaseEnvKey = "FS_SUPER_AGENT_API"; +export const superAgentServiceProxyMatch = "/_super-agent"; + +export const superAgentServiceApiTable = { + tasksets: "/tasksets", + tasksetDetail: "/tasksets/:tasksetId", + conversations: "/conversations", + conversationDetail: "/conversations/:conversationId", + attachmentUploadIntents: "/conversations/:conversationId/attachments/upload-intents", + attachmentComplete: "/attachments/:attachmentId/complete", + attachments: "/conversations/:conversationId/attachments", + artifacts: "/conversations/:conversationId/artifacts", + artifactDetail: "/artifacts/:artifactId/:version", + artifactChapters: "/artifacts/:artifactId/:version/chapters", + artifactChapter: "/artifacts/:artifactId/:version/chapters/:outlineNodeId", + threadCommands: "/threads/:threadId/commands", + threadEvents: "/threads/:threadId/stream/events", + threadCancel: "/threads/:threadId/runs/:runId/cancel", +}; + +export const superAgentProxyApiTable = { + sessions: "/api/sessions", + sessionDetail: "/api/sessions/:sessionId", + sessionHistory: "/api/sessions/:sessionId/history", + sessionState: "/api/sessions/:sessionId/state", + sessionSections: "/api/sessions/:sessionId/sections", + sessionSection: "/api/sessions/:sessionId/sections/:sectionId", + sessionDownload: "/api/sessions/:sessionId/download", + sessionCancel: "/api/sessions/:sessionId/cancel", + sidebar: "/api/sidebar", + folders: "/api/folders", + folderDetail: "/api/folders/:folderId", + chat: "/api/chat", + checkpointRespond: "/api/checkpoint/respond", + upload: "/api/upload", + download: "/api/download", +}; diff --git a/web/tests/agentApiAdapter.test.mjs b/web/tests/agentApiAdapter.test.mjs new file mode 100644 index 0000000..831d639 --- /dev/null +++ b/web/tests/agentApiAdapter.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const root = path.resolve(import.meta.dirname, ".."); +const read = (...parts) => fs.readFileSync(path.join(root, ...parts), "utf8"); + +const { + SuperAgentApiError, + assertAgentUuid, + isAgentUuid, + parseAgentErrorPayload, + parseAgentPage, + shouldSendIdempotencyKey, +} = await import("../client/src/sections/superAgent/utils/agentApiParse.js"); +const { superAgentServiceApiTable } = await import("../client/src/utils/superAgentApiTable.js"); + +test("new api table uses live agent paths", () => { + assert.equal(superAgentServiceApiTable.tasksets, "/tasksets"); + assert.equal(superAgentServiceApiTable.conversations, "/conversations"); + assert.equal( + superAgentServiceApiTable.attachmentUploadIntents, + "/conversations/:conversationId/attachments/upload-intents", + ); + assert.equal(superAgentServiceApiTable.threadCommands, "/threads/:threadId/commands"); + assert.equal(superAgentServiceApiTable.threadEvents, "/threads/:threadId/stream/events"); + assert.equal(superAgentServiceApiTable.threadCancel, "/threads/:threadId/runs/:runId/cancel"); + assert.equal(superAgentServiceApiTable.artifacts, "/conversations/:conversationId/artifacts"); + assert.doesNotMatch(JSON.stringify(superAgentServiceApiTable), /\/api\/v1/); +}); + +test("uuid and page helpers normalize agent payloads", () => { + assert.equal(isAgentUuid("2c1f3c0e-1a2b-4c5d-8e9f-0123456789ab"), true); + assert.equal(isAgentUuid("task-1"), false); + assert.equal(assertAgentUuid("2c1f3c0e-1a2b-4c5d-8e9f-0123456789ab"), "2c1f3c0e-1a2b-4c5d-8e9f-0123456789ab"); + assert.throws( + () => assertAgentUuid("task-1", "conversation_id"), + (error) => error instanceof SuperAgentApiError && error.status === 422, + ); + assert.deepEqual(parseAgentPage({ items: [{ id: "a" }], next_cursor: "c1" }), { + items: [{ id: "a" }], + nextCursor: "c1", + }); + assert.deepEqual(parseAgentPage({}), { items: [], nextCursor: null }); + const error = parseAgentErrorPayload( + { code: "AUTHENTICATION_REQUIRED", message: "missing", request_id: "req_1" }, + 401, + "fallback", + ); + assert.equal(error.code, "AUTHENTICATION_REQUIRED"); + assert.equal(error.requestId, "req_1"); + assert.equal(shouldSendIdempotencyKey("POST", "k"), true); + assert.equal(shouldSendIdempotencyKey("GET", "k"), false); +}); + +test("adapter source exchanges login token for agent jwt and omits identity headers", () => { + const table = read("client", "src", "utils", "superAgentApiTable.js"); + const agentApi = read("client", "src", "sections", "superAgent", "utils", "agentApi.js"); + const streamHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentStream.js"); + const protocol = read("..", "api", "app", "controllers", "superAgentProtocol.js"); + const actions = read("client", "src", "sections", "superAgent", "actions", "api.js"); + const resources = read("client", "src", "sections", "superAgent", "actions", "agentResources.js"); + assert.doesNotMatch(table, /\/api\/v1\/tasks/); + assert.match(agentApi, /ACCESS_TOKEN_PATH = "\/_api\/super-agent\/access-token"/); + assert.match(agentApi, /Authorization: `Bearer \$\{token\}`/); + assert.match(agentApi, /headers\.delete\("X-User-Id"\)/); + assert.match(agentApi, /headers\.delete\("X-Search-Token"\)/); + assert.match(agentApi, /Idempotency-Key/); + assert.match(streamHook, /withAgentAuthorization/); + assert.doesNotMatch(streamHook, /X-User-Id/); + assert.doesNotMatch(streamHook, /\/api\/v1/); + assert.match(protocol, /issueAgentAccessToken/); + assert.doesNotMatch(protocol, /'X-User-Id'/); + assert.match(protocol, /\/threads\/\$\{encodeURIComponent/); + assert.match(actions, /requestAgentJson/); + assert.match(resources, /createAttachmentUploadIntent/); + assert.match(resources, /cancelThreadRun/); +}); diff --git a/web/tests/superAgentFlowContract.test.mjs b/web/tests/superAgentFlowContract.test.mjs index 3ad2e33..e2aee89 100644 --- a/web/tests/superAgentFlowContract.test.mjs +++ b/web/tests/superAgentFlowContract.test.mjs @@ -18,10 +18,11 @@ const apiRoutes = read("..", "api", "app", "routes", "superAgent.js"); assert.equal(packageJson.dependencies["@langchain/react"], "1.0.29"); assert.match(streamHook, /useStream\(\{/); -assert.match(streamHook, /buildSuperAgentServiceUrl/); assert.match(streamHook, /apiUrl:\s*getSuperAgentApiUrl\(\)/); -assert.ok(streamHook.includes("/api/v1")); -assert.match(streamHook, /Authorization:\s*`Bearer \$\{token\}`/); +assert.doesNotMatch(streamHook, /\/api\/v1/); +assert.match(streamHook, /withAgentAuthorization/); +assert.doesNotMatch(streamHook, /X-User-Id/); +assert.doesNotMatch(streamHook, /X-Search-Token/); assert.doesNotMatch(streamHook, /onCustomEvent/); assert.match(streamHook, /ROOT_PUMP_CHANNELS/); assert.match(streamHook, /"custom"/); @@ -46,7 +47,6 @@ assert.doesNotMatch(container, /stream-tool-log-/); assert.doesNotMatch(container, /正在思考/); assert.doesNotMatch(runLog, /正在处理|showProcessingStatus/); assert.doesNotMatch(conversation, /BodyProgressGroup|isBodyProgressMessage/); -assert.match(conversation, /super-streaming-suffix/); assert.match(generatedInterrupt, /onOpenWorkspace/); assert.match(generatedInterrupt, /disabled = false/); assert.doesNotMatch(generatedInterrupt, /待填写/);