mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
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:
+43
@@ -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');
|
||||
});
|
||||
});
|
||||
+13
@@ -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', () => {
|
||||
|
||||
+5
@@ -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({
|
||||
|
||||
+41
-6
@@ -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": "使用工作流作为工具",
|
||||
|
||||
Reference in New Issue
Block a user