Browse Source

feat: 新版 API 适配层

master
Senor-Liu 1 month ago
parent
commit
fee92a2f50
  1. 11
      api/app/controllers/superAgentProtocol.js
  2. 183
      web/client/src/sections/superAgent/actions/agentResources.js
  3. 144
      web/client/src/sections/superAgent/actions/api.js
  4. 26
      web/client/src/sections/superAgent/hooks/useSuperAgentStream.js
  5. 217
      web/client/src/sections/superAgent/utils/agentApi.js
  6. 76
      web/client/src/sections/superAgent/utils/agentApiParse.js
  7. 48
      web/client/src/utils/api.js
  8. 39
      web/client/src/utils/superAgentApiTable.js
  9. 79
      web/tests/agentApiAdapter.test.mjs
  10. 8
      web/tests/superAgentFlowContract.test.mjs

11
api/app/controllers/superAgentProtocol.js

@ -17,6 +17,7 @@
'use strict'; 'use strict';
const { Readable } = require('node:stream'); const { Readable } = require('node:stream');
const { issueAgentAccessToken } = require('../utils/agentJwt');
const CONNECT_TIMEOUT_MS = 10_000; const CONNECT_TIMEOUT_MS = 10_000;
const JSON_TIMEOUT_MS = 30_000; const JSON_TIMEOUT_MS = 30_000;
@ -46,18 +47,20 @@ const getUpstreamBaseUrl = (ctx) => {
const buildTrustedHeaders = (ctx, hasJsonBody = false) => { const buildTrustedHeaders = (ctx, hasJsonBody = false) => {
const userId = resolveAuthenticatedUserId(ctx); const userId = resolveAuthenticatedUserId(ctx);
if (!userId) return null; if (!userId) return null;
const issued = issueAgentAccessToken({
userInfo: ctx.fs?.curUser?.userInfo,
config: ctx.app.fs.config?.superAgent?.jwt,
});
const headers = { const headers = {
Accept: 'application/json, text/event-stream', 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'; if (hasJsonBody) headers['Content-Type'] = 'application/json';
return headers; return headers;
}; };
const buildThreadPath = (threadId, suffix = '') => { const buildThreadPath = (threadId, suffix = '') => {
return `/api/v1/threads/${encodeURIComponent(String(threadId || ''))}${suffix}`; return `/threads/${encodeURIComponent(String(threadId || ''))}${suffix}`;
}; };
const buildCancelQuery = (ctx) => { const buildCancelQuery = (ctx) => {

183
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: "取消运行失败",
});

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

@ -18,13 +18,16 @@
import { import {
danaApi, danaApi,
fastgptApiTable, fastgptApiTable,
superAgentServiceProxyMatch,
superAgentServiceApiTable, superAgentServiceApiTable,
basicAction, basicAction,
} from "@u"; } from "@u";
import { getCompatibleToken, getCompatibleUserId } from "@u/authCompat"; import {
buildSuperAgentHeaders,
buildSuperAgentPath,
buildSuperAgentServiceUrl,
requestAgentJson,
} from "../utils/agentApi";
import request from "superagent"; import request from "superagent";
import { v4 as uuidv4 } from "uuid";
import { import {
createSuperAgentTraceId, createSuperAgentTraceId,
summarizeSuperAgentPayload, summarizeSuperAgentPayload,
@ -33,7 +36,7 @@ import {
} from "../utils/debugLog"; } from "../utils/debugLog";
const createDirectJsonAction = ({ method, path, fallbackMessage }) => (options = {}) => async () => { const createDirectJsonAction = ({ method, path, fallbackMessage }) => (options = {}) => async () => {
const data = await requestSuperAgentJson({ const data = await requestAgentJson({
method, method,
path, path,
params: options.params, params: options.params,
@ -44,14 +47,10 @@ const createDirectJsonAction = ({ method, path, fallbackMessage }) => (options =
return { payload: { data } }; return { payload: { data } };
}; };
const createClientSessionId = () => {
return uuidv4();
};
// SuperAgent 前端业务接口使用同源路径,由 web/config.cjs 转发到 Agent 服务。 // SuperAgent 前端业务接口使用同源路径,由 web/config.cjs 转发到 Agent 服务。
export const listSuperAgentSessions = createDirectJsonAction({ export const listSuperAgentSessions = createDirectJsonAction({
method: "GET", method: "GET",
path: superAgentServiceApiTable.sessions, path: superAgentServiceApiTable.conversations,
fallbackMessage: "获取超级智能体会话失败", fallbackMessage: "获取超级智能体会话失败",
}); });
@ -60,23 +59,19 @@ export const createSuperAgentSession = (options = {}) => async () => {
const sessionType = const sessionType =
requestBody.type === "task" || requestBody.type === "conversation" requestBody.type === "task" || requestBody.type === "conversation"
? requestBody.type ? requestBody.type
: undefined; : "conversation";
const taskType = requestBody.task_type ||
(sessionType === "conversation" ? "conversation" : "bid");
const body = { const body = {
session_id: requestBody.session_id || requestBody.sessionId || createClientSessionId(), type: sessionType,
task_type: taskType, name: String(requestBody.name || requestBody.title || (sessionType === "conversation" ? "新对话" : "新任务")),
title: String(requestBody.title || (sessionType === "conversation" ? "新对话" : "新任务")),
reuse_empty: requestBody.reuse_empty === true,
}; };
const folderId = requestBody.folder_id ?? requestBody.folderId; const tasksetId = requestBody.taskset_id ?? requestBody.tasksetId ?? requestBody.folder_id ?? requestBody.folderId;
if (folderId !== undefined && folderId !== null && folderId !== "") { if (sessionType === "task") {
body.folder_id = folderId; body.taskset_id = tasksetId;
} }
const data = await requestSuperAgentJson({ const data = await requestSuperAgentJson({
method: "POST", method: "POST",
path: superAgentServiceApiTable.sessions, path: superAgentServiceApiTable.conversations,
query: options.query, query: options.query,
body, body,
fallbackMessage: "创建超级智能体会话失败", fallbackMessage: "创建超级智能体会话失败",
@ -86,43 +81,43 @@ export const createSuperAgentSession = (options = {}) => async () => {
export const getSuperAgentSessionSections = createDirectJsonAction({ export const getSuperAgentSessionSections = createDirectJsonAction({
method: "GET", method: "GET",
path: superAgentServiceApiTable.sessionSections, path: superAgentServiceApiTable.artifactChapters,
fallbackMessage: "获取超级智能体章节列表失败", fallbackMessage: "获取超级智能体章节列表失败",
}); });
export const getSuperAgentSessionSection = createDirectJsonAction({ export const getSuperAgentSessionSection = createDirectJsonAction({
method: "GET", method: "GET",
path: superAgentServiceApiTable.sessionSection, path: superAgentServiceApiTable.artifactChapter,
fallbackMessage: "获取超级智能体章节内容失败", fallbackMessage: "获取超级智能体章节内容失败",
}); });
export const updateSuperAgentSessionSection = createDirectJsonAction({ export const updateSuperAgentSessionSection = createDirectJsonAction({
method: "PUT", method: "PUT",
path: superAgentServiceApiTable.sessionSection, path: superAgentServiceApiTable.artifactChapter,
fallbackMessage: "保存超级智能体章节内容失败", fallbackMessage: "保存超级智能体章节内容失败",
}); });
export const deleteSuperAgentSession = createDirectJsonAction({ export const deleteSuperAgentSession = createDirectJsonAction({
method: "DELETE", method: "DELETE",
path: superAgentServiceApiTable.sessionDetail, path: superAgentServiceApiTable.conversationDetail,
fallbackMessage: "删除超级智能体会话失败", fallbackMessage: "删除超级智能体会话失败",
}); });
export const getSuperAgentSidebar = createDirectJsonAction({ export const getSuperAgentSidebar = createDirectJsonAction({
method: "GET", method: "GET",
path: superAgentServiceApiTable.sidebar, path: superAgentServiceApiTable.tasksets,
fallbackMessage: "获取超级智能体侧栏失败", fallbackMessage: "获取超级智能体侧栏失败",
}); });
export const createSuperAgentFolder = createDirectJsonAction({ export const createSuperAgentFolder = createDirectJsonAction({
method: "POST", method: "POST",
path: superAgentServiceApiTable.folders, path: superAgentServiceApiTable.tasksets,
fallbackMessage: "创建文件夹失败", fallbackMessage: "创建文件夹失败",
}); });
export const deleteSuperAgentFolder = createDirectJsonAction({ export const deleteSuperAgentFolder = createDirectJsonAction({
method: "DELETE", method: "DELETE",
path: superAgentServiceApiTable.folderDetail, path: superAgentServiceApiTable.tasksetDetail,
fallbackMessage: "删除文件夹失败", fallbackMessage: "删除文件夹失败",
}); });
@ -131,7 +126,7 @@ export const respondSuperAgentCheckpoint = (options = {}) => async () => {
const sessionId = body.session_id || body.sessionId; const sessionId = body.session_id || body.sessionId;
const data = await requestSuperAgentJson({ const data = await requestSuperAgentJson({
method: "POST", method: "POST",
path: superAgentServiceApiTable.checkpointRespond, path: superAgentServiceApiTable.threadCommands,
params: { sessionId }, params: { sessionId },
query: options.query, query: options.query,
body, body,
@ -157,7 +152,7 @@ export const respondSuperAgentCheckpointStream = async ({
action: requestBody.action, action: requestBody.action,
}); });
await requestSuperAgentStream({ await requestSuperAgentStream({
path: buildSuperAgentPath(superAgentServiceApiTable.checkpointRespond, { path: buildSuperAgentPath(superAgentServiceApiTable.threadCommands, {
sessionId: requestSessionId, sessionId: requestSessionId,
}), }),
userId, userId,
@ -175,40 +170,12 @@ export const respondSuperAgentCheckpointStream = async ({
}; };
}; };
const requestSuperAgentJson = async ({ const requestSuperAgentJson = async (options) => requestAgentJson(options);
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);
};
export const updateSuperAgentFolder = async ({ folderId, userId, body }) => export const updateSuperAgentFolder = async ({ folderId, userId, body }) =>
requestSuperAgentJson({ requestSuperAgentJson({
method: "PATCH", method: "PATCH",
path: superAgentServiceApiTable.folderDetail, path: superAgentServiceApiTable.tasksetDetail,
params: { folderId }, params: { folderId },
query: { userId }, query: { userId },
body, body,
@ -218,7 +185,7 @@ export const updateSuperAgentFolder = async ({ folderId, userId, body }) =>
export const patchSuperAgentSession = async ({ sessionId, userId, body }) => export const patchSuperAgentSession = async ({ sessionId, userId, body }) =>
requestSuperAgentJson({ requestSuperAgentJson({
method: "PATCH", method: "PATCH",
path: superAgentServiceApiTable.sessionDetail, path: superAgentServiceApiTable.conversationDetail,
params: { sessionId }, params: { sessionId },
query: { userId }, query: { userId },
body, body,
@ -228,7 +195,7 @@ export const patchSuperAgentSession = async ({ sessionId, userId, body }) =>
export const cancelSuperAgentSession = async ({ sessionId, userId }) => export const cancelSuperAgentSession = async ({ sessionId, userId }) =>
requestSuperAgentJson({ requestSuperAgentJson({
method: "POST", method: "POST",
path: superAgentServiceApiTable.sessionCancel, path: superAgentServiceApiTable.threadCancel,
params: { sessionId }, params: { sessionId },
query: { userId }, query: { userId },
fallbackMessage: "取消任务失败", fallbackMessage: "取消任务失败",
@ -240,7 +207,7 @@ export const getSuperAgentSessionSectionsDirect = async ({
userId, userId,
includeContent = 1, includeContent = 1,
}) => { }) => {
const path = buildSuperAgentPath(superAgentServiceApiTable.sessionSections, { const path = buildSuperAgentPath(superAgentServiceApiTable.artifactChapters, {
sessionId, sessionId,
}); });
const query = new URLSearchParams(); const query = new URLSearchParams();
@ -251,7 +218,7 @@ export const getSuperAgentSessionSectionsDirect = async ({
buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`),
{ {
method: "GET", method: "GET",
headers: buildSuperAgentHeaders(userId), headers: await buildLegacyCompatibleHeaders(),
cache: "no-store", cache: "no-store",
} }
); );
@ -263,7 +230,7 @@ export const getSuperAgentSessionSectionDirect = async ({
sectionId, sectionId,
userId, userId,
}) => { }) => {
const path = buildSuperAgentPath(superAgentServiceApiTable.sessionSection, { const path = buildSuperAgentPath(superAgentServiceApiTable.artifactChapter, {
sessionId, sessionId,
sectionId, sectionId,
}); });
@ -274,7 +241,7 @@ export const getSuperAgentSessionSectionDirect = async ({
buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`),
{ {
method: "GET", method: "GET",
headers: buildSuperAgentHeaders(userId), headers: await buildLegacyCompatibleHeaders(),
cache: "no-store", cache: "no-store",
} }
); );
@ -639,19 +606,7 @@ export const getDatasetList = async (fastgptAppKey, parentId = null) => {
} }
}; };
export const buildSuperAgentServiceUrl = (path) => { export { buildSuperAgentServiceUrl, buildSuperAgentPath } from "../utils/agentApi";
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;
};
const parseJsonResponse = async (response, fallbackMessage) => { const parseJsonResponse = async (response, fallbackMessage) => {
const text = await response.text(); const text = await response.text();
@ -681,18 +636,8 @@ const parseJsonResponse = async (response, fallbackMessage) => {
return data; return data;
}; };
const buildSuperAgentHeaders = (userId, extraHeaders = {}) => { const buildLegacyCompatibleHeaders = async (extraHeaders = {}) => {
const headers = { ...extraHeaders }; return buildSuperAgentHeaders(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 requestSuperAgentStream = async ({ const requestSuperAgentStream = async ({
@ -708,7 +653,7 @@ const requestSuperAgentStream = async ({
}) => { }) => {
const response = await fetch(buildSuperAgentServiceUrl(path), { const response = await fetch(buildSuperAgentServiceUrl(path), {
method: "POST", method: "POST",
headers: buildSuperAgentHeaders(userId, { headers: await buildLegacyCompatibleHeaders({
"Content-Type": "application/json", "Content-Type": "application/json",
}), }),
body: JSON.stringify(body), body: JSON.stringify(body),
@ -823,7 +768,7 @@ export const sendSuperAgentChat = async ({
userId, userId,
message, message,
}); });
const chatPath = buildSuperAgentPath(superAgentServiceApiTable.chat, { const chatPath = buildSuperAgentPath(superAgentServiceApiTable.threadEvents, {
sessionId, sessionId,
}); });
await requestSuperAgentStream({ await requestSuperAgentStream({
@ -862,14 +807,15 @@ export const uploadSuperAgentFile = async ({ sessionId, userId, file, onProgress
formData.append("session_id", sessionId); formData.append("session_id", sessionId);
formData.append("file", file); formData.append("file", file);
const uploadPath = buildSuperAgentPath(superAgentServiceApiTable.upload, { const uploadPath = buildSuperAgentPath(superAgentServiceApiTable.attachmentUploadIntents, {
sessionId, conversationId: sessionId,
}); });
const agentHeaders = await buildLegacyCompatibleHeaders();
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", buildSuperAgentServiceUrl(uploadPath)); xhr.open("POST", buildSuperAgentServiceUrl(uploadPath));
Object.entries(buildSuperAgentHeaders(userId)).forEach(([key, value]) => { Object.entries(agentHeaders).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") { if (value !== undefined && value !== null && value !== "") {
xhr.setRequestHeader(key, value); xhr.setRequestHeader(key, value);
} }
@ -931,11 +877,11 @@ export const buildSuperAgentDownloadUrl = (filePath, sessionId = "") => {
if (sessionId) { if (sessionId) {
query.set("session_id", sessionId); query.set("session_id", sessionId);
} }
return buildSuperAgentServiceUrl(`${superAgentServiceApiTable.download}?${query.toString()}`); return buildSuperAgentServiceUrl(`${superAgentServiceApiTable.attachments}?${query.toString()}`);
}; };
export const buildSuperAgentSessionDownloadUrl = (sessionId, userId = "", options = {}) => { export const buildSuperAgentSessionDownloadUrl = (sessionId, userId = "", options = {}) => {
const path = buildSuperAgentPath(superAgentServiceApiTable.sessionDownload, { const path = buildSuperAgentPath(superAgentServiceApiTable.artifacts, {
sessionId, sessionId,
}); });
const query = new URLSearchParams(); const query = new URLSearchParams();
@ -959,7 +905,7 @@ export const updateSuperAgentSessionSectionDirect = async ({
order, order,
}) => { }) => {
const traceId = createSuperAgentTraceId("section_save"); const traceId = createSuperAgentTraceId("section_save");
const path = buildSuperAgentPath(superAgentServiceApiTable.sessionSection, { const path = buildSuperAgentPath(superAgentServiceApiTable.artifactChapter, {
sessionId, sessionId,
sectionId, sectionId,
}); });
@ -979,7 +925,7 @@ export const updateSuperAgentSessionSectionDirect = async ({
}); });
const response = await fetch(buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), { const response = await fetch(buildSuperAgentServiceUrl(`${path}${query.toString() ? `?${query.toString()}` : ""}`), {
method: "PUT", method: "PUT",
headers: buildSuperAgentHeaders(userId, { headers: await buildLegacyCompatibleHeaders({
"Content-Type": "application/json", "Content-Type": "application/json",
}), }),
body: JSON.stringify({ body: JSON.stringify({

26
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 { 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 { buildSuperAgentServiceUrl } from "../actions/api"; import { getCompatibleToken } from "../../../utils/authCompat";
import { import {
getCompatibleToken, getSuperAgentApiUrl,
getCompatibleUserId, withAgentAuthorization,
} from "../../../utils/authCompat"; } from "../utils/agentApi";
const BUSINESS_CHANNELS = [ const BUSINESS_CHANNELS = [
"custom", "custom",
@ -419,8 +419,6 @@ const normalizeStreamError = (error) => {
return error instanceof Error ? error : new Error("智能体协议响应错误"); return error instanceof Error ? error : new Error("智能体协议响应错误");
}; };
const getSuperAgentApiUrl = () => buildSuperAgentServiceUrl("/api/v1");
const useSuperAgentStream = ({ const useSuperAgentStream = ({
activeSessionId, activeSessionId,
userId: sessionUserId, userId: sessionUserId,
@ -453,17 +451,14 @@ const useSuperAgentStream = ({
const localRunHasSeenLoadingRef = useRef(false); const localRunHasSeenLoadingRef = useRef(false);
const activeSessionIdRef = useRef(""); const activeSessionIdRef = useRef("");
activeSessionIdRef.current = activeSessionId; activeSessionIdRef.current = activeSessionId;
const token = getCompatibleToken(); const loginToken = getCompatibleToken();
const userId = sessionUserId || getCompatibleUserId(); // 保持登录态变化时重建 transport;Agent JWT 在 fetch 拦截器中换票。
// 保持身份头稳定;会话切换由 callerOptions 的会话级 fetch 变化触发 // 会话切换由 callerOptions 的会话级 fetch 变化触发新 controller。
// 新 controller,同时先中止旧会话的 /events 请求。
const defaultHeaders = useMemo( const defaultHeaders = useMemo(
() => ({ () => ({
...(token ? { Authorization: `Bearer ${token}` } : {}), Accept: "application/json, text/event-stream",
...(token ? { "X-Search-Token": token } : {}),
...(userId ? { "X-User-Id": String(userId) } : {}),
}), }),
[token, userId] [loginToken]
); );
const fetchUseStreamTransport = useCallback(async (...args) => { const fetchUseStreamTransport = useCallback(async (...args) => {
const [input, init = {}] = 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( const eventTransport = useMemo(
() => { () => {

217
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,
};
};

76
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");
};

48
web/client/src/utils/api.js

@ -58,49 +58,15 @@ export const tianchiReportApiTable = {
downloadByFilename: `${tianchiReportProxyMatch}/api/download/:filename`, downloadByFilename: `${tianchiReportProxyMatch}/api/download/:filename`,
}; };
export const superAgentApiBaseEnvKey = "FS_SUPER_AGENT_API"; export {
export const superAgentServiceProxyMatch = "/_super-agent"; superAgentApiBaseEnvKey,
superAgentServiceProxyMatch,
// SuperAgent 服务端接口(前端使用同源代理,代理目标为 Agent 服务) superAgentServiceApiTable,
export const superAgentServiceApiTable = { superAgentProxyApiTable,
sessions: "/api/v1/tasks", } from "./superAgentApiTable";
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 const apiTable = { export const apiTable = {
superAgentAccessToken: "/super-agent/access-token",
pepLogin: "/pep-login", pepLogin: "/pep-login",
login: "/login", login: "/login",
logout: "/logout", logout: "/logout",

39
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",
};

79
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/);
});

8
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.equal(packageJson.dependencies["@langchain/react"], "1.0.29");
assert.match(streamHook, /useStream\(\{/); assert.match(streamHook, /useStream\(\{/);
assert.match(streamHook, /buildSuperAgentServiceUrl/);
assert.match(streamHook, /apiUrl:\s*getSuperAgentApiUrl\(\)/); assert.match(streamHook, /apiUrl:\s*getSuperAgentApiUrl\(\)/);
assert.ok(streamHook.includes("/api/v1")); assert.doesNotMatch(streamHook, /\/api\/v1/);
assert.match(streamHook, /Authorization:\s*`Bearer \$\{token\}`/); assert.match(streamHook, /withAgentAuthorization/);
assert.doesNotMatch(streamHook, /X-User-Id/);
assert.doesNotMatch(streamHook, /X-Search-Token/);
assert.doesNotMatch(streamHook, /onCustomEvent/); assert.doesNotMatch(streamHook, /onCustomEvent/);
assert.match(streamHook, /ROOT_PUMP_CHANNELS/); assert.match(streamHook, /ROOT_PUMP_CHANNELS/);
assert.match(streamHook, /"custom"/); assert.match(streamHook, /"custom"/);
@ -46,7 +47,6 @@ assert.doesNotMatch(container, /stream-tool-log-/);
assert.doesNotMatch(container, /正在思考/); assert.doesNotMatch(container, /正在思考/);
assert.doesNotMatch(runLog, /正在处理|showProcessingStatus/); assert.doesNotMatch(runLog, /正在处理|showProcessingStatus/);
assert.doesNotMatch(conversation, /BodyProgressGroup|isBodyProgressMessage/); assert.doesNotMatch(conversation, /BodyProgressGroup|isBodyProgressMessage/);
assert.match(conversation, /super-streaming-suffix/);
assert.match(generatedInterrupt, /onOpenWorkspace/); assert.match(generatedInterrupt, /onOpenWorkspace/);
assert.match(generatedInterrupt, /disabled = false/); assert.match(generatedInterrupt, /disabled = false/);
assert.doesNotMatch(generatedInterrupt, /待填写/); assert.doesNotMatch(generatedInterrupt, /待填写/);

Loading…
Cancel
Save