feat: 支持智能体文档附件与轻量读取
- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环 - 按智能体 20K 上下文预算选择文档片段并保留稳定引用 - 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatDocumentLoader,
|
||||
ChatImageAttachment,
|
||||
ChatImageLoader,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import type { AiChatMessage, AiToolApprovalPayload } from './types';
|
||||
import type { ChatImageAttachment, ChatImageLoader } from '@easyflow/common-ui';
|
||||
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import { ElButton } from 'element-plus';
|
||||
@@ -12,28 +18,43 @@ withDefaults(
|
||||
defineProps<{
|
||||
approvalLoading?: boolean;
|
||||
closable?: boolean;
|
||||
documentLoader?: ChatDocumentLoader;
|
||||
documents?: ChatDocumentAttachment[];
|
||||
emptyText?: string;
|
||||
loading?: boolean;
|
||||
images?: ChatImageAttachment[];
|
||||
imageEnabled?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
modelValue?: string;
|
||||
images?: ChatImageAttachment[];
|
||||
loading?: boolean;
|
||||
messages: AiChatMessage[];
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
subtitle?: string;
|
||||
title: string;
|
||||
}>(),
|
||||
{ imageEnabled: true },
|
||||
{
|
||||
documentLoader: undefined,
|
||||
documents: () => [],
|
||||
emptyText: '',
|
||||
imageEnabled: true,
|
||||
imageLoader: undefined,
|
||||
images: () => [],
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
subtitle: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
addDocumentFiles: [files: File[]];
|
||||
addFiles: [files: File[]];
|
||||
approve: [payload: AiToolApprovalPayload];
|
||||
close: [];
|
||||
reject: [payload: AiToolApprovalPayload];
|
||||
send: [text: string];
|
||||
removeDocument: [item: ChatDocumentAttachment];
|
||||
removeImage: [item: ChatImageAttachment];
|
||||
retryDocument: [item: ChatDocumentAttachment];
|
||||
retryImage: [item: ChatImageAttachment];
|
||||
send: [text: string];
|
||||
stop: [];
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
@@ -76,6 +97,8 @@ defineSlots<{
|
||||
</slot>
|
||||
<AiPromptInput
|
||||
:model-value="modelValue"
|
||||
:documents="documents"
|
||||
:document-loader="documentLoader"
|
||||
:images="images"
|
||||
:image-enabled="imageEnabled"
|
||||
:image-loader="imageLoader"
|
||||
@@ -83,8 +106,11 @@ defineSlots<{
|
||||
:placeholder="placeholder"
|
||||
@send="emit('send', $event)"
|
||||
@add-files="emit('addFiles', $event)"
|
||||
@add-document-files="emit('addDocumentFiles', $event)"
|
||||
@remove-document="emit('removeDocument', $event)"
|
||||
@remove-image="emit('removeImage', $event)"
|
||||
@retry-image="emit('retryImage', $event)"
|
||||
@retry-document="emit('retryDocument', $event)"
|
||||
@stop="emit('stop')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatImageAttachment, ChatImageLoader } from '@easyflow/common-ui';
|
||||
import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatDocumentLoader,
|
||||
ChatImageAttachment,
|
||||
ChatImageLoader,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ChatImageAttachments } from '@easyflow/common-ui';
|
||||
import {
|
||||
ChatDocumentAttachments,
|
||||
ChatImageAttachments,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import { Paperclip, Promotion } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
images?: ChatImageAttachment[];
|
||||
documentLoader?: ChatDocumentLoader;
|
||||
documents?: ChatDocumentAttachment[];
|
||||
imageEnabled?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
images?: ChatImageAttachment[];
|
||||
loading?: boolean;
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ imageEnabled: true },
|
||||
{
|
||||
documentLoader: undefined,
|
||||
documents: () => [],
|
||||
imageEnabled: true,
|
||||
imageLoader: undefined,
|
||||
images: () => [],
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
addDocumentFiles: [files: File[]];
|
||||
addFiles: [files: File[]];
|
||||
removeDocument: [item: ChatDocumentAttachment];
|
||||
removeImage: [item: ChatImageAttachment];
|
||||
retryDocument: [item: ChatDocumentAttachment];
|
||||
retryImage: [item: ChatImageAttachment];
|
||||
send: [text: string];
|
||||
stop: [];
|
||||
@@ -40,34 +62,56 @@ const hasReadyImage = computed(() =>
|
||||
const hasPendingImage = computed(() =>
|
||||
(props.images || []).some((item) => item.status !== 'ready'),
|
||||
);
|
||||
const hasReadyDocument = computed(() =>
|
||||
(props.documents || []).some((item) => item.status === 'ready'),
|
||||
);
|
||||
const hasPendingDocument = computed(() =>
|
||||
(props.documents || []).some((item) => item.status !== 'ready'),
|
||||
);
|
||||
const canSend = computed(
|
||||
() =>
|
||||
(text.value.trim().length > 0 || hasReadyImage.value) &&
|
||||
(text.value.trim().length > 0 ||
|
||||
hasReadyImage.value ||
|
||||
hasReadyDocument.value) &&
|
||||
(!hasReadyImage.value || props.imageEnabled !== false) &&
|
||||
!hasPendingImage.value &&
|
||||
!hasPendingDocument.value &&
|
||||
!props.loading,
|
||||
);
|
||||
const fileAccept = computed(() => {
|
||||
const documents =
|
||||
'.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,application/pdf,text/plain,text/markdown';
|
||||
if (props.imageEnabled === false) return documents;
|
||||
return `${documents},.png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp`;
|
||||
});
|
||||
|
||||
function send() {
|
||||
const value = text.value.trim();
|
||||
if (
|
||||
(!value && !hasReadyImage.value) ||
|
||||
(!value && !hasReadyImage.value && !hasReadyDocument.value) ||
|
||||
props.loading ||
|
||||
hasPendingImage.value
|
||||
hasPendingImage.value ||
|
||||
hasPendingDocument.value
|
||||
)
|
||||
return;
|
||||
emit('send', value);
|
||||
}
|
||||
|
||||
function chooseFiles() {
|
||||
if (props.imageEnabled === false) return;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function handleFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = [...(target.files || [])];
|
||||
if (files.length) emit('addFiles', files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
||||
const documentFiles = files.filter((file) => !file.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0 && props.imageEnabled !== false) {
|
||||
emit('addFiles', imageFiles);
|
||||
}
|
||||
if (documentFiles.length > 0) {
|
||||
emit('addDocumentFiles', documentFiles);
|
||||
}
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
@@ -76,7 +120,7 @@ function handlePaste(event: ClipboardEvent) {
|
||||
const files = [...(event.clipboardData?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (!files.length) return;
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
emit('addFiles', files);
|
||||
ElMessage.success(
|
||||
@@ -87,8 +131,7 @@ function handlePaste(event: ClipboardEvent) {
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (
|
||||
!props.loading &&
|
||||
props.imageEnabled !== false &&
|
||||
(props.images?.length || 0) < 5 &&
|
||||
((props.images?.length || 0) < 5 || (props.documents?.length || 0) < 3) &&
|
||||
[...(event.dataTransfer?.types || [])].includes('Files')
|
||||
) {
|
||||
dragActive.value = true;
|
||||
@@ -109,14 +152,18 @@ function handleDrop(event: DragEvent) {
|
||||
dragActive.value = false;
|
||||
if (
|
||||
props.loading ||
|
||||
props.imageEnabled === false ||
|
||||
(props.images?.length || 0) >= 5
|
||||
((props.images?.length || 0) >= 5 && (props.documents?.length || 0) >= 3)
|
||||
)
|
||||
return;
|
||||
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (files.length) emit('addFiles', files);
|
||||
const files = [...(event.dataTransfer?.files || [])];
|
||||
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
||||
const documentFiles = files.filter((file) => !file.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0 && props.imageEnabled !== false) {
|
||||
emit('addFiles', imageFiles);
|
||||
}
|
||||
if (documentFiles.length > 0) {
|
||||
emit('addDocumentFiles', documentFiles);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
@@ -151,6 +198,16 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
@remove="emit('removeImage', $event)"
|
||||
@retry="emit('retryImage', $event)"
|
||||
/>
|
||||
<ChatDocumentAttachments
|
||||
v-if="documents?.length"
|
||||
class="ai-prompt-input__documents"
|
||||
:items="documents"
|
||||
:document-loader="documentLoader"
|
||||
removable
|
||||
retryable
|
||||
@remove="emit('removeDocument', $event)"
|
||||
@retry="emit('retryDocument', $event)"
|
||||
/>
|
||||
<ElInput
|
||||
v-model="text"
|
||||
class="ai-prompt-input__textarea"
|
||||
@@ -163,22 +220,22 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<input
|
||||
v-if="imageEnabled !== false"
|
||||
ref="fileInput"
|
||||
class="ai-prompt-input__file"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp"
|
||||
:accept="fileAccept"
|
||||
multiple
|
||||
@change="handleFiles"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="imageEnabled !== false"
|
||||
:icon="Paperclip"
|
||||
circle
|
||||
text
|
||||
:disabled="loading || (images?.length || 0) >= 5"
|
||||
aria-label="添加图片"
|
||||
title="添加图片"
|
||||
:disabled="
|
||||
loading || ((images?.length || 0) >= 5 && (documents?.length || 0) >= 3)
|
||||
"
|
||||
aria-label="添加附件"
|
||||
title="添加附件"
|
||||
class="ai-prompt-input__attach"
|
||||
@click="chooseFiles"
|
||||
/>
|
||||
@@ -229,6 +286,10 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.ai-prompt-input__documents {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.ai-prompt-input__file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChatDocumentAttachment } from '@easyflow/common-ui';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadAgentChatDocument } from './mediaApi';
|
||||
|
||||
const requestApi = vi.hoisted(() => ({
|
||||
download: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/request', () => ({
|
||||
api: requestApi,
|
||||
}));
|
||||
|
||||
const documentAttachment: ChatDocumentAttachment = {
|
||||
downloadUrl:
|
||||
'/api/v1/agent/media/document/content?reference=document:attachment',
|
||||
name: '需求说明.docx',
|
||||
status: 'ready',
|
||||
};
|
||||
|
||||
describe('agent chat document download', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:document');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
() => undefined,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
requestApi.download.mockReset();
|
||||
});
|
||||
|
||||
it('downloads a non-empty blob and revokes its URL after the browser takes over', async () => {
|
||||
requestApi.download.mockResolvedValue(new Blob(['document']));
|
||||
|
||||
await loadAgentChatDocument(documentAttachment);
|
||||
|
||||
expect(requestApi.download).toHaveBeenCalledWith(
|
||||
documentAttachment.downloadUrl,
|
||||
);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:document');
|
||||
});
|
||||
|
||||
it('rejects an empty response before creating a damaged download', async () => {
|
||||
requestApi.download.mockResolvedValue(new Blob([]));
|
||||
|
||||
await expect(loadAgentChatDocument(documentAttachment)).rejects.toThrow(
|
||||
'下载内容为空,请稍后重试',
|
||||
);
|
||||
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||
import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatImageAttachment,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
const DOCUMENT_URL_REVOKE_DELAY_MS = 1000;
|
||||
|
||||
export type AgentComposerMode = 'DRAFT' | 'FORMAL';
|
||||
|
||||
export interface AgentMediaUpload extends ChatImageAttachment {
|
||||
@@ -15,8 +20,16 @@ export interface AgentMediaUpload extends ChatImageAttachment {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface AgentDocumentUpload extends ChatDocumentAttachment {
|
||||
expiresAt?: string;
|
||||
status: 'error' | 'reading' | 'ready' | 'uploading';
|
||||
uploadId: string;
|
||||
}
|
||||
|
||||
export interface AgentComposerDraftPayload {
|
||||
agentId: string;
|
||||
documentUploadIds: string[];
|
||||
documents?: AgentDocumentUpload[];
|
||||
expiresAt?: string;
|
||||
imageUploadIds: string[];
|
||||
images?: AgentMediaUpload[];
|
||||
@@ -63,6 +76,49 @@ export function deleteAgentChatImage(uploadId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadAgentChatDocument(
|
||||
file: File,
|
||||
context: {
|
||||
agentId: string;
|
||||
mode: AgentComposerMode;
|
||||
sessionId: string;
|
||||
},
|
||||
uploadId?: string,
|
||||
) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
body.append('mode', context.mode);
|
||||
body.append('agentId', context.agentId);
|
||||
body.append('sessionId', context.sessionId);
|
||||
if (uploadId) {
|
||||
body.append('uploadId', uploadId);
|
||||
}
|
||||
return api.postFile<RequestResult<AgentDocumentUpload>>(
|
||||
'/api/v1/agent/media/document/upload',
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentChatDocumentStatus(uploadId: string) {
|
||||
return api.get<RequestResult<AgentDocumentUpload>>(
|
||||
'/api/v1/agent/media/document/status',
|
||||
{ params: { uploadId } },
|
||||
);
|
||||
}
|
||||
|
||||
export function retryAgentChatDocument(uploadId: string) {
|
||||
return api.post<RequestResult<AgentDocumentUpload>>(
|
||||
'/api/v1/agent/media/document/retry',
|
||||
{ uploadId },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAgentChatDocument(uploadId: string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/agent/media/document/delete', {
|
||||
uploadId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentComposerDraft(params: {
|
||||
agentId: string;
|
||||
mode: AgentComposerMode;
|
||||
@@ -84,6 +140,7 @@ export function saveAgentComposerDraft(data: AgentComposerDraftPayload) {
|
||||
export function deleteAgentComposerDraft(data: {
|
||||
agentId: string;
|
||||
deleteUploads?: boolean;
|
||||
documentUploadIds?: string[];
|
||||
imageUploadIds?: string[];
|
||||
mode: AgentComposerMode;
|
||||
sessionId: string;
|
||||
@@ -95,7 +152,28 @@ export function deleteAgentComposerDraft(data: {
|
||||
}
|
||||
|
||||
export async function loadAgentChatImage(previewUrl: string) {
|
||||
if (!previewUrl || /^(blob:|data:)/i.test(previewUrl)) return previewUrl;
|
||||
if (!previewUrl || /^(?:blob:|data:)/i.test(previewUrl)) return previewUrl;
|
||||
const blob = await api.download<Blob>(previewUrl);
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
export async function loadAgentChatDocument(item: ChatDocumentAttachment) {
|
||||
if (!item.downloadUrl) return;
|
||||
const blob = await api.download<Blob>(item.downloadUrl);
|
||||
if (!(blob instanceof Blob) || blob.size <= 0) {
|
||||
throw new Error('下载内容为空,请稍后重试');
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = item.name || '文档';
|
||||
try {
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
window.setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, DOCUMENT_URL_REVOKE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import {
|
||||
allocateAgentComposerSession,
|
||||
deleteAgentComposerDraft,
|
||||
@@ -15,11 +15,15 @@ import { useAgentComposerDraft } from './useAgentComposerDraft';
|
||||
|
||||
vi.mock('./mediaApi', () => ({
|
||||
allocateAgentComposerSession: vi.fn(),
|
||||
deleteAgentChatDocument: vi.fn(),
|
||||
deleteAgentChatImage: vi.fn(),
|
||||
deleteAgentComposerDraft: vi.fn(),
|
||||
getAgentChatDocumentStatus: vi.fn(),
|
||||
getAgentComposerDraft: vi.fn(),
|
||||
loadAgentChatImage: vi.fn(),
|
||||
retryAgentChatDocument: vi.fn(),
|
||||
saveAgentComposerDraft: vi.fn(),
|
||||
uploadAgentChatDocument: vi.fn(),
|
||||
uploadAgentChatImage: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -174,6 +178,7 @@ describe('useAgentComposerDraft', () => {
|
||||
expect(deleteAgentComposerDraft).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
deleteUploads: false,
|
||||
documentUploadIds: [],
|
||||
imageUploadIds: [],
|
||||
mode: 'DRAFT',
|
||||
sessionId: 'agent-draft-100',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentComposerDraftPayload, AgentComposerMode } from './mediaApi';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
getAgentComposerDraft,
|
||||
saveAgentComposerDraft,
|
||||
} from './mediaApi';
|
||||
import type { AgentComposerDraftPayload, AgentComposerMode } from './mediaApi';
|
||||
import { useChatDocumentUploads } from './useChatDocumentUploads';
|
||||
import { useChatImageUploads } from './useChatImageUploads';
|
||||
|
||||
const SHADOW_TTL = 24 * 60 * 60 * 1000;
|
||||
@@ -31,6 +33,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
const text = ref('');
|
||||
const revision = ref(0);
|
||||
const images = useChatImageUploads();
|
||||
const documents = useChatDocumentUploads();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let activation = 0;
|
||||
let changeSequence = 0;
|
||||
@@ -140,6 +143,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
function payloadOf(): AgentComposerDraftPayload {
|
||||
return {
|
||||
agentId: agentId.value,
|
||||
documentUploadIds: [...documents.uploadIds.value],
|
||||
imageUploadIds: [...images.uploadIds.value],
|
||||
mode,
|
||||
revision: revision.value,
|
||||
@@ -151,6 +155,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
function shadowPayload(): AgentComposerDraftPayload {
|
||||
return {
|
||||
...payloadOf(),
|
||||
documents: documents.readyItems.value.map((item) => ({ ...item })),
|
||||
images: images.readyItems.value.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
@@ -160,6 +165,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
text.value = draft.text || '';
|
||||
revision.value = Number(draft.revision || 0);
|
||||
images.restore(draft.images || []);
|
||||
documents.restore(draft.documents || []);
|
||||
changeSequence++;
|
||||
}
|
||||
|
||||
@@ -212,7 +218,8 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
(Boolean(saveTimer) ||
|
||||
pendingOperations > 0 ||
|
||||
Boolean(text.value.trim()) ||
|
||||
images.uploadIds.value.length > 0)
|
||||
images.uploadIds.value.length > 0 ||
|
||||
documents.uploadIds.value.length > 0)
|
||||
) {
|
||||
await flush();
|
||||
}
|
||||
@@ -226,6 +233,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
text.value = '';
|
||||
revision.value = 0;
|
||||
images.clear();
|
||||
documents.clear();
|
||||
if (!targetAgentId) return;
|
||||
const shadow = readShadow(targetAgentId, preferredSessionId);
|
||||
try {
|
||||
@@ -276,7 +284,11 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
}
|
||||
const request = shadowPayload();
|
||||
const savedSequence = changeSequence;
|
||||
if (!request.text.trim() && request.imageUploadIds.length === 0) {
|
||||
if (
|
||||
!request.text.trim() &&
|
||||
request.imageUploadIds.length === 0 &&
|
||||
request.documentUploadIds.length === 0
|
||||
) {
|
||||
clearShadow(request.agentId, request.sessionId, context.identity);
|
||||
const response = await deleteAgentComposerDraft({
|
||||
agentId: request.agentId,
|
||||
@@ -335,6 +347,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
const scope = {
|
||||
identity: identityScope(),
|
||||
agentId: agentId.value,
|
||||
documentUploadIds: [...documents.uploadIds.value],
|
||||
imageUploadIds: [...images.uploadIds.value],
|
||||
mode,
|
||||
sessionId: sessionId.value,
|
||||
@@ -345,11 +358,13 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
text.value = '';
|
||||
revision.value = 0;
|
||||
images.clear();
|
||||
documents.clear();
|
||||
if (deleteRemote && scope.agentId && scope.sessionId) {
|
||||
await enqueue(async () => {
|
||||
const response = await deleteAgentComposerDraft({
|
||||
agentId: scope.agentId,
|
||||
deleteUploads,
|
||||
documentUploadIds: scope.documentUploadIds,
|
||||
imageUploadIds: scope.imageUploadIds,
|
||||
mode: scope.mode,
|
||||
sessionId: scope.sessionId,
|
||||
@@ -373,11 +388,13 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
(saveTimer ||
|
||||
pendingOperations > 0 ||
|
||||
text.value.trim() ||
|
||||
images.uploadIds.value.length > 0)
|
||||
images.uploadIds.value.length > 0 ||
|
||||
documents.uploadIds.value.length > 0)
|
||||
) {
|
||||
await flush();
|
||||
text.value = '';
|
||||
images.clear();
|
||||
documents.clear();
|
||||
}
|
||||
const response = await allocateAgentComposerSession(mode);
|
||||
if (response.errorCode !== 0 || !response.data?.sessionId) {
|
||||
@@ -390,6 +407,7 @@ export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
activate,
|
||||
agentId,
|
||||
clear,
|
||||
documents,
|
||||
ensureSession,
|
||||
flush,
|
||||
images,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { nextTick, watchEffect } from 'vue';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useChatDocumentUploads } from './useChatDocumentUploads';
|
||||
|
||||
const mediaApi = vi.hoisted(() => ({
|
||||
deleteAgentChatDocument: vi.fn(),
|
||||
getAgentChatDocumentStatus: vi.fn(),
|
||||
retryAgentChatDocument: vi.fn(),
|
||||
uploadAgentChatDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./mediaApi', () => mediaApi);
|
||||
|
||||
describe('useChatDocumentUploads', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.values(mediaApi).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
it('reactively exposes the ready state after upload', async () => {
|
||||
let resolveUpload: (value: any) => void = () => undefined;
|
||||
mediaApi.uploadAgentChatDocument.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const uploads = useChatDocumentUploads();
|
||||
const observedStatuses: (string | undefined)[] = [];
|
||||
const stop = watchEffect(() => {
|
||||
observedStatuses.push(uploads.items.value[0]?.status);
|
||||
});
|
||||
const pending = uploads.addFiles(
|
||||
[
|
||||
new File(['document'], 'test.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}),
|
||||
],
|
||||
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
resolveUpload({
|
||||
data: {
|
||||
attachmentRef: 'document:test',
|
||||
mimeType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
name: 'test.docx',
|
||||
size: 8,
|
||||
status: 'READY',
|
||||
uploadId: 'upload-1',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
await pending;
|
||||
await nextTick();
|
||||
stop();
|
||||
|
||||
expect(observedStatuses).toContain('uploading');
|
||||
expect(observedStatuses.at(-1)).toBe('ready');
|
||||
expect(uploads.uploadIds.value).toEqual(['upload-1']);
|
||||
expect(mediaApi.uploadAgentChatDocument).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
|
||||
expect.stringMatching(/^[a-f0-9]{32}$/),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes a server read failure without continuing to poll', async () => {
|
||||
mediaApi.uploadAgentChatDocument.mockResolvedValue({
|
||||
data: {
|
||||
attachmentRef: 'document:failed',
|
||||
errorMessage: '未检测到可读取文字',
|
||||
mimeType: 'application/pdf',
|
||||
name: 'scan.pdf',
|
||||
size: 8,
|
||||
status: 'READ_FAILED',
|
||||
uploadId: 'upload-failed',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
|
||||
const uploads = useChatDocumentUploads();
|
||||
await uploads.addFiles(
|
||||
[new File(['document'], 'scan.pdf', { type: 'application/pdf' })],
|
||||
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
|
||||
);
|
||||
|
||||
expect(uploads.items.value[0]).toMatchObject({
|
||||
error: '未检测到可读取文字',
|
||||
status: 'error',
|
||||
uploadId: 'upload-failed',
|
||||
});
|
||||
expect(mediaApi.getAgentChatDocumentStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createChatDocumentUploads } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
deleteAgentChatDocument,
|
||||
getAgentChatDocumentStatus,
|
||||
retryAgentChatDocument,
|
||||
uploadAgentChatDocument,
|
||||
} from './mediaApi';
|
||||
|
||||
/**
|
||||
* 创建接入 Agent 文档接口的聊天附件状态组合。
|
||||
*/
|
||||
export function useChatDocumentUploads() {
|
||||
return createChatDocumentUploads({
|
||||
delete: deleteAgentChatDocument,
|
||||
retry: retryAgentChatDocument,
|
||||
status: getAgentChatDocumentStatus,
|
||||
upload: uploadAgentChatDocument,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user