Browse Source

feat: 文件上传迁移

master
Senor-Liu 1 month ago
parent
commit
f08dda4fcc
  1. 8
      web/client/src/sections/superAgent/components/SuperAgentInputBar.jsx
  2. 15
      web/client/src/sections/superAgent/container/index.jsx
  3. 186
      web/client/src/sections/superAgent/hooks/useSuperAgentChat.js
  4. 94
      web/client/src/sections/superAgent/utils/attachmentUpload.js
  5. 33
      web/tests/agentApiAdapter.test.mjs

8
web/client/src/sections/superAgent/components/SuperAgentInputBar.jsx

@ -17,7 +17,7 @@
import React from "react";
import { Button, Input, Progress, Space, Tag, Upload } from "antd";
import { StopOutlined } from "@ant-design/icons";
import { CloseOutlined, StopOutlined } from "@ant-design/icons";
import SendIcon from "@assets/img/newAiChat/icon (3).png";
import FileUploadIcon from "@assets/img/aichat/file_upload.png";
@ -34,6 +34,7 @@ const SuperAgentInputBar = ({
isStreamingAnswer,
handleUploadFile,
handleRemoveUploadedFile,
handleCancelUpload,
handleSend,
handleStopAnswer,
}) => {
@ -108,11 +109,14 @@ const SuperAgentInputBar = ({
beforeUpload={handleUploadFile}
showUploadList={false}
>
<Button title="上传文件">
<Button title="上传文件" disabled={isUploadingFile}>
<img src={FileUploadIcon} alt="" />
上传文件
</Button>
</Upload>
{isUploadingFile ? (
<Button icon={<CloseOutlined />} onClick={handleCancelUpload} title="取消上传" />
) : null}
</Space>
<Button
type="primary"

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

@ -488,6 +488,7 @@ const SuperAgent = () => {
handleSelectTaskType,
handleUploadFile,
handleRemoveUploadedFile,
handleCancelUpload,
handleSelectTask: selectLegacyTask,
handleSelectChat: selectLegacyChat,
} = chat;
@ -1438,13 +1439,14 @@ const SuperAgent = () => {
const userInput = inputValue.trim();
const currentFileList = uploadedFileList;
const attachmentIds = Array.from(new Set(
currentFileList.map((file) => file.attachmentId).filter(Boolean)
currentFileList
.filter((file) => file.status === "ready")
.map((file) => file.attachmentId)
.filter(Boolean)
));
const fileReferences = currentFileList
.map((file) => file.url || file.localPath || file.name)
.filter(Boolean)
.join("\n");
const content = [fileReferences, userInput].filter(Boolean).join("\n");
const content = userInput || (attachmentIds.length
? `请处理已上传的附件:${currentFileList.map((file) => file.name).filter(Boolean).join("、")}`
: "");
const generatedInterrupt = activeGeneratedInterrupt;
if (generatedInterrupt?.value?.type === "generated_ui_interrupt" && userInput) {
if (stream.isLoading) return;
@ -2648,6 +2650,7 @@ const SuperAgent = () => {
isStreamingAnswer={isStreamingAnswer}
handleUploadFile={handleUploadFile}
handleRemoveUploadedFile={handleRemoveUploadedFile}
handleCancelUpload={handleCancelUpload}
handleSend={handleSend}
handleStopAnswer={handleStopAnswer}
/>

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

@ -24,9 +24,12 @@ import {
deleteSuperAgentSession,
respondSuperAgentCheckpointStream,
sendSuperAgentChat,
uploadSuperAgentFile,
} from "../actions/api";
import { createResourceFileFromUpload } from "../actions/resourceLibrary";
import {
completeAttachmentUpload,
createAttachmentUploadIntent,
listAttachments,
} from "../actions/agentResources";
import useSuperAgentSidebar from "./useSuperAgentSidebar";
import {
TASK_STAGE,
@ -62,6 +65,11 @@ import {
} from "../utils/workspace";
import { normalizeCheckpoint } from "../utils/checkpoint";
import { isWorkflowBodyText } from "../utils/tenderWorkflow";
import {
calculateFileSha256,
normalizeAttachmentUploadIntent,
uploadFileToIntent,
} from "../utils/attachmentUpload";
const getActionData = (res) => {
const payload = res?.payload ?? res;
@ -76,41 +84,19 @@ const ensureActionSuccess = (res, fallbackMessage) => {
return getActionData(res);
};
const toReadyUploadedFile = (attachment) => ({
id: String(attachment.id || attachment.attachment_id || attachment.attachmentId),
attachmentId: String(attachment.id || attachment.attachment_id || attachment.attachmentId),
name: attachment.file_name || attachment.filename || "未命名文件",
status: attachment.status,
});
const buildSessionParams = (sessionId) => ({
conversationId: sessionId,
sessionId,
":sessionId": sessionId,
});
const hasFilePathPrefix = (filePath = "") => {
return /^(https?:)?\/\//i.test(filePath) || /^(data|blob):/i.test(filePath);
};
const buildQiniuFilePath = (filePath = "") => {
const cleanFilePath = String(filePath || "").trim();
if (!cleanFilePath || hasFilePathPrefix(cleanFilePath)) return cleanFilePath;
const qiniuDomain = window?.env?.FS_QINIU_DOMAIN || "";
const cleanDomain = String(qiniuDomain || "").replace(/\/+$/, "");
if (!cleanDomain) return cleanFilePath;
return `${cleanDomain}/${cleanFilePath.replace(/^\/+/, "")}`;
};
//[文件上下文] 上传文件只向智能体传递访问地址,不附加固定业务文案。
const buildUploadedFileReference = (fileList = []) => {
return fileList
.map((file) => {
const fileName = file?.name || file?.originalFilename || "未命名文件";
const rawFilePath = file?.url || file?.path || file?.objectKey || file?.localPath || "";
const filePath = buildQiniuFilePath(rawFilePath);
if (!filePath) return "";
return `(文件名:${fileName},文件路径:${filePath})`;
})
.filter(Boolean)
.join("\n");
};
const useSuperAgentChat = ({
currentUserId,
openAuthModal,
@ -156,6 +142,7 @@ const useSuperAgentChat = ({
const streamingMessageKeyRef = useRef("");
const chatRunPromiseRef = useRef(Promise.resolve());
const chatRunActiveRef = useRef(false);
const uploadAbortControllerRef = useRef(null);
const creatingSessionRef = useRef(false);
const sidebarRefreshTimerRef = useRef(null);
const artifactProgressSessionRef = useRef("");
@ -168,6 +155,33 @@ const useSuperAgentChat = ({
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
useEffect(() => {
const conversationId = String(activeConversationId || "");
if (!conversationId) {
setUploadedFileList([]);
return undefined;
}
let active = true;
listAttachments(conversationId)
.then((page) => {
if (!active) return;
setUploadedFileList(
page.items
.filter((attachment) => attachment.status === "ready" && !attachment.message_id)
.map(toReadyUploadedFile)
);
})
.catch((error) => {
superAgentWarn("chat.upload", "attachment_list_failed", {
conversationId,
error: error?.message,
});
});
return () => {
active = false;
};
}, [activeConversationId]);
const activeTask = useMemo(
() => taskList.find((task) => task.id === activeTaskId) || null,
[activeTaskId, taskList]
@ -357,6 +371,9 @@ const useSuperAgentChat = ({
if (chatAbortControllerRef.current) {
chatAbortControllerRef.current.abort();
}
if (uploadAbortControllerRef.current) {
uploadAbortControllerRef.current.abort();
}
if (sidebarRefreshTimerRef.current) {
window.clearTimeout(sidebarRefreshTimerRef.current);
}
@ -398,6 +415,7 @@ const useSuperAgentChat = ({
folderId = null,
title = "",
shouldResetMessages = false,
returnConversationId = false,
} = {}) => {
if (!currentUserId) {
openAuthModal("login");
@ -469,7 +487,7 @@ const useSuperAgentChat = ({
}
await loadSidebar({ silent: true });
return nextSessionId;
return returnConversationId ? nextSessionId : nextThreadId;
};
const getCurrentSessionType = () => {
@ -1545,14 +1563,14 @@ const useSuperAgentChat = ({
//[执行上传文件的实际逻辑]
const performUpload = (file, fileName, currentSessionType) => {
const currentSessionId = activeSessionId || activeTaskId;
const shouldCreateSession = !currentSessionId;
const currentConversationId = activeConversationId || activeTaskId;
const shouldCreateSession = !currentConversationId;
const shouldUseTaskSession =
currentSessionType === "task" || (shouldCreateSession && isTaskRoute);
const shouldCreateNewTask = shouldCreateSession && shouldUseTaskSession;
superAgentLog("chat.upload", "perform.start", {
fileName,
currentSessionId,
currentConversationId,
currentSessionType,
shouldCreateSession,
shouldUseTaskSession,
@ -1583,23 +1601,32 @@ const useSuperAgentChat = ({
status: "preparing",
});
try {
const sessionId =
currentSessionId ||
const conversationId =
currentConversationId ||
(await createAndActivateSession({
isTaskSession: shouldUseTaskSession,
title: shouldUseTaskSession
? fileName.replace(/\.[^.]+$/, "") || "AI 标书任务"
: "",
returnConversationId: true,
}));
superAgentLog("chat.upload", "session.ready", {
sessionId,
conversationId,
fileName,
shouldUseTaskSession,
});
const uploadResult = await uploadSuperAgentFile({
sessionId,
userId: currentUserId,
file,
const sha256 = await calculateFileSha256(file);
const uploadIntent = normalizeAttachmentUploadIntent(
await createAttachmentUploadIntent(conversationId, {
file_name: fileName,
mime_type: file.type || "application/octet-stream",
file_size: Number(file.size || 0),
...(sha256 ? { sha256 } : {}),
})
);
uploadAbortControllerRef.current = new AbortController();
await uploadFileToIntent(uploadIntent, file, {
signal: uploadAbortControllerRef.current.signal,
onProgress: (progress) => {
setUploadProgress({
fileName,
@ -1613,57 +1640,36 @@ const useSuperAgentChat = ({
percent: 100,
status: "processing",
}));
const uploadData = uploadResult?.data || uploadResult || {};
try {
await createResourceFileFromUpload(uploadData);
} catch (resourceError) {
superAgentWarn("chat.upload", "resource_library_save_failed", {
fileName,
error: resourceError?.message,
});
message.warning("文件已上传,但保存到资源库失败");
const uploadData = await completeAttachmentUpload(uploadIntent.attachmentId);
const attachmentPage = await listAttachments(conversationId);
const readyAttachment = attachmentPage.items.find((attachment) =>
String(attachment.id || attachment.attachment_id || attachment.attachmentId) === uploadIntent.attachmentId
) || uploadData?.data || uploadData || {};
if (readyAttachment.status !== "ready") {
throw new Error("附件尚未准备完成,请稍后重试");
}
const uploadedFileName =
uploadData.filename ||
uploadData.original_filename ||
uploadData.originalFilename ||
readyAttachment.file_name ||
readyAttachment.filename ||
readyAttachment.original_filename ||
readyAttachment.originalFilename ||
fileName;
const rawUploadedFilePath =
uploadData.path ||
uploadData.url ||
uploadData.object_key ||
uploadData.objectKey ||
"";
const uploadedFilePath = buildQiniuFilePath(rawUploadedFilePath);
const uploadedLocalPath = uploadData.local_path || uploadData.localPath || "";
const uploadedFileStorage = uploadData.storage || "";
const normalizedUploadedFileStorage =
uploadedFileStorage || uploadData.storage_provider || uploadData.storageProvider || "";
superAgentLog("chat.upload", "upload.done", {
sessionId,
conversationId,
uploadedFileName,
uploadedFilePath,
uploadedLocalPath,
uploadedFileStorage: normalizedUploadedFileStorage,
uploadResult,
attachmentId: uploadIntent.attachmentId,
});
const uploadedFile = toReadyUploadedFile({
...readyAttachment,
id: uploadIntent.attachmentId,
file_name: uploadedFileName,
status: "ready",
});
const uploadedFile = {
id: `${sessionId}_${uploadedFileName}_${Date.now()}`,
attachmentId: uploadData.id || "",
name: uploadedFileName,
url: uploadedFilePath,
objectKey: uploadData.object_key || uploadData.objectKey || "",
localPath: uploadedLocalPath,
storage: normalizedUploadedFileStorage,
};
if (shouldCreateNewTask) {
updateActiveTask({
title: uploadedFileName.replace(/\.[^.]+$/, "") || "AI 标书任务",
fileName: uploadedFileName,
filePath: uploadedFilePath,
localPath: uploadedLocalPath,
fileStorage: normalizedUploadedFileStorage,
});
}
@ -1677,6 +1683,7 @@ const useSuperAgentChat = ({
});
message.error(error?.message || "上传文件失败");
} finally {
uploadAbortControllerRef.current = null;
setIsUploadingFile(false);
setUploadProgress(null);
}
@ -1689,6 +1696,10 @@ const useSuperAgentChat = ({
setUploadedFileList((prev) => prev.filter((file) => file.id !== fileId));
};
const handleCancelUpload = () => {
uploadAbortControllerRef.current?.abort();
};
const confirmBasicInfo = () => {
superAgentLog("chat.confirm", "basic_info", { activeSessionId, activeTaskId });
setTaskStage(TASK_STAGE.SCORE);
@ -1727,8 +1738,9 @@ const useSuperAgentChat = ({
const handleSend = () => {
const userInput = inputValue.trim();
const currentFileList = uploadedFileList;
const fileReferenceText = buildUploadedFileReference(currentFileList);
const finalMessage = [fileReferenceText, userInput].filter(Boolean).join("\n");
const finalMessage = userInput || (currentFileList.length
? `请处理已上传的附件:${currentFileList.map((file) => file.name).filter(Boolean).join("、")}`
: "");
if (!finalMessage || isStreamingAnswer) return;
@ -1770,7 +1782,10 @@ const useSuperAgentChat = ({
//[文件引用写入历史展示字段,刷新或切换会话后才能还原文件标签]
displayContent: finalMessage,
attachmentIds: Array.from(
new Set(currentFileList.map((file) => file.attachmentId).filter(Boolean))
new Set(currentFileList
.filter((file) => file.status === "ready")
.map((file) => file.attachmentId)
.filter(Boolean))
),
});
};
@ -1887,6 +1902,7 @@ const useSuperAgentChat = ({
handleTenderQuickStart,
handleUploadFile,
handleRemoveUploadedFile,
handleCancelUpload,
confirmBasicInfo,
confirmScoreItems,
confirmCatalog,

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

@ -0,0 +1,94 @@
/**
* Attachment upload primitives for the Agent upload-intent protocol.
*/
"use strict";
const getPayload = (value) => value?.data ?? value ?? {};
export const calculateFileSha256 = async (file) => {
if (!file?.arrayBuffer || !globalThis.crypto?.subtle?.digest) return "";
const buffer = await file.arrayBuffer();
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
export const normalizeAttachmentUploadIntent = (response) => {
const payload = getPayload(response);
const upload = getPayload(payload.upload);
const attachment = getPayload(payload.attachment);
const attachmentId = attachment.id || payload.attachment_id || payload.attachmentId || payload.id || "";
const method = String(upload.method || "").toUpperCase();
if (!attachmentId || !upload.url || !["POST", "PUT"].includes(method)) {
throw new Error("上传意图响应不完整");
}
return {
attachmentId: String(attachmentId),
expiresAt: upload.expires_at || upload.expiresAt || payload.expires_at || payload.expiresAt || "",
upload: {
method,
url: upload.url,
fields: upload.fields && typeof upload.fields === "object" ? upload.fields : {},
headers: upload.headers && typeof upload.headers === "object" ? upload.headers : {},
},
};
};
export const isUploadIntentExpired = (intent, now = Date.now()) => {
if (!intent?.expiresAt) return false;
const expiresAt = Date.parse(intent.expiresAt);
return Number.isFinite(expiresAt) && expiresAt <= now;
};
export const uploadFileToIntent = (intent, file, { onProgress, signal } = {}) => {
if (isUploadIntentExpired(intent)) {
return Promise.reject(new Error("上传凭证已过期,请重新选择文件"));
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
xhr.open(intent.upload.method, intent.upload.url);
Object.entries(intent.upload.headers || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
xhr.setRequestHeader(key, String(value));
}
});
xhr.upload.onprogress = (event) => {
const total = Number(event.total || file?.size || 0);
const loaded = Number(event.loaded || 0);
onProgress?.({
loaded,
total,
percent: total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0,
});
};
xhr.onerror = () => reject(new Error("上传文件失败,请检查网络连接"));
xhr.onabort = () => reject(new Error("文件上传已取消"));
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
return;
}
reject(new Error(`文件直传失败(${xhr.status || "网络错误"})`));
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
if (intent.upload.method === "POST") {
const formData = new FormData();
Object.entries(intent.upload.fields || {}).forEach(([key, value]) => {
formData.append(key, String(value));
});
formData.append("file", file);
xhr.send(formData);
return;
}
xhr.send(file);
});
};

33
web/tests/agentApiAdapter.test.mjs

@ -15,6 +15,10 @@ const {
shouldSendIdempotencyKey,
} = await import("../client/src/sections/superAgent/utils/agentApiParse.js");
const { superAgentServiceApiTable } = await import("../client/src/utils/superAgentApiTable.js");
const {
isUploadIntentExpired,
normalizeAttachmentUploadIntent,
} = await import("../client/src/sections/superAgent/utils/attachmentUpload.js");
test("new api table uses live agent paths", () => {
assert.equal(superAgentServiceApiTable.tasksets, "/tasksets");
@ -54,6 +58,23 @@ test("uuid and page helpers normalize agent payloads", () => {
assert.equal(shouldSendIdempotencyKey("GET", "k"), false);
});
test("attachment upload intent follows the v2 three-stage payload", () => {
const intent = normalizeAttachmentUploadIntent({
attachment: { id: "2c1f3c0e-1a2b-4c5d-8e9f-0123456789ab" },
upload: {
method: "POST",
url: "https://upload.example.test",
fields: { token: "opaque", key: "uploads/file.txt" },
expires_at: "2030-01-01T00:00:00Z",
},
});
assert.equal(intent.attachmentId, "2c1f3c0e-1a2b-4c5d-8e9f-0123456789ab");
assert.equal(intent.upload.method, "POST");
assert.equal(intent.upload.fields.key, "uploads/file.txt");
assert.equal(isUploadIntentExpired(intent, Date.parse("2029-01-01T00:00:00Z")), false);
assert.equal(isUploadIntentExpired(intent, Date.parse("2031-01-01T00:00:00Z")), true);
});
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");
@ -61,6 +82,8 @@ test("adapter source exchanges login token for agent jwt and omits identity head
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");
const chatHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentChat.js");
const uploadUtils = read("client", "src", "sections", "superAgent", "utils", "attachmentUpload.js");
assert.doesNotMatch(table, /\/api\/v1\/tasks/);
assert.match(agentApi, /ACCESS_TOKEN_PATH = "\/_api\/super-agent\/access-token"/);
assert.match(agentApi, /Authorization: `Bearer \$\{token\}`/);
@ -75,5 +98,15 @@ test("adapter source exchanges login token for agent jwt and omits identity head
assert.match(protocol, /\/threads\/\$\{encodeURIComponent/);
assert.match(actions, /requestAgentJson/);
assert.match(resources, /createAttachmentUploadIntent/);
assert.match(resources, /completeAttachmentUpload/);
assert.match(resources, /listAttachments/);
assert.match(chatHook, /createAttachmentUploadIntent\(conversationId/);
assert.match(chatHook, /completeAttachmentUpload\(uploadIntent\.attachmentId\)/);
assert.match(chatHook, /listAttachments\(conversationId\)/);
assert.match(chatHook, /attachment\.status === "ready" && !attachment\.message_id/);
assert.match(chatHook, /handleCancelUpload/);
assert.doesNotMatch(chatHook, /uploadSuperAgentFile/);
assert.match(uploadUtils, /FormData/);
assert.match(uploadUtils, /xhr\.send\(file\)/);
assert.match(resources, /cancelThreadRun/);
});

Loading…
Cancel
Save