Merge branch 'main' into next

# Conflicts:
#	packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/utils.ts
#	packages/plugins/@nocobase/plugin-ai/src/client/__tests__/chatbox/uploadAttachment.test.ts
#	packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/Sender.tsx
#	packages/plugins/@nocobase/plugin-ai/src/client/ai-employees/chatbox/hooks/useUploadFiles.ts
This commit is contained in:
Drol
2026-07-29 15:24:30 +08:00
7 changed files with 180 additions and 11 deletions
@@ -0,0 +1,43 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { describe, expect, it } from 'vitest';
import {
AI_EMPLOYEE_ATTACHMENT_COUNT_LIMIT,
AI_EMPLOYEE_ATTACHMENT_SIZE_LIMIT_DEFAULT,
formatAttachmentSizeLimit,
resolveStorageSizeLimit,
validateAIEmployeeAttachmentLimits,
} from '../../ai-employees/chatbox/utils';
describe('AI employee attachment limits', () => {
it('uses the storage size rule as the total attachment size limit', () => {
expect(resolveStorageSizeLimit({ size: 30 * 1024 * 1024 })).toBe(30 * 1024 * 1024);
expect(resolveStorageSizeLimit({})).toBe(AI_EMPLOYEE_ATTACHMENT_SIZE_LIMIT_DEFAULT);
});
it('limits the total size of all attachments', () => {
const sizeLimit = 20 * 1024 * 1024;
expect(
validateAIEmployeeAttachmentLimits([{ size: 12 * 1024 * 1024 }, { size: 9 * 1024 * 1024 }], sizeLimit),
).toEqual({ type: 'size', limit: sizeLimit });
});
it('allows no more than ten attachments', () => {
const attachments = Array.from({ length: AI_EMPLOYEE_ATTACHMENT_COUNT_LIMIT + 1 }, () => ({ size: 1 }));
expect(validateAIEmployeeAttachmentLimits(attachments, 1024)).toEqual({
type: 'count',
limit: AI_EMPLOYEE_ATTACHMENT_COUNT_LIMIT,
});
});
it('formats the configured size limit for user-facing messages', () => {
expect(formatAttachmentSizeLimit(20 * 1024 * 1024)).toBe('20 MB');
});
});
@@ -44,6 +44,19 @@ describe('normalizeAIFileUploadAttachment', () => {
status: 'done',
});
});
it('uses the local file name before the server returns an attachment', () => {
const uploadFile = {
uid: 'large-file',
name: 'large-file.zip',
status: 'error',
};
expect(normalizeAIFileUploadAttachment(uploadFile, uploadFile.status)).toEqual({
...uploadFile,
filename: uploadFile.name,
});
});
});
describe('uploadAIFile', () => {
@@ -238,6 +238,10 @@ export const Sender: React.FC<SenderOptions> = observer((options) => {
return;
}
if (!uploadProps.validateFiles([pastedFile])) {
return;
}
event.preventDefault();
const uid = Date.now().toString();
@@ -485,6 +489,7 @@ const UploadFiles: React.FC<{ disabled?: boolean }> = observer(({ disabled }) =>
}}
items={items}
action={uploadProps.action}
beforeUpload={uploadProps.beforeUpload}
customRequest={uploadProps.customRequest as React.ComponentProps<typeof Attachments>['customRequest']}
onChange={(info) => {
uploadProps.onChange({
@@ -8,11 +8,19 @@
*/
import { useApp } from '@nocobase/client-v2';
import { App, Upload, type UploadProps } from 'antd';
import { useRequest } from 'ahooks';
import { useT } from '../../../locale';
import { useChat } from '../hooks/useChat';
import type { Attachment } from '../../types';
import { uploadAIFile } from '../upload';
import { type ChatBoxRuntime, useResolvedChatBoxRuntime } from '../stores/runtime';
import {
formatAttachmentSizeLimit,
normalizeAIFileUploadAttachment,
resolveStorageSizeLimit,
validateAIEmployeeAttachmentLimits,
} from '../utils';
type StorageBasicInfo = {
rules?: Record<string, unknown>;
@@ -117,20 +125,20 @@ export const useUploadFiles = (runtime?: ChatBoxRuntime) => {
const resolvedRuntime = useResolvedChatBoxRuntime(runtime);
const currentConversation = resolvedRuntime.chatConversationModel.currentConversation;
const chat = useChat(currentConversation, resolvedRuntime);
const attachments = chat.use.attachments();
const setAttachments = chat.setAttachments;
const { message } = App.useApp();
const t = useT();
const uploadProps = {
action: 'aiFiles:create',
onChange({ fileList }: UploadChangeInfo) {
setAttachments(
fileList.map((file) => {
if (file.status === 'done') {
if (!file?.response?.data) {
return file;
}
return file.response.data;
if (file.status === 'done' && file.response?.data) {
return normalizeAIFileUploadAttachment(file.response.data, file.status);
}
return file;
return normalizeAIFileUploadAttachment(file, file.status);
}),
);
},
@@ -138,9 +146,36 @@ export const useUploadFiles = (runtime?: ChatBoxRuntime) => {
const props = useUploadProps(uploadProps);
const storageUploadProps = useStorageUploadProps(uploadProps);
const sizeLimit = resolveStorageSizeLimit(storageUploadProps.rules);
const validateFiles = (files: unknown[], showMessage = true) => {
const violation = validateAIEmployeeAttachmentLimits([...(attachments ?? []), ...files], sizeLimit);
if (!violation) {
return true;
}
if (showMessage) {
if (violation.type === 'count') {
message.error(t('You can upload up to {{count}} attachments.', { count: violation.limit }));
} else {
message.error(
t('The total size of attachments cannot exceed {{size}}.', {
size: formatAttachmentSizeLimit(violation.limit),
}),
);
}
}
return false;
};
const beforeUpload: NonNullable<UploadProps['beforeUpload']> = (file, selectedFiles) => {
const files = selectedFiles.length ? selectedFiles : [file];
return validateFiles(files, files[0]?.uid === file.uid) ? true : Upload.LIST_IGNORE;
};
return {
...props,
...uploadProps,
...storageUploadProps,
beforeUpload,
validateFiles,
};
};
@@ -110,22 +110,91 @@ async function getAIEmployeesFromAPIClient(apiClient?: Pick<APIClient, 'resource
export function normalizeAIFileUploadAttachment<T extends Record<string, unknown>>(
fileData: T,
status: string,
): T & { source?: Record<string, unknown>; status: string };
export function normalizeAIFileUploadAttachment<T>(fileData: T, status: string): T;
export function normalizeAIFileUploadAttachment(fileData: unknown, status: string) {
status?: string,
): T & { filename?: string; source?: Record<string, unknown>; status?: string };
export function normalizeAIFileUploadAttachment<T>(fileData: T, status?: string): T;
export function normalizeAIFileUploadAttachment(fileData: unknown, status?: string) {
if (!isRecord(fileData)) {
return fileData;
}
const meta = isRecord(fileData.meta) ? fileData.meta : undefined;
const source = isRecord(meta?.source) ? meta.source : undefined;
const filename =
typeof fileData.filename === 'string' && fileData.filename
? fileData.filename
: typeof fileData.name === 'string'
? fileData.name
: undefined;
return {
...fileData,
...(filename ? { filename } : {}),
...(source ? { source } : {}),
status,
...(status ? { status } : {}),
};
}
export const AI_EMPLOYEE_ATTACHMENT_COUNT_LIMIT = 10;
export const AI_EMPLOYEE_ATTACHMENT_SIZE_LIMIT_DEFAULT = 20 * 1024 * 1024;
export type AttachmentLimitViolation =
| {
type: 'count';
limit: number;
}
| {
type: 'size';
limit: number;
};
export function resolveStorageSizeLimit(rules: unknown): number {
const configuredSize = isRecord(rules) ? Number(rules.size) : Number.NaN;
return Number.isFinite(configuredSize) && configuredSize > 0
? configuredSize
: AI_EMPLOYEE_ATTACHMENT_SIZE_LIMIT_DEFAULT;
}
function getAttachmentSize(value: unknown): number {
if (!isRecord(value)) {
return 0;
}
const size = Number(value.size);
return Number.isFinite(size) && size > 0 ? size : 0;
}
export function validateAIEmployeeAttachmentLimits(
attachments: unknown[],
sizeLimit: number,
): AttachmentLimitViolation | null {
if (attachments.length > AI_EMPLOYEE_ATTACHMENT_COUNT_LIMIT) {
return {
type: 'count',
limit: AI_EMPLOYEE_ATTACHMENT_COUNT_LIMIT,
};
}
const totalSize = attachments.reduce<number>((total, attachment) => total + getAttachmentSize(attachment), 0);
if (totalSize > sizeLimit) {
return {
type: 'size',
limit: sizeLimit,
};
}
return null;
}
export function formatAttachmentSizeLimit(size: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const fractionDigits = Number.isInteger(value) ? 0 : 2;
return `${value.toFixed(fractionDigits)} ${units[unitIndex]}`;
}
export function isCurrentLiveMessage(
latestMessageId: string | undefined,
messageId?: string,
@@ -276,6 +276,8 @@
"UID": "UID",
"Up": "Up",
"Upload files": "Upload files",
"You can upload up to {{count}} attachments.": "You can upload up to {{count}} attachments.",
"The total size of attachments cannot exceed {{size}}.": "The total size of attachments cannot exceed {{size}}.",
"Use skill": "Use skill",
"Use skills": "Use skills",
"Use workflow as a tool": "Use workflow as a tool",
@@ -277,6 +277,8 @@
"UID": "唯一标识",
"Up": "上移",
"Upload files": "上传文件",
"You can upload up to {{count}} attachments.": "最多可以上传 {{count}} 个附件。",
"The total size of attachments cannot exceed {{size}}.": "附件总大小不能超过 {{size}}。",
"Use skill": "使用技能",
"Use skills": "使用技能",
"Use workflow as a tool": "使用工作流作为工具",