feat: 优化聊天附件拖拽上传提示

- 不支持格式改用轻量提示并展示支持范围

- 扩展拖拽区域并增加全区上传覆盖层

- 补充文档和图片格式校验测试
This commit is contained in:
2026-07-31 09:19:11 +08:00
parent 2267517fd8
commit 0b764b79de
7 changed files with 242 additions and 67 deletions

View File

@@ -107,6 +107,21 @@ describe('useChatDocumentUploads', () => {
}); });
}); });
it('rejects unsupported files without adding an error attachment', async () => {
const uploads = useChatDocumentUploads();
const result = await uploads.addFiles(
[new File(['archive'], 'unsupported.zip', { type: 'application/zip' })],
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
);
expect(result).toEqual({
rejectedCount: 0,
unsupportedCount: 1,
});
expect(uploads.items.value).toEqual([]);
expect(mediaApi.uploadAgentChatDocument).not.toHaveBeenCalled();
});
it('exposes a server read failure without continuing to poll', async () => { it('exposes a server read failure without continuing to poll', async () => {
mediaApi.uploadAgentChatDocument.mockResolvedValue({ mediaApi.uploadAgentChatDocument.mockResolvedValue({
data: { data: {

View File

@@ -59,4 +59,19 @@ describe('useChatImageUploads', () => {
expect(observedStatuses.at(-1)).toBe('ready'); expect(observedStatuses.at(-1)).toBe('ready');
expect(uploads.uploadIds.value).toEqual(['upload-1']); expect(uploads.uploadIds.value).toEqual(['upload-1']);
}); });
it('rejects unsupported images without adding an error attachment', async () => {
const uploads = useChatImageUploads();
const result = await uploads.addFiles(
[new File(['vector'], 'unsupported.svg', { type: 'image/svg+xml' })],
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
);
expect(result).toEqual({
rejectedCount: 0,
unsupportedCount: 1,
});
expect(uploads.items.value).toEqual([]);
expect(mediaApi.uploadAgentChatImage).not.toHaveBeenCalled();
});
}); });

View File

@@ -7,6 +7,7 @@ import type { AgentComposerMode, AgentMediaUpload } from './mediaApi';
const MAX_IMAGES = 5; const MAX_IMAGES = 5;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const CHAT_IMAGE_SUPPORTED_FORMATS = 'PNG、JPG、JPEG、WebP、GIF、BMP';
const ACCEPTED_EXTENSIONS = new Set([ const ACCEPTED_EXTENSIONS = new Set([
'bmp', 'bmp',
'gif', 'gif',
@@ -26,6 +27,11 @@ interface LocalAttachment extends ChatImageAttachment {
file?: File; file?: File;
} }
interface ChatImageAddFilesResult {
rejectedCount: number;
unsupportedCount: number;
}
function createLocalId() { function createLocalId() {
return `agent-image-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; return `agent-image-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
} }
@@ -69,12 +75,20 @@ export function useChatImageUploads() {
); );
async function addFiles(files: File[], context: UploadContext) { async function addFiles(files: File[], context: UploadContext) {
const supportedFiles: File[] = [];
let unsupportedCount = 0;
for (const file of files) {
if (ACCEPTED_EXTENSIONS.has(extensionOf(file))) {
supportedFiles.push(file);
} else {
unsupportedCount++;
}
}
const available = Math.max(0, MAX_IMAGES - items.value.length); const available = Math.max(0, MAX_IMAGES - items.value.length);
const accepted = files.slice(0, available); const accepted = supportedFiles.slice(0, available);
const rejectedCount = Math.max(0, files.length - accepted.length); const rejectedCount = Math.max(0, supportedFiles.length - accepted.length);
const uploads: Promise<void>[] = []; const uploads: Promise<void>[] = [];
for (const file of accepted) { for (const file of accepted) {
const extension = extensionOf(file);
const local: LocalAttachment = { const local: LocalAttachment = {
file, file,
localId: createLocalId(), localId: createLocalId(),
@@ -85,11 +99,6 @@ export function useChatImageUploads() {
status: 'uploading', status: 'uploading',
}; };
items.value.push(local); items.value.push(local);
if (!ACCEPTED_EXTENSIONS.has(extension)) {
local.status = 'error';
local.error = '仅支持 PNG、JPG、JPEG、WebP、GIF、BMP';
continue;
}
if (file.size > MAX_IMAGE_BYTES) { if (file.size > MAX_IMAGE_BYTES) {
local.status = 'error'; local.status = 'error';
local.error = '单张图片不能超过 10 MiB'; local.error = '单张图片不能超过 10 MiB';
@@ -98,7 +107,10 @@ export function useChatImageUploads() {
uploads.push(upload(local, context)); uploads.push(upload(local, context));
} }
await Promise.all(uploads); await Promise.all(uploads);
return rejectedCount; return {
rejectedCount,
unsupportedCount,
} satisfies ChatImageAddFilesResult;
} }
async function upload(item: LocalAttachment, context: UploadContext) { async function upload(item: LocalAttachment, context: UploadContext) {

View File

@@ -19,6 +19,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { import {
CHAT_DOCUMENT_SUPPORTED_FORMATS,
ChatDocumentAttachments, ChatDocumentAttachments,
ChatImageAttachments, ChatImageAttachments,
ChatTimeline, ChatTimeline,
@@ -33,6 +34,7 @@ import {
Paperclip, Paperclip,
Plus, Plus,
Promotion, Promotion,
UploadFilled,
} from '@element-plus/icons-vue'; } from '@element-plus/icons-vue';
import { import {
ElButton, ElButton,
@@ -52,6 +54,7 @@ import {
loadAgentChatImage, loadAgentChatImage,
} from '#/components/ai-chat/mediaApi'; } from '#/components/ai-chat/mediaApi';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft'; import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { CHAT_IMAGE_SUPPORTED_FORMATS } from '#/components/ai-chat/useChatImageUploads';
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue'; import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue'; import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger'; import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
@@ -114,6 +117,11 @@ const selectedAgentImageSupport = computed(() =>
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage, selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
), ),
); );
const supportedAttachmentFormats = computed(() =>
selectedAgentImageSupport.value === false
? CHAT_DOCUMENT_SUPPORTED_FORMATS
: `${CHAT_DOCUMENT_SUPPORTED_FORMATS};图片支持 ${CHAT_IMAGE_SUPPORTED_FORMATS}`,
);
const interactionDisplay = computed(() => const interactionDisplay = computed(() =>
resolveInteractionDisplay(selectedAgent.value), resolveInteractionDisplay(selectedAgent.value),
); );
@@ -747,17 +755,29 @@ async function addImageFiles(files: File[]) {
return; return;
} }
if (selectedAgentImageSupport.value === false) { if (selectedAgentImageSupport.value === false) {
ElMessage.warning('当前智能体不支持图片'); ElMessage.warning({
grouping: true,
message: `当前智能体不支持图片,支持:${CHAT_DOCUMENT_SUPPORTED_FORMATS}`,
});
return; return;
} }
await composer.ensureSession(); await composer.ensureSession();
const rejected = await composer.images.addFiles(files, { const { rejectedCount, unsupportedCount } = await composer.images.addFiles(
files,
{
agentId: selectedAgentId.value, agentId: selectedAgentId.value,
mode: 'FORMAL', mode: 'FORMAL',
sessionId: composer.sessionId.value, sessionId: composer.sessionId.value,
}); },
);
composer.scheduleSave(); composer.scheduleSave();
if (rejected > 0) { if (unsupportedCount > 0) {
ElMessage.warning({
grouping: true,
message: `不支持该图片格式,支持:${CHAT_IMAGE_SUPPORTED_FORMATS}`,
});
}
if (rejectedCount > 0) {
ElMessage.warning('每次最多添加 5 张图片'); ElMessage.warning('每次最多添加 5 张图片');
} }
} }
@@ -768,22 +788,38 @@ async function addDocumentFiles(files: File[]) {
return; return;
} }
await composer.ensureSession(); await composer.ensureSession();
const rejected = await composer.documents.addFiles(files, { const { rejectedCount, unsupportedCount } = await composer.documents.addFiles(
files,
{
agentId: selectedAgentId.value, agentId: selectedAgentId.value,
mode: 'FORMAL', mode: 'FORMAL',
sessionId: composer.sessionId.value, sessionId: composer.sessionId.value,
}); },
);
composer.scheduleSave(); composer.scheduleSave();
if (rejected > 0) { if (unsupportedCount > 0) {
ElMessage.warning({
grouping: true,
message: `不支持该文件格式,支持:${supportedAttachmentFormats.value}`,
});
}
if (rejectedCount > 0) {
ElMessage.warning('每轮最多添加 3 份文档'); ElMessage.warning('每轮最多添加 3 份文档');
} }
} }
function isImageAttachmentFile(file: File) {
return (
file.type.startsWith('image/') ||
/\.(?:bmp|gif|jpe?g|png|webp)$/i.test(file.name)
);
}
function handleAttachmentFiles(event: Event) { function handleAttachmentFiles(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const files = [...(target.files || [])]; const files = [...(target.files || [])];
const imageFiles = files.filter((file) => file.type.startsWith('image/')); const imageFiles = files.filter((file) => isImageAttachmentFile(file));
const documentFiles = files.filter((file) => !file.type.startsWith('image/')); const documentFiles = files.filter((file) => !isImageAttachmentFile(file));
if (imageFiles.length > 0) void addImageFiles(imageFiles); if (imageFiles.length > 0) void addImageFiles(imageFiles);
if (documentFiles.length > 0) void addDocumentFiles(documentFiles); if (documentFiles.length > 0) void addDocumentFiles(documentFiles);
target.value = ''; target.value = '';
@@ -810,6 +846,13 @@ function handleAttachmentDragEnter(event: DragEvent) {
} }
} }
function handleAttachmentDragOver(event: DragEvent) {
handleAttachmentDragEnter(event);
if (composerDragActive.value && event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
}
function handleAttachmentDragLeave(event: DragEvent) { function handleAttachmentDragLeave(event: DragEvent) {
const container = event.currentTarget as HTMLElement; const container = event.currentTarget as HTMLElement;
if ( if (
@@ -824,17 +867,8 @@ function handleAttachmentDrop(event: DragEvent) {
composerDragActive.value = false; composerDragActive.value = false;
if (capabilityDisabled.value) return; if (capabilityDisabled.value) return;
const files = [...(event.dataTransfer?.files || [])]; const files = [...(event.dataTransfer?.files || [])];
const imageFiles = files.filter( const imageFiles = files.filter((file) => isImageAttachmentFile(file));
(file) => const documentFiles = files.filter((file) => !isImageAttachmentFile(file));
file.type.startsWith('image/') &&
selectedAgentImageSupport.value !== false &&
composer.images.items.value.length < 5,
);
const documentFiles = files.filter(
(file) =>
!file.type.startsWith('image/') &&
composer.documents.items.value.length < 3,
);
if (imageFiles.length > 0) void addImageFiles(imageFiles); if (imageFiles.length > 0) void addImageFiles(imageFiles);
if (documentFiles.length > 0) void addDocumentFiles(documentFiles); if (documentFiles.length > 0) void addDocumentFiles(documentFiles);
} }
@@ -1151,7 +1185,13 @@ onBeforeUnmount(() => {
</div> </div>
</aside> </aside>
<main class="agent-chat__main"> <main
class="agent-chat__main"
@dragenter.prevent="handleAttachmentDragEnter"
@dragover.prevent="handleAttachmentDragOver"
@dragleave.prevent="handleAttachmentDragLeave"
@drop.prevent="handleAttachmentDrop"
>
<header class="agent-chat__main-head"> <header class="agent-chat__main-head">
<div> <div>
<div class="agent-chat__main-title"> <div class="agent-chat__main-title">
@@ -1214,14 +1254,7 @@ onBeforeUnmount(() => {
/> />
</div> </div>
<div <div class="agent-chat__composer">
class="agent-chat__composer"
:class="{ 'is-dragging': composerDragActive }"
@dragenter.prevent="handleAttachmentDragEnter"
@dragover.prevent
@dragleave.prevent="handleAttachmentDragLeave"
@drop.prevent="handleAttachmentDrop"
>
<ChatCapabilityMenu <ChatCapabilityMenu
:disabled="capabilityDisabled" :disabled="capabilityDisabled"
:extra-knowledge-ids="extraKnowledgeIds" :extra-knowledge-ids="extraKnowledgeIds"
@@ -1353,6 +1386,24 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
<Transition name="agent-chat-drag-fade">
<div
v-if="composerDragActive"
class="agent-chat__drop-overlay"
role="status"
aria-live="polite"
>
<div class="agent-chat__drop-overlay-content">
<ElIcon class="agent-chat__drop-overlay-icon">
<UploadFilled />
</ElIcon>
<div class="agent-chat__drop-overlay-title">松手上传文件</div>
<div class="agent-chat__drop-overlay-formats">
支持 {{ supportedAttachmentFormats }}
</div>
</div>
</div>
</Transition>
</main> </main>
</section> </section>
</template> </template>
@@ -1526,9 +1577,53 @@ onBeforeUnmount(() => {
box-shadow: var(--el-box-shadow-light); box-shadow: var(--el-box-shadow-light);
} }
.agent-chat__composer.is-dragging { .agent-chat__drop-overlay {
position: absolute;
inset: 16px;
z-index: 20;
display: grid;
pointer-events: none;
background: var(--el-color-primary-light-9); background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5); border: 2px dashed var(--el-color-primary-light-5);
border-radius: 16px;
place-items: center;
}
.agent-chat__drop-overlay-content {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
max-width: min(560px, calc(100% - 48px));
padding: 24px;
text-align: center;
}
.agent-chat__drop-overlay-icon {
font-size: 40px;
color: var(--el-color-primary);
}
.agent-chat__drop-overlay-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.agent-chat__drop-overlay-formats {
font-size: 13px;
line-height: 20px;
color: var(--el-text-color-secondary);
}
.agent-chat-drag-fade-enter-active,
.agent-chat-drag-fade-leave-active {
transition: opacity 160ms ease;
}
.agent-chat-drag-fade-enter-from,
.agent-chat-drag-fade-leave-to {
opacity: 0;
} }
.agent-chat__trigger-panel { .agent-chat__trigger-panel {

View File

@@ -14,7 +14,10 @@ import type {
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { ChatTimeline } from '@easyflow/common-ui'; import {
CHAT_DOCUMENT_SUPPORTED_FORMATS,
ChatTimeline,
} from '@easyflow/common-ui';
import { BrushCleaning } from '@easyflow/icons'; import { BrushCleaning } from '@easyflow/icons';
import { copyTextToClipboard } from '@easyflow/utils'; import { copyTextToClipboard } from '@easyflow/utils';
@@ -26,6 +29,7 @@ import {
loadAgentChatImage, loadAgentChatImage,
} from '#/components/ai-chat/mediaApi'; } from '#/components/ai-chat/mediaApi';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft'; import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { CHAT_IMAGE_SUPPORTED_FORMATS } from '#/components/ai-chat/useChatImageUploads';
import { approveAgentRun, rejectAgentRun } from '../api'; import { approveAgentRun, rejectAgentRun } from '../api';
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream'; import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
@@ -214,12 +218,21 @@ async function handleAddImageFiles(files: File[]) {
return; return;
} }
await composer.ensureSession(); await composer.ensureSession();
const rejected = await composer.images.addFiles(files, { const { rejectedCount, unsupportedCount } = await composer.images.addFiles(
files,
{
agentId: String(props.agent.id), agentId: String(props.agent.id),
mode: 'DRAFT', mode: 'DRAFT',
sessionId: composer.sessionId.value, sessionId: composer.sessionId.value,
},
);
if (unsupportedCount > 0) {
ElMessage.warning({
grouping: true,
message: `不支持该图片格式,支持:${CHAT_IMAGE_SUPPORTED_FORMATS}`,
}); });
if (rejected > 0) { }
if (rejectedCount > 0) {
ElMessage.warning('每次最多添加 5 张图片'); ElMessage.warning('每次最多添加 5 张图片');
} }
composer.scheduleSave(); composer.scheduleSave();
@@ -231,12 +244,21 @@ async function handleAddDocumentFiles(files: File[]) {
return; return;
} }
await composer.ensureSession(); await composer.ensureSession();
const rejected = await composer.documents.addFiles(files, { const { rejectedCount, unsupportedCount } = await composer.documents.addFiles(
files,
{
agentId: String(props.agent.id), agentId: String(props.agent.id),
mode: 'DRAFT', mode: 'DRAFT',
sessionId: composer.sessionId.value, sessionId: composer.sessionId.value,
},
);
if (unsupportedCount > 0) {
ElMessage.warning({
grouping: true,
message: `不支持该文件格式,支持:${CHAT_DOCUMENT_SUPPORTED_FORMATS}`,
}); });
if (rejected > 0) { }
if (rejectedCount > 0) {
ElMessage.warning('每轮最多添加 3 份文档'); ElMessage.warning('每轮最多添加 3 份文档');
} }
composer.scheduleSave(); composer.scheduleSave();

View File

@@ -34,6 +34,8 @@ export type {
ChatTimelineToolStatus, ChatTimelineToolStatus,
} from './types'; } from './types';
export { export {
CHAT_DOCUMENT_SUPPORTED_FORMATS,
type ChatDocumentAddFilesResult,
type ChatDocumentUploadApi, type ChatDocumentUploadApi,
type ChatDocumentUploadContext, type ChatDocumentUploadContext,
type ChatDocumentUploadView, type ChatDocumentUploadView,

View File

@@ -11,6 +11,8 @@ const MAX_EXCEL_BYTES = 10 * 1024 * 1024;
const MAX_TEXT_BYTES = 5 * 1024 * 1024; const MAX_TEXT_BYTES = 5 * 1024 * 1024;
const POLL_INTERVAL_MS = 750; const POLL_INTERVAL_MS = 750;
const MAX_POLL_ATTEMPTS = 80; const MAX_POLL_ATTEMPTS = 80;
export const CHAT_DOCUMENT_SUPPORTED_FORMATS =
'PDF、Word、PPT、Excel、TXT、Markdown';
const SUPPORTED_EXTENSIONS = new Set([ const SUPPORTED_EXTENSIONS = new Set([
'doc', 'doc',
'docx', 'docx',
@@ -54,6 +56,11 @@ export interface ChatDocumentUploadApi {
) => Promise<RequestResult<ChatDocumentUploadView>>; ) => Promise<RequestResult<ChatDocumentUploadView>>;
} }
export interface ChatDocumentAddFilesResult {
rejectedCount: number;
unsupportedCount: number;
}
interface LocalDocument extends ChatDocumentAttachment { interface LocalDocument extends ChatDocumentAttachment {
file?: File; file?: File;
} }
@@ -198,9 +205,18 @@ export function createChatDocumentUploads(api: ChatDocumentUploadApi) {
} }
async function addFiles(files: File[], context: ChatDocumentUploadContext) { async function addFiles(files: File[], context: ChatDocumentUploadContext) {
const supportedFiles: File[] = [];
let unsupportedCount = 0;
for (const file of files) {
if (SUPPORTED_EXTENSIONS.has(extensionOf(file))) {
supportedFiles.push(file);
} else {
unsupportedCount++;
}
}
const available = Math.max(0, MAX_DOCUMENTS - items.value.length); const available = Math.max(0, MAX_DOCUMENTS - items.value.length);
const accepted = files.slice(0, available); const accepted = supportedFiles.slice(0, available);
const rejectedCount = Math.max(0, files.length - accepted.length); const rejectedCount = Math.max(0, supportedFiles.length - accepted.length);
const currentTotal = items.value.reduce( const currentTotal = items.value.reduce(
(total, item) => total + Number(item.size || 0), (total, item) => total + Number(item.size || 0),
0, 0,
@@ -220,11 +236,6 @@ export function createChatDocumentUploads(api: ChatDocumentUploadApi) {
items.value.push(item); items.value.push(item);
const trackedItem = items.value[items.value.length - 1]; const trackedItem = items.value[items.value.length - 1];
if (!trackedItem) continue; if (!trackedItem) continue;
if (!SUPPORTED_EXTENSIONS.has(extension)) {
trackedItem.status = 'error';
trackedItem.error = '仅支持 PDF、Word、PPT、Excel、TXT、Markdown';
continue;
}
if (file.size > maxBytes(extension)) { if (file.size > maxBytes(extension)) {
trackedItem.status = 'error'; trackedItem.status = 'error';
trackedItem.error = sizeLimitMessage(extension); trackedItem.error = sizeLimitMessage(extension);
@@ -239,7 +250,10 @@ export function createChatDocumentUploads(api: ChatDocumentUploadApi) {
uploads.push(upload(trackedItem, context)); uploads.push(upload(trackedItem, context));
} }
await Promise.all(uploads); await Promise.all(uploads);
return rejectedCount; return {
rejectedCount,
unsupportedCount,
} satisfies ChatDocumentAddFilesResult;
} }
async function remove(item: ChatDocumentAttachment) { async function remove(item: ChatDocumentAttachment) {