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