feat: 支持智能体文档附件与轻量读取
- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环 - 按智能体 20K 上下文预算选择文档片段并保留稳定引用 - 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user