feat: 支持智能体文档附件与轻量读取

- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环

- 按智能体 20K 上下文预算选择文档片段并保留稳定引用

- 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
This commit is contained in:
2026-07-29 01:32:19 +08:00
parent fceedd02cd
commit d45c67a317
72 changed files with 5876 additions and 237 deletions

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
@@ -17,6 +19,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
ChatDocumentAttachments,
ChatImageAttachments,
ChatTimeline,
ChatTimelineBuilder,
@@ -44,11 +47,14 @@ import {
ElSelect,
} from 'element-plus';
import {
loadAgentChatDocument,
loadAgentChatImage,
} from '#/components/ai-chat/mediaApi';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue';
import { resolveInteractionDisplay } from '../agents/interaction-config';
@@ -82,7 +88,7 @@ const currentSessionId = ref('');
const composer = useAgentComposerDraft('FORMAL');
const promptText = composer.text;
const promptInputRef = ref();
const imageFileInputRef = ref<HTMLInputElement>();
const attachmentFileInputRef = ref<HTMLInputElement>();
const composerDragActive = ref(false);
const loadingAgents = ref(false);
const agentLoadError = ref('');
@@ -120,9 +126,12 @@ const canStopRuntime = computed(() => sending.value || runtimeRunning.value);
const canSend = computed(
() =>
(Boolean(promptText.value.trim()) ||
composer.images.readyItems.value.length > 0) &&
composer.images.readyItems.value.length > 0 ||
composer.documents.readyItems.value.length > 0) &&
!composer.images.uploading.value &&
!composer.documents.processing.value &&
!composer.images.items.value.some((item) => item.status === 'error') &&
!composer.documents.items.value.some((item) => item.status === 'error') &&
(selectedAgentImageSupport.value !== false ||
composer.images.readyItems.value.length === 0) &&
Boolean(selectedAgentId.value) &&
@@ -569,11 +578,9 @@ async function bindCreatedSession(sessionId: string, prompt: string) {
async function handleAgentChange() {
extraKnowledgeIds.value = [];
if (timelineItems.value.length > 0 || currentSessionId.value) {
await createNewSession();
} else {
await activateComposer(selectedAgentId.value);
}
await (timelineItems.value.length > 0 || currentSessionId.value
? createNewSession()
: activateComposer(selectedAgentId.value));
if (
selectedAgentImageSupport.value === false &&
composer.images.items.value.length > 0
@@ -641,7 +648,9 @@ function buildCapabilities() {
async function sendContent(rawContent: string) {
const content = rawContent.trim();
if (
(!content && composer.images.readyItems.value.length === 0) ||
(!content &&
composer.images.readyItems.value.length === 0 &&
composer.documents.readyItems.value.length === 0) ||
!selectedAgentId.value ||
sending.value
) {
@@ -655,6 +664,10 @@ async function sendContent(rawContent: string) {
ElMessage.warning('图片上传完成后再发送');
return;
}
if (composer.documents.processing.value) {
ElMessage.warning('文档读取完成后再发送');
return;
}
const failedImage = composer.images.items.value.find(
(item) => item.status === 'error',
);
@@ -662,6 +675,13 @@ async function sendContent(rawContent: string) {
ElMessage.error(failedImage.error || '请处理上传失败的图片');
return;
}
const failedDocument = composer.documents.items.value.find(
(item) => item.status === 'error',
);
if (failedDocument) {
ElMessage.error(failedDocument.error || '请处理读取失败的文档');
return;
}
await composer.flush();
sending.value = true;
try {
@@ -670,6 +690,10 @@ async function sendContent(rawContent: string) {
agentName: selectedAgent.value?.name,
baseItems: timelineItems.value,
capabilities: buildCapabilities(),
documentUploadIds: composer.documents.uploadIds.value,
documents: composer.documents.readyItems.value.map((item) => ({
...item,
})),
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
onInputAccepted: () =>
@@ -713,8 +737,8 @@ async function activateComposer(agentId: string, sessionId?: string) {
}
}
function chooseImageFiles() {
imageFileInputRef.value?.click();
function chooseAttachmentFiles() {
attachmentFileInputRef.value?.click();
}
async function addImageFiles(files: File[]) {
@@ -738,10 +762,30 @@ async function addImageFiles(files: File[]) {
}
}
function handleImageFiles(event: Event) {
async function addDocumentFiles(files: File[]) {
if (!selectedAgentId.value) {
ElMessage.warning('请先选择智能体');
return;
}
await composer.ensureSession();
const rejected = await composer.documents.addFiles(files, {
agentId: selectedAgentId.value,
mode: 'FORMAL',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
if (rejected > 0) {
ElMessage.warning('每轮最多添加 3 份文档');
}
}
function handleAttachmentFiles(event: Event) {
const target = event.target as HTMLInputElement;
const files = [...(target.files || [])];
if (files.length) void addImageFiles(files);
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
const documentFiles = files.filter((file) => !file.type.startsWith('image/'));
if (imageFiles.length > 0) void addImageFiles(imageFiles);
if (documentFiles.length > 0) void addDocumentFiles(documentFiles);
target.value = '';
}
@@ -749,23 +793,24 @@ function handleImagePaste(event: ClipboardEvent) {
const files = [...(event.clipboardData?.files || [])].filter((file) =>
file.type.startsWith('image/'),
);
if (!files.length) return;
if (files.length === 0) return;
event.preventDefault();
void addImageFiles(files);
}
function handleImageDragEnter(event: DragEvent) {
function handleAttachmentDragEnter(event: DragEvent) {
if (
selectedAgentImageSupport.value !== false &&
!capabilityDisabled.value &&
composer.images.items.value.length < 5 &&
(composer.documents.items.value.length < 3 ||
(selectedAgentImageSupport.value !== false &&
composer.images.items.value.length < 5)) &&
[...(event.dataTransfer?.types || [])].includes('Files')
) {
composerDragActive.value = true;
}
}
function handleImageDragLeave(event: DragEvent) {
function handleAttachmentDragLeave(event: DragEvent) {
const container = event.currentTarget as HTMLElement;
if (
!(event.relatedTarget instanceof Node) ||
@@ -775,21 +820,26 @@ function handleImageDragLeave(event: DragEvent) {
}
}
function handleImageDrop(event: DragEvent) {
function handleAttachmentDrop(event: DragEvent) {
composerDragActive.value = false;
if (
selectedAgentImageSupport.value === false ||
capabilityDisabled.value ||
composer.images.items.value.length >= 5
)
return;
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
file.type.startsWith('image/'),
if (capabilityDisabled.value) return;
const files = [...(event.dataTransfer?.files || [])];
const imageFiles = files.filter(
(file) =>
file.type.startsWith('image/') &&
selectedAgentImageSupport.value !== false &&
composer.images.items.value.length < 5,
);
if (files.length) void addImageFiles(files);
const documentFiles = files.filter(
(file) =>
!file.type.startsWith('image/') &&
composer.documents.items.value.length < 3,
);
if (imageFiles.length > 0) void addImageFiles(imageFiles);
if (documentFiles.length > 0) void addDocumentFiles(documentFiles);
}
async function retryImage(item: any) {
async function retryImage(item: ChatImageAttachment) {
await composer.images.retry(item, {
agentId: selectedAgentId.value,
mode: 'FORMAL',
@@ -798,7 +848,7 @@ async function retryImage(item: any) {
composer.scheduleSave();
}
async function removeImage(item: any) {
async function removeImage(item: ChatImageAttachment) {
try {
await composer.images.remove(item);
composer.scheduleSave();
@@ -807,6 +857,24 @@ async function removeImage(item: any) {
}
}
async function retryDocument(item: ChatDocumentAttachment) {
await composer.documents.retry(item, {
agentId: selectedAgentId.value,
mode: 'FORMAL',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
}
async function removeDocument(item: ChatDocumentAttachment) {
try {
await composer.documents.remove(item);
composer.scheduleSave();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文档删除失败');
}
}
function handlePromptKeyup() {
chatInputTrigger.sync();
}
@@ -1133,6 +1201,7 @@ onBeforeUnmount(() => {
<ChatTimeline
v-else
:items="timelineItems"
:document-loader="loadAgentChatDocument"
:image-loader="loadAgentChatImage"
empty-text="选择智能体后开始对话"
:approval-loading="Boolean(approvalLoadingKey)"
@@ -1148,10 +1217,10 @@ onBeforeUnmount(() => {
<div
class="agent-chat__composer"
:class="{ 'is-dragging': composerDragActive }"
@dragenter.prevent="handleImageDragEnter"
@dragenter.prevent="handleAttachmentDragEnter"
@dragover.prevent
@dragleave.prevent="handleImageDragLeave"
@drop.prevent="handleImageDrop"
@dragleave.prevent="handleAttachmentDragLeave"
@drop.prevent="handleAttachmentDrop"
>
<ChatCapabilityMenu
:disabled="capabilityDisabled"
@@ -1173,7 +1242,7 @@ onBeforeUnmount(() => {
@set-active="chatInputTrigger.setActiveIndex"
/>
<ChatImageAttachments
v-if="composer.images.items.value.length"
v-if="composer.images.items.value.length > 0"
:items="composer.images.items.value"
:image-loader="loadAgentChatImage"
removable
@@ -1181,6 +1250,15 @@ onBeforeUnmount(() => {
@remove="removeImage"
@retry="retryImage"
/>
<ChatDocumentAttachments
v-if="composer.documents.items.value.length > 0"
:items="composer.documents.items.value"
:document-loader="loadAgentChatDocument"
removable
retryable
@remove="removeDocument"
@retry="retryDocument"
/>
<ElInput
ref="promptInputRef"
v-model="promptText"
@@ -1198,27 +1276,32 @@ onBeforeUnmount(() => {
/>
<div class="agent-chat__composer-footer">
<div class="agent-chat__composer-tools">
<template v-if="selectedAgentImageSupport !== false">
<input
ref="imageFileInputRef"
class="agent-chat__image-file-input"
type="file"
accept=".png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp"
multiple
@change="handleImageFiles"
/>
<ElButton
:icon="Paperclip"
circle
text
:disabled="
capabilityDisabled || composer.images.items.value.length >= 5
"
aria-label="添加图片"
title="添加图片"
@click="chooseImageFiles"
/>
</template>
<input
ref="attachmentFileInputRef"
class="agent-chat__image-file-input"
type="file"
:accept="
selectedAgentImageSupport === false
? '.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md'
: '.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp'
"
multiple
@change="handleAttachmentFiles"
/>
<ElButton
:icon="Paperclip"
circle
text
:disabled="
capabilityDisabled ||
(composer.documents.items.value.length >= 3 &&
(selectedAgentImageSupport === false ||
composer.images.items.value.length >= 5))
"
aria-label="添加附件"
title="添加附件"
@click="chooseAttachmentFiles"
/>
<ChatCapabilityMenu
class="agent-chat__capability-entry"
:disabled="capabilityDisabled"