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,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[];