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,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)"
/>

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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,
});
}

View File

@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest';
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
import type {ChatTimelineMessageItem} from '@easyflow/common-ui';
import { describe, expect, it } from 'vitest';
import {
applyAgentSseEnvelope,
@@ -16,6 +16,18 @@ describe('agentTimelineAdapter', () => {
senderRole: 'user',
contentText: '帮我查一下',
roundId: 'r1',
contentPayload: {
attachments: [
{
attachmentRef: 'formal:r1:document:0',
downloadUrl:
'/api/v1/agent/media/document/content?reference=formal:r1:document:0',
name: '需求说明.docx',
readSnapshotId: 'snapshot-1',
size: 1024,
},
],
},
},
{
id: '2',
@@ -49,6 +61,17 @@ describe('agentTimelineAdapter', () => {
expect(
items.some((item) => item.type === 'message' && item.role === 'user'),
).toBe(true);
const user = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'user',
);
expect(user?.documents?.[0]).toEqual(
expect.objectContaining({
attachmentRef: 'formal:r1:document:0',
name: '需求说明.docx',
status: 'ready',
}),
);
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
@@ -219,7 +242,9 @@ describe('agentTimelineAdapter', () => {
),
).toBe(true);
expect(
items.some((item) => item.type === 'message' && item.role === 'assistant'),
items.some(
(item) => item.type === 'message' && item.role === 'assistant',
),
).toBe(true);
const assistant = items.find(
(item): item is ChatTimelineMessageItem =>
@@ -279,7 +304,9 @@ describe('agentTimelineAdapter', () => {
expect(items.some((item) => item.type === 'tool')).toBe(false);
expect(items.some((item) => item.type === 'status')).toBe(false);
expect(
items.some((item) => item.type === 'message' && item.role === 'assistant'),
items.some(
(item) => item.type === 'message' && item.role === 'assistant',
),
).toBe(true);
});
@@ -301,11 +328,7 @@ describe('agentTimelineAdapter', () => {
it('reconciles streamed text with the canonical final answer', () => {
const items: any[] = [];
for (const delta of [
'http://127.0.0.1:39',
'0',
'/easyflow/file.docx',
]) {
for (const delta of ['http://127.0.0.1:39', '0', '/easyflow/file.docx']) {
applyAgentSseEnvelope(items, {
domain: 'LLM',
type: 'MESSAGE',

View File

@@ -1,6 +1,7 @@
import type { ServerSentEventMessage } from 'fetch-event-stream';
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineKnowledgeHit,
@@ -8,10 +9,11 @@ import type {
ChatTimelineToolApprovalPayload,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import type { AgentChatMessageRecord } from '../api';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
export interface AgentSseEnvelope {
domain: string;
payload: Record<string, any>;
@@ -122,12 +124,13 @@ function assistantMetadata(
}
function normalizeKnowledgeItems(payload: Record<string, any>) {
const rawItems =
asArray(payload.items).length > 0
? asArray(payload.items)
: asArray(payload.knowledgeReferences).length > 0
? asArray(payload.knowledgeReferences)
: asArray(payload.knowledgeCitations);
let rawItems = asArray(payload.items);
if (rawItems.length === 0) {
rawItems = asArray(payload.knowledgeReferences);
}
if (rawItems.length === 0) {
rawItems = asArray(payload.knowledgeCitations);
}
return rawItems
.map((item, index): ChatTimelineKnowledgeHit => {
const source = asRecord(item);
@@ -177,7 +180,26 @@ function normalizeImages(payload: Record<string, any>) {
width: Number(image.width || 0) || undefined,
};
})
.filter((item): item is ChatImageAttachment => Boolean(item));
.filter((item): item is ChatImageAttachment => item !== undefined);
}
function normalizeDocuments(payload: Record<string, any>) {
return asArray(payload.attachments)
.map((value): ChatDocumentAttachment | undefined => {
const document = asRecord(value);
const attachmentRef = asText(document.attachmentRef);
if (!attachmentRef) return undefined;
return {
attachmentRef,
downloadUrl: asText(document.downloadUrl),
mimeType: asText(document.mimeType),
name: asText(document.name) || '文档',
readSnapshotId: asText(document.readSnapshotId),
size: Number(document.size || 0) || undefined,
status: 'ready',
};
})
.filter((item): item is ChatDocumentAttachment => item !== undefined);
}
function buildApprovalPayload(payload: Record<string, any>) {
@@ -340,6 +362,7 @@ function appendHistoryRecord(
if (role === 'user') {
ChatTimelineBuilder.appendUserMessage(items, record.contentText, {
...metadata,
documents: normalizeDocuments(asRecord(record.contentPayload)),
images: normalizeImages(asRecord(record.contentPayload)),
});
return;
@@ -467,6 +490,12 @@ export function applyAgentSseEnvelope(
const toolName = normalizeToolName(
payload.toolDisplayName ?? payload.toolName ?? payload.name,
);
let status: ChatTimelineToolStatus = 'running';
if (asyncTool) {
status = asyncToolTimelineStatus(payload);
} else if (type === 'TOOL_RESULT') {
status = 'success';
}
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: asyncTool
@@ -476,11 +505,7 @@ export function applyAgentSseEnvelope(
payload.result ??
payload.text)
: (payload.output ?? payload.result ?? payload.text),
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'
? 'success'
: 'running',
status,
statusKey: statusKeyForProjection(
payload,
metadata,

View File

@@ -1,14 +1,14 @@
// @vitest-environment happy-dom
import { useUserStore } from '@easyflow/stores';
import { createPinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useUserStore } from '@easyflow/stores';
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
import { sendAgentChat } from './api';
import { agentChatRuntimeManager } from './agentChatRuntimeManager';
import { sendAgentChat } from './api';
vi.mock('./api', () => ({
generateAgentSessionId: vi.fn(),
@@ -27,7 +27,7 @@ describe('agentChatRuntimeManager', () => {
vi.useRealTimers();
});
it('replaces draft image URLs and isolates snapshots by account', async () => {
it('replaces accepted attachments and isolates snapshots by account', async () => {
let callbacks: any;
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
callbacks = options;
@@ -45,6 +45,16 @@ describe('agentChatRuntimeManager', () => {
await agentChatRuntimeManager.start({
agentId: 'agent-1',
documentUploadIds: ['document-upload-1'],
documents: [
{
downloadUrl:
'/api/v1/agent/media/document/content?reference=draft%3Adocument-upload-1',
name: 'draft.docx',
status: 'ready',
uploadId: 'document-upload-1',
},
],
images: [
{
name: 'draft.png',
@@ -60,6 +70,16 @@ describe('agentChatRuntimeManager', () => {
data: JSON.stringify({
domain: 'SYSTEM',
payload: {
attachments: [
{
attachmentRef: 'formal:101:201:document:0',
downloadUrl:
'/api/v1/agent/media/document/content?reference=formal:101:201:document:0',
name: 'draft.docx',
readSnapshotId: 'snapshot-1',
size: 2048,
},
],
images: [
{
imageRef: 'formal:101:201:0:png',
@@ -86,6 +106,20 @@ describe('agentChatRuntimeManager', () => {
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
}),
);
expect(
userMessage?.type === 'message' ? userMessage.documents?.[0] : null,
).toEqual(
expect.objectContaining({
attachmentRef: 'formal:101:201:document:0',
readSnapshotId: 'snapshot-1',
status: 'ready',
}),
);
expect(vi.mocked(sendAgentChat).mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
documentUploadIds: ['document-upload-1'],
}),
);
userStore.setUserInfo({
...firstAccount,

View File

@@ -1,8 +1,12 @@
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import type { AgentChatCapabilityPayload } from './api';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import { useUserStore } from '@easyflow/stores';
@@ -12,18 +16,16 @@ import {
RUNTIME_STORAGE_PREFIX,
} from '#/utils/agent-chat-cache';
import type { AgentChatCapabilityPayload } from './api';
import {
applyAgentSseEnvelope,
parseAgentSseMessage,
} from './adapters/agentTimelineAdapter';
import {
generateAgentSessionId,
sendAgentChat,
stopAgentChatStream,
} from './api';
import {
applyAgentSseEnvelope,
parseAgentSseMessage,
} from './adapters/agentTimelineAdapter';
interface RuntimeSessionState {
agentId: string;
agentName?: string;
@@ -56,9 +58,11 @@ interface StartOptions {
agentName?: string;
baseItems?: ChatTimelineItem[];
capabilities?: AgentChatCapabilityPayload[];
documentUploadIds?: string[];
documents?: ChatDocumentAttachment[];
imageUploadIds?: string[];
images?: ChatImageAttachment[];
onInputAccepted?: () => void | Promise<void>;
onInputAccepted?: () => Promise<void> | void;
prompt: string;
sessionId?: string;
}
@@ -345,16 +349,44 @@ function normalizeAcceptedImages(payload: Record<string, any>) {
width: Number(image.width || 0) || undefined,
};
})
.filter((image): image is ChatImageAttachment => Boolean(image));
.filter((item): item is ChatImageAttachment => item !== undefined);
}
function replaceAcceptedImages(
function normalizeAcceptedDocuments(payload: Record<string, any>) {
if (!Array.isArray(payload.attachments)) {
return [];
}
return payload.attachments
.map((value): ChatDocumentAttachment | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const document = value as Record<string, any>;
const attachmentRef = String(document.attachmentRef || '');
if (!attachmentRef) {
return undefined;
}
return {
attachmentRef,
downloadUrl: String(document.downloadUrl || ''),
mimeType: String(document.mimeType || ''),
name: String(document.name || '文档'),
readSnapshotId: String(document.readSnapshotId || ''),
size: Number(document.size || 0) || undefined,
status: 'ready',
};
})
.filter((item): item is ChatDocumentAttachment => item !== undefined);
}
function replaceAcceptedAttachments(
items: ChatTimelineItem[],
roundId: string,
payload: Record<string, any>,
) {
const acceptedImages = normalizeAcceptedImages(payload);
if (acceptedImages.length === 0) {
const acceptedDocuments = normalizeAcceptedDocuments(payload);
if (acceptedImages.length === 0 && acceptedDocuments.length === 0) {
return;
}
const userMessage = items.find(
@@ -364,7 +396,12 @@ function replaceAcceptedImages(
item.roundId === roundId,
);
if (userMessage) {
userMessage.images = acceptedImages;
if (acceptedImages.length > 0) {
userMessage.images = acceptedImages;
}
if (acceptedDocuments.length > 0) {
userMessage.documents = acceptedDocuments;
}
}
}
@@ -430,6 +467,7 @@ export const agentChatRuntimeManager = {
updatedAt: Date.now(),
};
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
documents: options.documents,
images: options.images,
roundId,
});
@@ -439,6 +477,7 @@ export const agentChatRuntimeManager = {
{
agentId: options.agentId,
capabilities: options.capabilities,
documentUploadIds: options.documentUploadIds,
imageUploadIds: options.imageUploadIds,
prompt: options.prompt,
sessionId,
@@ -479,7 +518,11 @@ export const agentChatRuntimeManager = {
envelope.domain === 'SYSTEM' &&
envelope.type === 'INPUT_ACCEPTED'
) {
replaceAcceptedImages(current.items, roundId, envelope.payload);
replaceAcceptedAttachments(
current.items,
roundId,
envelope.payload,
);
void options.onInputAccepted?.();
}
applyAgentSseEnvelope(current.items, envelope, { roundId });

View File

@@ -170,6 +170,7 @@ export function sendAgentChat(
data: {
agentId: number | string;
capabilities?: AgentChatCapabilityPayload[];
documentUploadIds?: string[];
imageUploadIds?: string[];
prompt: string;
sessionId?: number | string;

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"

View File

@@ -97,6 +97,44 @@ const emit = defineEmits<{ change: [] }>();
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span class="agent-form__label">
文档上下文预算
<ElTooltip
:trigger-keys="['Enter', 'Space']"
content="控制单次对话最多注入多少文档内容,建议设置为 20K Token 或以上。数值过低可能导致长文档仅读取部分内容,影响回答完整性;请确保该值未超过模型的实际上下文能力。"
effect="light"
placement="top"
>
<ElIcon
class="agent-form__info"
aria-label="文档上下文预算说明"
tabindex="0"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="agent.executionConfigJson!.documentContextBudgetTokens"
:min="1"
:step="1000"
controls-position="right"
aria-label="文档上下文预算 Token "
@change="emit('change')"
/>
<div
v-if="
Number(agent.executionConfigJson!.documentContextBudgetTokens) <
20_000
"
class="agent-form__hint is-warning"
>
建议设置为 20K Token 或以上
</div>
</ElFormItem>
<ElFormItem label="系统提示词">
<ElInput
v-model="agent.promptConfigJson!.systemPrompt"
@@ -175,14 +213,24 @@ const emit = defineEmits<{ change: [] }>();
.agent-form__label {
display: inline-flex;
align-items: center;
gap: 4px;
align-items: center;
}
.agent-form__info {
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: help;
font-size: 14px;
}
.agent-form__hint {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
}
.agent-form__hint.is-warning {
color: var(--el-color-warning);
}
.agent-form :deep(.el-select),

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from '@easyflow/common-ui';
@@ -19,7 +21,10 @@ import { copyTextToClipboard } from '@easyflow/utils';
import { ElButton, ElMessage } from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
import {
loadAgentChatDocument,
loadAgentChatImage,
} from '#/components/ai-chat/mediaApi';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { approveAgentRun, rejectAgentRun } from '../api';
@@ -104,6 +109,10 @@ async function handleSend(prompt: string) {
ElMessage.warning('图片上传完成后再发送');
return;
}
if (composer.documents.processing.value) {
ElMessage.warning('文档读取完成后再发送');
return;
}
const failedImage = composer.images.items.value.find(
(item) => item.status === 'error',
);
@@ -111,9 +120,20 @@ async function handleSend(prompt: 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();
await sendDraft({
...getDraftContext(),
documentUploadIds: composer.documents.uploadIds.value,
documents: composer.documents.readyItems.value.map((item) => ({
...item,
})),
prompt,
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
@@ -188,7 +208,7 @@ async function handleClearSession() {
}
}
async function handleAddFiles(files: File[]) {
async function handleAddImageFiles(files: File[]) {
if (!props.agent.id) {
ElMessage.warning('请先保存智能体');
return;
@@ -205,7 +225,24 @@ async function handleAddFiles(files: File[]) {
composer.scheduleSave();
}
async function handleRetryImage(item: any) {
async function handleAddDocumentFiles(files: File[]) {
if (!props.agent.id) {
ElMessage.warning('请先保存智能体');
return;
}
await composer.ensureSession();
const rejected = await composer.documents.addFiles(files, {
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
});
if (rejected > 0) {
ElMessage.warning('每轮最多添加 3 份文档');
}
composer.scheduleSave();
}
async function handleRetryImage(item: ChatImageAttachment) {
await composer.images.retry(item, {
agentId: String(props.agent.id),
mode: 'DRAFT',
@@ -214,7 +251,7 @@ async function handleRetryImage(item: any) {
composer.scheduleSave();
}
async function handleRemoveImage(item: any) {
async function handleRemoveImage(item: ChatImageAttachment) {
try {
await composer.images.remove(item);
composer.scheduleSave();
@@ -223,6 +260,24 @@ async function handleRemoveImage(item: any) {
}
}
async function handleRetryDocument(item: ChatDocumentAttachment) {
await composer.documents.retry(item, {
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
}
async function handleRemoveDocument(item: ChatDocumentAttachment) {
try {
await composer.documents.remove(item);
composer.scheduleSave();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文档删除失败');
}
}
function handleStop() {
if (!loading.value) {
return;
@@ -275,6 +330,8 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
closable
:messages="[]"
:loading="loading"
:documents="composer.documents.items.value"
:document-loader="loadAgentChatDocument"
:images="composer.images.items.value"
:image-enabled="imageEnabled"
:image-loader="loadAgentChatImage"
@@ -282,8 +339,11 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
:approval-loading="approvalLoading"
@send="handleSend"
@update:model-value="handleDraftTextInput"
@add-files="handleAddFiles"
@add-files="handleAddImageFiles"
@add-document-files="handleAddDocumentFiles"
@remove-document="handleRemoveDocument"
@remove-image="handleRemoveImage"
@retry-document="handleRetryDocument"
@retry-image="handleRetryImage"
@stop="handleStop"
@approve="handleApprove"
@@ -314,6 +374,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
<ChatTimeline
v-else
:items="timelineItems"
:document-loader="loadAgentChatDocument"
:image-loader="loadAgentChatImage"
empty-text="输入问题试运行当前智能体"
:approval-loading="approvalLoading"

View File

@@ -32,6 +32,38 @@ describe('useAgentDesignerState generation stream', () => {
});
});
describe('useAgentDesignerState document context budget', () => {
it('defaults new and legacy agents to twenty thousand tokens', () => {
expect(
createEmptyAgent().executionConfigJson?.documentContextBudgetTokens,
).toBe(20_000);
const designer = useAgentDesignerState();
designer.reset({ name: '旧智能体' });
expect(
designer.state.agent.executionConfigJson?.documentContextBudgetTokens,
).toBe(20_000);
expect(
designer.buildPayloadAgent().executionConfigJson
?.documentContextBudgetTokens,
).toBe(20_000);
});
it('preserves a positive custom document context budget', () => {
const designer = useAgentDesignerState();
designer.reset({
executionConfigJson: { documentContextBudgetTokens: 32_000 },
name: '长文档智能体',
});
expect(
designer.buildPayloadAgent().executionConfigJson
?.documentContextBudgetTokens,
).toBe(32_000);
});
});
describe('useAgentDesignerState memory compression', () => {
it('keeps only token-based compression settings in state and payload', () => {
expect(

View File

@@ -51,10 +51,13 @@ function resolveToolName(
if (isSafeToolName(resource?.name)) {
return String(resource?.name);
}
return buildFallbackToolName(
kind === 'workflow' ? 'workflow' : kind === 'mcp' ? 'mcp' : 'plugin',
resource,
);
let prefix = 'plugin';
if (kind === 'workflow') {
prefix = 'workflow';
} else if (kind === 'mcp') {
prefix = 'mcp';
}
return buildFallbackToolName(prefix, resource);
}
function normalizeBindingToolName(binding: AgentToolBinding) {
@@ -66,8 +69,8 @@ function normalizeBindingToolName(binding: AgentToolBinding) {
}
const kind = toolKindFromType(binding.toolType);
const resource = {
...(binding.resourceSnapshot || {}),
...(binding.resourceSummary || {}),
...binding.resourceSnapshot,
...binding.resourceSummary,
id:
binding.targetId ||
binding.resourceSummary?.id ||
@@ -90,6 +93,9 @@ export function createEmptyAgent(): AgentInfo {
description: '',
avatar: '',
categoryId: '',
executionConfigJson: {
documentContextBudgetTokens: 20_000,
},
modelId: '',
promptConfigJson: { systemPrompt: '' },
generationConfigJson: { stream: true },
@@ -124,6 +130,13 @@ function normalizeAgent(agent?: AgentInfo): AgentInfo {
...source.generationConfigJson,
stream: source.generationConfigJson?.stream !== false,
},
executionConfigJson: {
...source.executionConfigJson,
documentContextBudgetTokens:
Number(source.executionConfigJson?.documentContextBudgetTokens) > 0
? Number(source.executionConfigJson?.documentContextBudgetTokens)
: 20_000,
},
memoryConfigJson: {
...memoryConfig,
compressionParameter: {
@@ -166,7 +179,7 @@ function normalizeToolBinding(
index: number,
): AgentToolBinding {
const optionsJson = {
...(binding.optionsJson || {}),
...binding.optionsJson,
};
if (String(optionsJson.executionMode || '').toUpperCase() !== 'ASYNC') {
optionsJson.executionMode = 'SYNC';
@@ -269,16 +282,17 @@ export function useAgentDesignerState() {
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
resource?: Record<string, any>,
) {
const toolType =
kind === 'workflow' ? 'WORKFLOW' : kind === 'mcp' ? 'MCP' : 'PLUGIN';
let toolType = 'PLUGIN';
if (kind === 'workflow') {
toolType = 'WORKFLOW';
} else if (kind === 'mcp') {
toolType = 'MCP';
}
const targetId = resource?.mcpId || resource?.id;
const binding = normalizeToolBinding(
{
toolType,
targetId: resource?.mcpId
? String(resource.mcpId)
: resource?.id
? String(resource.id)
: '',
targetId: targetId ? String(targetId) : '',
toolName: kind === 'mcp' ? '' : resolveToolName(kind, resource),
resourceSummary: resource || {},
},
@@ -373,6 +387,17 @@ export function useAgentDesignerState() {
...state.agent.generationConfigJson,
stream: state.agent.generationConfigJson?.stream !== false,
},
executionConfigJson: {
...state.agent.executionConfigJson,
documentContextBudgetTokens: Math.max(
1,
Math.trunc(
Number(
state.agent.executionConfigJson?.documentContextBudgetTokens,
) || 20_000,
),
),
},
memoryConfigJson: {
...restMemoryConfigJson,
compressionParameter: {

View File

@@ -1,10 +1,12 @@
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
interface AgentTryoutRuntimeEvent {
@@ -26,6 +28,7 @@ interface AgentTryoutRawVariant {
interface AgentTryoutRawRound {
createdAt: number;
documents?: ChatDocumentAttachment[];
images?: ChatImageAttachment[];
prompt: string;
roundId: string;
@@ -41,7 +44,7 @@ interface AgentTryoutRawSessionRecord {
version: number;
}
const STORAGE_VERSION = 1;
const STORAGE_VERSION = 2;
const MAX_ROUNDS = 50;
const MAX_VARIANTS = 10;
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
@@ -86,7 +89,7 @@ function isHiddenToolName(value: unknown) {
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
return structuredClone(value);
}
function storageKey(mode: string, sessionId: string) {
@@ -141,7 +144,7 @@ function normalizeVariant(value: any, index: number) {
.filter(
(
item: AgentTryoutRuntimeEvent | undefined,
): item is AgentTryoutRuntimeEvent => Boolean(item),
): item is AgentTryoutRuntimeEvent => item !== undefined,
)
: [];
return {
@@ -162,8 +165,11 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
}
const prompt = asText(value.prompt);
const roundId = asText(value.roundId);
const documents = Array.isArray(value.documents)
? value.documents.slice(0, 3)
: [];
const images = Array.isArray(value.images) ? value.images.slice(0, 5) : [];
if ((!prompt && images.length === 0) || !roundId) {
if ((!prompt && images.length === 0 && documents.length === 0) || !roundId) {
return undefined;
}
const variants = Array.isArray(value.variants)
@@ -175,11 +181,12 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
variants.push(createVariant(1));
}
const selectedVariantIndex = Math.min(
Math.max(Number(value.selectedVariantIndex || variants.length), 1),
Math.max(Number(value.selectedVariantIndex ?? variants.length), 1),
variants.length,
);
return {
createdAt: Number(value.createdAt || Date.now()),
documents,
images,
prompt,
roundId,
@@ -215,7 +222,7 @@ function restoreSession(mode: string, sessionId: string) {
const rounds = Array.isArray(parsed.rounds)
? parsed.rounds
.map((item) => normalizeRound(item))
.filter((item): item is AgentTryoutRawRound => Boolean(item))
.filter((item): item is AgentTryoutRawRound => item !== undefined)
: [];
memorySessions.set(
key,
@@ -337,14 +344,14 @@ function findRoundResponseRange(items: ChatTimelineItem[], roundId: string) {
const userIndex = items.findIndex(
(item) => isUserMessage(item) && item.roundId === roundId,
);
if (userIndex < 0) {
if (userIndex === -1) {
return undefined;
}
const nextUserIndex = items.findIndex(
(item, index) => index > userIndex && isUserMessage(item),
);
return {
end: nextUserIndex >= 0 ? nextUserIndex : items.length,
end: nextUserIndex === -1 ? items.length : nextUserIndex,
start: userIndex + 1,
};
}
@@ -569,6 +576,16 @@ function projectEventToTimeline(
);
const asyncTool = payload.asyncTool === true;
const taskInput = asRecord(payload.input ?? payload.toolInput);
let status: ChatTimelineToolStatus = 'running';
if (asyncTool) {
status = asyncToolTimelineStatus(payload);
} else if (type === 'TOOL_RESULT') {
status = 'success';
}
let toolName = displayToolName;
if (!asyncTool && isHiddenToolName(rawToolName)) {
toolName = rawToolName;
}
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: asyncTool
@@ -578,11 +595,7 @@ function projectEventToTimeline(
payload.result ??
payload.text)
: (payload.output ?? payload.result ?? payload.text),
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'
? 'success'
: 'running',
status,
statusKey: statusKeyForProjection(
payload,
roundId,
@@ -601,11 +614,7 @@ function projectEventToTimeline(
payload.tool_call_id ??
payload.id,
),
toolName: asyncTool
? displayToolName
: isHiddenToolName(rawToolName)
? rawToolName
: displayToolName,
toolName,
});
return;
}
@@ -713,11 +722,16 @@ export function useAgentTryoutRawRounds(options: {
removeStoredSession(options.mode, options.sessionId);
}
function createRound(prompt: string, images: ChatImageAttachment[] = []) {
function createRound(
prompt: string,
images: ChatImageAttachment[] = [],
documents: ChatDocumentAttachment[] = [],
) {
const now = Date.now();
const roundId = createRoundId();
rounds.set(roundId, {
createdAt: now,
documents: clone(documents),
images: clone(images),
prompt,
roundId,
@@ -835,6 +849,7 @@ export function useAgentTryoutRawRounds(options: {
const items: ChatTimelineItem[] = [];
for (const round of sortedRounds(rounds)) {
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
documents: round.documents,
id: `user-${round.roundId}`,
images: round.images,
roundId: round.roundId,

View File

@@ -1,11 +1,11 @@
import type { ServerSentEventMessage } from 'fetch-event-stream';
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import type {
AgentInfo,
@@ -15,6 +15,8 @@ import type {
import { ref } from 'vue';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
import { sseClient } from '#/api/request';
import { clearAgentDraftSession } from '../api';
@@ -211,22 +213,17 @@ export function useAgentTryoutStream() {
}
if (isEndOfRoundEvent(domain, type)) {
markRoundCompleted(activeRoundId);
return;
}
if (type === 'ERROR' || domain === 'ERROR') {
const message = payload.message ?? payload.error ?? '试运行失败';
if (shouldIgnoreStoppedError(message)) {
return;
}
}
}
async function runDraft(payload: {
agent: AgentInfo;
documents?: ChatDocumentAttachment[];
documentUploadIds?: string[];
images?: ChatImageAttachment[];
imageUploadIds?: string[];
knowledgeBindings: AgentKnowledgeBinding[];
onAccepted?: () => void | Promise<void>;
onAccepted?: () => Promise<void> | void;
prompt: string;
sessionId?: string;
toolBindings: AgentToolBinding[];
@@ -235,7 +232,11 @@ export function useAgentTryoutStream() {
if (!rawRounds) {
return;
}
activeRoundId = rawRounds.createRound(payload.prompt, payload.images);
activeRoundId = rawRounds.createRound(
payload.prompt,
payload.images,
payload.documents,
);
rebuildTimeline();
loading.value = true;
userStopped = false;
@@ -244,6 +245,7 @@ export function useAgentTryoutStream() {
'/api/v1/agent/chat/draft',
{
agent: payload.agent,
documentUploadIds: payload.documentUploadIds,
imageUploadIds: payload.imageUploadIds,
knowledgeBindings: payload.knowledgeBindings,
prompt: payload.prompt,
@@ -292,10 +294,12 @@ export function useAgentTryoutStream() {
async function sendDraft(payload: {
agent: AgentInfo;
documents?: ChatDocumentAttachment[];
documentUploadIds?: string[];
images?: ChatImageAttachment[];
imageUploadIds?: string[];
knowledgeBindings: AgentKnowledgeBinding[];
onAccepted?: () => void | Promise<void>;
onAccepted?: () => Promise<void> | void;
prompt: string;
sessionId?: string;
toolBindings: AgentToolBinding[];