feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理 - 支持输入草稿恢复、图片交互和模型图片能力约束 - 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
@@ -7,23 +8,34 @@ import { ElButton } from 'element-plus';
|
||||
import AiConversation from './AiConversation.vue';
|
||||
import AiPromptInput from './AiPromptInput.vue';
|
||||
|
||||
defineProps<{
|
||||
approvalLoading?: boolean;
|
||||
closable?: boolean;
|
||||
emptyText?: string;
|
||||
loading?: boolean;
|
||||
messages: AiChatMessage[];
|
||||
placeholder?: string;
|
||||
subtitle?: string;
|
||||
title: string;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
approvalLoading?: boolean;
|
||||
closable?: boolean;
|
||||
emptyText?: string;
|
||||
loading?: boolean;
|
||||
images?: ChatImageAttachment[];
|
||||
imageEnabled?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
modelValue?: string;
|
||||
messages: AiChatMessage[];
|
||||
placeholder?: string;
|
||||
subtitle?: string;
|
||||
title: string;
|
||||
}>(),
|
||||
{ imageEnabled: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
addFiles: [files: File[]];
|
||||
approve: [payload: AiToolApprovalPayload];
|
||||
close: [];
|
||||
reject: [payload: AiToolApprovalPayload];
|
||||
send: [text: string];
|
||||
removeImage: [item: ChatImageAttachment];
|
||||
retryImage: [item: ChatImageAttachment];
|
||||
stop: [];
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
@@ -63,10 +75,18 @@ defineSlots<{
|
||||
/>
|
||||
</slot>
|
||||
<AiPromptInput
|
||||
:model-value="modelValue"
|
||||
:images="images"
|
||||
:image-enabled="imageEnabled"
|
||||
:image-loader="imageLoader"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
@send="emit('send', $event)"
|
||||
@add-files="emit('addFiles', $event)"
|
||||
@remove-image="emit('removeImage', $event)"
|
||||
@retry-image="emit('retryImage', $event)"
|
||||
@stop="emit('stop')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {mount} from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AiPromptInput from './AiPromptInput.vue';
|
||||
|
||||
@@ -9,10 +9,12 @@ describe('AiPromptInput', () => {
|
||||
const wrapper = mount(AiPromptInput, {
|
||||
props: {
|
||||
loading: false,
|
||||
modelValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find('textarea').setValue('你好');
|
||||
await wrapper.setProps({ modelValue: '你好' });
|
||||
await wrapper.find('[aria-label="发送"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('你好');
|
||||
@@ -30,4 +32,62 @@ describe('AiPromptInput', () => {
|
||||
|
||||
expect(wrapper.emitted('stop')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports sending a ready image without text', async () => {
|
||||
const wrapper = mount(AiPromptInput, {
|
||||
props: {
|
||||
images: [
|
||||
{
|
||||
localId: 'image-1',
|
||||
mimeType: 'image/png',
|
||||
name: 'test.png',
|
||||
previewUrl: 'data:image/png;base64,AA==',
|
||||
size: 1,
|
||||
status: 'ready',
|
||||
uploadId: 'upload-1',
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find('[aria-label="发送"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('');
|
||||
});
|
||||
|
||||
it('extracts image files from clipboard paste', async () => {
|
||||
const wrapper = mount(AiPromptInput, {
|
||||
props: {
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
const image = new File(['image'], 'pasted.png', { type: 'image/png' });
|
||||
const event = new Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', {
|
||||
value: { files: [image] },
|
||||
});
|
||||
|
||||
wrapper.find('textarea').element.dispatchEvent(event);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(wrapper.emitted('addFiles')?.[0]?.[0]).toEqual([image]);
|
||||
});
|
||||
|
||||
it('extracts image files from drop', async () => {
|
||||
const wrapper = mount(AiPromptInput, {
|
||||
props: {
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
const image = new File(['image'], 'dropped.jpg', { type: 'image/jpeg' });
|
||||
const text = new File(['text'], 'note.txt', { type: 'text/plain' });
|
||||
|
||||
await wrapper.find('.ai-prompt-input').trigger('drop', {
|
||||
dataTransfer: { files: [image, text] },
|
||||
});
|
||||
|
||||
expect(wrapper.emitted('addFiles')?.[0]?.[0]).toEqual([image]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue';
|
||||
import type { ChatImageAttachment, ChatImageLoader } from '@easyflow/common-ui';
|
||||
|
||||
import {Promotion} from '@element-plus/icons-vue';
|
||||
import {ElButton, ElInput} from 'element-plus';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
import { 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[];
|
||||
imageEnabled?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ imageEnabled: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
addFiles: [files: File[]];
|
||||
removeImage: [item: ChatImageAttachment];
|
||||
retryImage: [item: ChatImageAttachment];
|
||||
send: [text: string];
|
||||
stop: [];
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
const canSend = computed(() => text.value.trim().length > 0 && !props.loading);
|
||||
const text = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (value: string) => emit('update:modelValue', value),
|
||||
});
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const dragActive = ref(false);
|
||||
const hasReadyImage = computed(() =>
|
||||
(props.images || []).some((item) => item.status === 'ready'),
|
||||
);
|
||||
const hasPendingImage = computed(() =>
|
||||
(props.images || []).some((item) => item.status !== 'ready'),
|
||||
);
|
||||
const canSend = computed(
|
||||
() =>
|
||||
(text.value.trim().length > 0 || hasReadyImage.value) &&
|
||||
(!hasReadyImage.value || props.imageEnabled !== false) &&
|
||||
!hasPendingImage.value &&
|
||||
!props.loading,
|
||||
);
|
||||
|
||||
function send() {
|
||||
const value = text.value.trim();
|
||||
if (!value || props.loading) return;
|
||||
if (
|
||||
(!value && !hasReadyImage.value) ||
|
||||
props.loading ||
|
||||
hasPendingImage.value
|
||||
)
|
||||
return;
|
||||
emit('send', value);
|
||||
text.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);
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (props.imageEnabled === false) return;
|
||||
const files = [...(event.clipboardData?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
emit('addFiles', files);
|
||||
ElMessage.success(
|
||||
files.length === 1 ? '已粘贴图片' : `已粘贴 ${files.length} 张图片`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (
|
||||
!props.loading &&
|
||||
props.imageEnabled !== false &&
|
||||
(props.images?.length || 0) < 5 &&
|
||||
[...(event.dataTransfer?.types || [])].includes('Files')
|
||||
) {
|
||||
dragActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!container.contains(event.relatedTarget)
|
||||
) {
|
||||
dragActive.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
dragActive.value = false;
|
||||
if (
|
||||
props.loading ||
|
||||
props.imageEnabled === false ||
|
||||
(props.images?.length || 0) >= 5
|
||||
)
|
||||
return;
|
||||
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (files.length) emit('addFiles', files);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
@@ -38,7 +133,24 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-prompt-input">
|
||||
<div
|
||||
class="ai-prompt-input"
|
||||
:class="{ 'is-dragging': dragActive }"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<ChatImageAttachments
|
||||
v-if="images?.length"
|
||||
class="ai-prompt-input__images"
|
||||
:items="images"
|
||||
:image-loader="imageLoader"
|
||||
removable
|
||||
retryable
|
||||
@remove="emit('removeImage', $event)"
|
||||
@retry="emit('retryImage', $event)"
|
||||
/>
|
||||
<ElInput
|
||||
v-model="text"
|
||||
class="ai-prompt-input__textarea"
|
||||
@@ -47,8 +159,29 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
:autosize="{ minRows: 1, maxRows: 5 }"
|
||||
:disabled="loading"
|
||||
:placeholder="placeholder || '输入消息'"
|
||||
@paste="handlePaste"
|
||||
@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"
|
||||
multiple
|
||||
@change="handleFiles"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="imageEnabled !== false"
|
||||
:icon="Paperclip"
|
||||
circle
|
||||
text
|
||||
:disabled="loading || (images?.length || 0) >= 5"
|
||||
aria-label="添加图片"
|
||||
title="添加图片"
|
||||
class="ai-prompt-input__attach"
|
||||
@click="chooseFiles"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="loading"
|
||||
type="primary"
|
||||
@@ -73,10 +206,12 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
|
||||
<style scoped>
|
||||
.ai-prompt-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
margin: 0 16px 16px;
|
||||
background: var(--el-bg-color);
|
||||
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
|
||||
@@ -85,6 +220,24 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.ai-prompt-input.is-dragging {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.ai-prompt-input__images {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.ai-prompt-input__file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-prompt-input__attach {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.ai-prompt-input__textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
101
easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts
Normal file
101
easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
export type AgentComposerMode = 'DRAFT' | 'FORMAL';
|
||||
|
||||
export interface AgentMediaUpload extends ChatImageAttachment {
|
||||
expiresAt?: string;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
name: string;
|
||||
previewUrl: string;
|
||||
size: number;
|
||||
uploadId: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface AgentComposerDraftPayload {
|
||||
agentId: string;
|
||||
expiresAt?: string;
|
||||
imageUploadIds: string[];
|
||||
images?: AgentMediaUpload[];
|
||||
mode: AgentComposerMode;
|
||||
revision: number;
|
||||
sessionId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface RequestResult<T = any> {
|
||||
data: T;
|
||||
errorCode: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function allocateAgentComposerSession(mode: AgentComposerMode) {
|
||||
return api.post<
|
||||
RequestResult<{ mode: AgentComposerMode; sessionId: string }>
|
||||
>('/api/v1/agent/composer/session', { mode });
|
||||
}
|
||||
|
||||
export function uploadAgentChatImage(
|
||||
file: File,
|
||||
context: {
|
||||
agentId: string;
|
||||
mode: AgentComposerMode;
|
||||
sessionId: string;
|
||||
},
|
||||
) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
body.append('mode', context.mode);
|
||||
body.append('agentId', context.agentId);
|
||||
body.append('sessionId', context.sessionId);
|
||||
return api.postFile<RequestResult<AgentMediaUpload>>(
|
||||
'/api/v1/agent/media/upload',
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAgentChatImage(uploadId: string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/agent/media/delete', {
|
||||
uploadId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentComposerDraft(params: {
|
||||
agentId: string;
|
||||
mode: AgentComposerMode;
|
||||
sessionId?: string;
|
||||
}) {
|
||||
return api.get<RequestResult<AgentComposerDraftPayload | null>>(
|
||||
'/api/v1/agent/composer/draft',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
export function saveAgentComposerDraft(data: AgentComposerDraftPayload) {
|
||||
return api.post<RequestResult<AgentComposerDraftPayload>>(
|
||||
'/api/v1/agent/composer/draft/persist',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAgentComposerDraft(data: {
|
||||
agentId: string;
|
||||
deleteUploads?: boolean;
|
||||
imageUploadIds?: string[];
|
||||
mode: AgentComposerMode;
|
||||
sessionId: string;
|
||||
}) {
|
||||
return api.post<RequestResult<void>>(
|
||||
'/api/v1/agent/composer/draft/delete',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadAgentChatImage(previewUrl: string) {
|
||||
if (!previewUrl || /^(blob:|data:)/i.test(previewUrl)) return previewUrl;
|
||||
const blob = await api.download<Blob>(previewUrl);
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import {
|
||||
allocateAgentComposerSession,
|
||||
deleteAgentComposerDraft,
|
||||
getAgentComposerDraft,
|
||||
saveAgentComposerDraft,
|
||||
} from './mediaApi';
|
||||
import { useAgentComposerDraft } from './useAgentComposerDraft';
|
||||
|
||||
vi.mock('./mediaApi', () => ({
|
||||
allocateAgentComposerSession: vi.fn(),
|
||||
deleteAgentChatImage: vi.fn(),
|
||||
deleteAgentComposerDraft: vi.fn(),
|
||||
getAgentComposerDraft: vi.fn(),
|
||||
loadAgentChatImage: vi.fn(),
|
||||
saveAgentComposerDraft: vi.fn(),
|
||||
uploadAgentChatImage: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useAgentComposerDraft', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'user-1',
|
||||
loginName: 'admin',
|
||||
nickname: '管理员',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(allocateAgentComposerSession).mockResolvedValue({
|
||||
data: { mode: 'FORMAL', sessionId: '100' },
|
||||
errorCode: 0,
|
||||
});
|
||||
vi.mocked(getAgentComposerDraft).mockResolvedValue({
|
||||
data: null,
|
||||
errorCode: 0,
|
||||
});
|
||||
vi.mocked(deleteAgentComposerDraft).mockResolvedValue({
|
||||
data: undefined,
|
||||
errorCode: 0,
|
||||
});
|
||||
vi.mocked(saveAgentComposerDraft).mockImplementation(async (draft) => ({
|
||||
data: {
|
||||
...draft,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
revision: draft.revision + 1,
|
||||
},
|
||||
errorCode: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it('restores an unsynced local shadow and writes it back to Redis', async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = useAgentComposerDraft('FORMAL');
|
||||
await first.activate('agent-1');
|
||||
first.text.value = '刷新前尚未发送的内容';
|
||||
first.scheduleSave();
|
||||
|
||||
const restored = useAgentComposerDraft('FORMAL');
|
||||
await restored.activate('agent-1');
|
||||
|
||||
expect(restored.sessionId.value).toBe('100');
|
||||
expect(restored.text.value).toBe('刷新前尚未发送的内容');
|
||||
expect(saveAgentComposerDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'agent-1',
|
||||
sessionId: '100',
|
||||
text: '刷新前尚未发送的内容',
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps local shadows isolated by the logged-in account', async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = useAgentComposerDraft('FORMAL');
|
||||
await first.activate('agent-1');
|
||||
first.text.value = '账号一的草稿';
|
||||
first.scheduleSave();
|
||||
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'user-2',
|
||||
loginName: 'other',
|
||||
nickname: '其他用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
vi.mocked(allocateAgentComposerSession).mockResolvedValueOnce({
|
||||
data: { mode: 'FORMAL', sessionId: '200' },
|
||||
errorCode: 0,
|
||||
});
|
||||
const second = useAgentComposerDraft('FORMAL');
|
||||
await second.activate('agent-1');
|
||||
|
||||
expect(second.sessionId.value).toBe('200');
|
||||
expect(second.text.value).toBe('');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('serializes a debounced save and the send-time flush', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveFirstSave: (() => void) | undefined;
|
||||
vi.mocked(saveAgentComposerDraft).mockImplementation((draft) => {
|
||||
if (!resolveFirstSave) {
|
||||
return new Promise((resolve) => {
|
||||
resolveFirstSave = () =>
|
||||
resolve({
|
||||
data: { ...draft, revision: 1 },
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
data: { ...draft, revision: draft.revision + 1 },
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
const composer = useAgentComposerDraft('FORMAL');
|
||||
await composer.activate('agent-1');
|
||||
composer.text.value = '并发保存内容';
|
||||
composer.scheduleSave();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await Promise.resolve();
|
||||
|
||||
const flushPromise = composer.flush();
|
||||
await Promise.resolve();
|
||||
expect(saveAgentComposerDraft).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirstSave?.();
|
||||
await flushPromise;
|
||||
|
||||
expect(saveAgentComposerDraft).toHaveBeenCalledTimes(2);
|
||||
expect(saveAgentComposerDraft).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ revision: 1, text: '并发保存内容' }),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for an in-flight save before clearing an accepted draft', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveSave: (() => void) | undefined;
|
||||
vi.mocked(saveAgentComposerDraft).mockImplementation(
|
||||
(draft) =>
|
||||
new Promise((resolve) => {
|
||||
resolveSave = () =>
|
||||
resolve({
|
||||
data: { ...draft, revision: 1 },
|
||||
errorCode: 0,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const composer = useAgentComposerDraft('DRAFT');
|
||||
await composer.activate('agent-1', 'agent-draft-100');
|
||||
composer.text.value = '试运行内容';
|
||||
composer.scheduleSave();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await Promise.resolve();
|
||||
|
||||
const accepted = composer.markAccepted();
|
||||
await Promise.resolve();
|
||||
expect(deleteAgentComposerDraft).not.toHaveBeenCalled();
|
||||
|
||||
resolveSave?.();
|
||||
await accepted;
|
||||
|
||||
expect(deleteAgentComposerDraft).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
deleteUploads: false,
|
||||
imageUploadIds: [],
|
||||
mode: 'DRAFT',
|
||||
sessionId: 'agent-draft-100',
|
||||
});
|
||||
expect(composer.text.value).toBe('');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import {
|
||||
COMPOSER_SHADOW_PREFIX,
|
||||
resolveAgentChatIdentity,
|
||||
} from '#/utils/agent-chat-cache';
|
||||
|
||||
import {
|
||||
allocateAgentComposerSession,
|
||||
deleteAgentComposerDraft,
|
||||
getAgentComposerDraft,
|
||||
saveAgentComposerDraft,
|
||||
} from './mediaApi';
|
||||
import type { AgentComposerDraftPayload, AgentComposerMode } from './mediaApi';
|
||||
import { useChatImageUploads } from './useChatImageUploads';
|
||||
|
||||
const SHADOW_TTL = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface ShadowDraft extends AgentComposerDraftPayload {
|
||||
pendingSync: boolean;
|
||||
shadowExpiresAt: number;
|
||||
shadowUpdatedAt: number;
|
||||
}
|
||||
|
||||
export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||
const userStore = useUserStore();
|
||||
const agentId = ref('');
|
||||
const sessionId = ref('');
|
||||
const text = ref('');
|
||||
const revision = ref(0);
|
||||
const images = useChatImageUploads();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let activation = 0;
|
||||
let changeSequence = 0;
|
||||
let pendingOperations = 0;
|
||||
let operationChain = Promise.resolve();
|
||||
|
||||
function identityScope() {
|
||||
return resolveAgentChatIdentity(userStore.userInfo);
|
||||
}
|
||||
|
||||
function shadowKey(
|
||||
targetAgentId = agentId.value,
|
||||
targetSessionId = sessionId.value,
|
||||
targetIdentity = identityScope(),
|
||||
) {
|
||||
return `${COMPOSER_SHADOW_PREFIX}:${targetIdentity}:${mode}:${targetAgentId}:${targetSessionId}`;
|
||||
}
|
||||
|
||||
function activeShadowKey(
|
||||
targetAgentId = agentId.value,
|
||||
targetIdentity = identityScope(),
|
||||
) {
|
||||
return `${COMPOSER_SHADOW_PREFIX}:${targetIdentity}:active:${mode}:${targetAgentId}`;
|
||||
}
|
||||
|
||||
function readShadow(targetAgentId: string, preferredSessionId?: string) {
|
||||
try {
|
||||
if (!identityScope()) return undefined;
|
||||
const activeSessionId =
|
||||
preferredSessionId ||
|
||||
localStorage.getItem(activeShadowKey(targetAgentId)) ||
|
||||
'';
|
||||
if (!activeSessionId) return undefined;
|
||||
const raw = localStorage.getItem(
|
||||
shadowKey(targetAgentId, activeSessionId),
|
||||
);
|
||||
if (!raw) return undefined;
|
||||
const draft = JSON.parse(raw) as ShadowDraft;
|
||||
if (draft.shadowExpiresAt <= Date.now()) {
|
||||
localStorage.removeItem(shadowKey(targetAgentId, activeSessionId));
|
||||
return undefined;
|
||||
}
|
||||
return draft;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeShadowPayload(
|
||||
payload: AgentComposerDraftPayload,
|
||||
pendingSync = true,
|
||||
serverExpiresAt?: string,
|
||||
targetIdentity = identityScope(),
|
||||
) {
|
||||
if (!targetIdentity || !payload.agentId || !payload.sessionId) return;
|
||||
const parsedServerExpiry = serverExpiresAt
|
||||
? Date.parse(serverExpiresAt)
|
||||
: Number.NaN;
|
||||
const shadow: ShadowDraft = {
|
||||
...payload,
|
||||
pendingSync,
|
||||
shadowExpiresAt:
|
||||
!pendingSync && Number.isFinite(parsedServerExpiry)
|
||||
? parsedServerExpiry
|
||||
: Date.now() + SHADOW_TTL,
|
||||
shadowUpdatedAt: Date.now(),
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(
|
||||
shadowKey(payload.agentId, payload.sessionId, targetIdentity),
|
||||
JSON.stringify(shadow),
|
||||
);
|
||||
localStorage.setItem(
|
||||
activeShadowKey(payload.agentId, targetIdentity),
|
||||
payload.sessionId,
|
||||
);
|
||||
} catch {
|
||||
// Redis 仍是权威草稿源,本地存储不足不阻断输入。
|
||||
}
|
||||
}
|
||||
|
||||
function writeShadow(pendingSync = true, serverExpiresAt?: string) {
|
||||
writeShadowPayload(shadowPayload(), pendingSync, serverExpiresAt);
|
||||
}
|
||||
|
||||
function clearShadow(
|
||||
targetAgentId = agentId.value,
|
||||
targetSessionId = sessionId.value,
|
||||
targetIdentity = identityScope(),
|
||||
) {
|
||||
try {
|
||||
if (!targetIdentity) return;
|
||||
localStorage.removeItem(
|
||||
shadowKey(targetAgentId, targetSessionId, targetIdentity),
|
||||
);
|
||||
if (
|
||||
localStorage.getItem(activeShadowKey(targetAgentId, targetIdentity)) ===
|
||||
targetSessionId
|
||||
) {
|
||||
localStorage.removeItem(activeShadowKey(targetAgentId, targetIdentity));
|
||||
}
|
||||
} catch {
|
||||
// 忽略不可用的本地存储。
|
||||
}
|
||||
}
|
||||
|
||||
function payloadOf(): AgentComposerDraftPayload {
|
||||
return {
|
||||
agentId: agentId.value,
|
||||
imageUploadIds: [...images.uploadIds.value],
|
||||
mode,
|
||||
revision: revision.value,
|
||||
sessionId: sessionId.value,
|
||||
text: text.value,
|
||||
};
|
||||
}
|
||||
|
||||
function shadowPayload(): AgentComposerDraftPayload {
|
||||
return {
|
||||
...payloadOf(),
|
||||
images: images.readyItems.value.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function applyDraft(draft: AgentComposerDraftPayload) {
|
||||
sessionId.value = String(draft.sessionId);
|
||||
text.value = draft.text || '';
|
||||
revision.value = Number(draft.revision || 0);
|
||||
images.restore(draft.images || []);
|
||||
changeSequence++;
|
||||
}
|
||||
|
||||
function enqueue(operation: () => Promise<void>) {
|
||||
pendingOperations++;
|
||||
const queued = operationChain.catch(() => undefined).then(operation);
|
||||
operationChain = queued
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
pendingOperations--;
|
||||
});
|
||||
return queued;
|
||||
}
|
||||
|
||||
function captureSaveContext() {
|
||||
return {
|
||||
activation,
|
||||
agentId: agentId.value,
|
||||
identity: identityScope(),
|
||||
sessionId: sessionId.value,
|
||||
};
|
||||
}
|
||||
|
||||
function isCurrentContext(context: ReturnType<typeof captureSaveContext>) {
|
||||
return (
|
||||
context.activation === activation &&
|
||||
context.agentId === agentId.value &&
|
||||
context.identity === identityScope() &&
|
||||
(!context.sessionId || context.sessionId === sessionId.value)
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (sessionId.value) return sessionId.value;
|
||||
const response = await allocateAgentComposerSession(mode);
|
||||
if (response.errorCode !== 0 || !response.data?.sessionId) {
|
||||
throw new Error(response.message || '会话创建失败');
|
||||
}
|
||||
sessionId.value = String(response.data.sessionId);
|
||||
return sessionId.value;
|
||||
}
|
||||
|
||||
async function activate(targetAgentId: string, preferredSessionId?: string) {
|
||||
const switchingScope =
|
||||
Boolean(agentId.value && sessionId.value) &&
|
||||
(targetAgentId !== agentId.value ||
|
||||
Boolean(preferredSessionId && preferredSessionId !== sessionId.value));
|
||||
if (
|
||||
switchingScope &&
|
||||
(Boolean(saveTimer) ||
|
||||
pendingOperations > 0 ||
|
||||
Boolean(text.value.trim()) ||
|
||||
images.uploadIds.value.length > 0)
|
||||
) {
|
||||
await flush();
|
||||
}
|
||||
const currentActivation = ++activation;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = undefined;
|
||||
}
|
||||
agentId.value = targetAgentId;
|
||||
sessionId.value = preferredSessionId || '';
|
||||
text.value = '';
|
||||
revision.value = 0;
|
||||
images.clear();
|
||||
if (!targetAgentId) return;
|
||||
const shadow = readShadow(targetAgentId, preferredSessionId);
|
||||
try {
|
||||
const response = await getAgentComposerDraft({
|
||||
agentId: targetAgentId,
|
||||
mode,
|
||||
...(preferredSessionId ? { sessionId: preferredSessionId } : {}),
|
||||
});
|
||||
if (currentActivation !== activation) return;
|
||||
if (response.errorCode !== 0) {
|
||||
throw new Error(response.message || '聊天草稿读取失败');
|
||||
}
|
||||
if (shadow?.pendingSync) {
|
||||
shadow.revision = Number(response.data?.revision || 0);
|
||||
applyDraft(shadow);
|
||||
await save();
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
applyDraft(response.data);
|
||||
writeShadow(false, response.data.expiresAt);
|
||||
return;
|
||||
}
|
||||
if (shadow) {
|
||||
applyDraft(shadow);
|
||||
await save();
|
||||
return;
|
||||
}
|
||||
await ensureSession();
|
||||
} catch (error) {
|
||||
if (currentActivation !== activation) return;
|
||||
if (shadow) {
|
||||
applyDraft(shadow);
|
||||
return;
|
||||
}
|
||||
await ensureSession();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function performSave(context: ReturnType<typeof captureSaveContext>) {
|
||||
if (!context.agentId || !context.identity || !isCurrentContext(context)) {
|
||||
return;
|
||||
}
|
||||
await ensureSession();
|
||||
if (!isCurrentContext(context)) {
|
||||
return;
|
||||
}
|
||||
const request = shadowPayload();
|
||||
const savedSequence = changeSequence;
|
||||
if (!request.text.trim() && request.imageUploadIds.length === 0) {
|
||||
clearShadow(request.agentId, request.sessionId, context.identity);
|
||||
const response = await deleteAgentComposerDraft({
|
||||
agentId: request.agentId,
|
||||
deleteUploads: true,
|
||||
mode,
|
||||
sessionId: request.sessionId,
|
||||
});
|
||||
if (response.errorCode !== 0) {
|
||||
throw new Error(response.message || '聊天草稿删除失败');
|
||||
}
|
||||
if (isCurrentContext(context)) {
|
||||
revision.value = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeShadowPayload(request, true, undefined, context.identity);
|
||||
const response = await saveAgentComposerDraft(request);
|
||||
if (response.errorCode !== 0 || !response.data) {
|
||||
throw new Error(response.message || '聊天草稿保存失败');
|
||||
}
|
||||
if (!isCurrentContext(context)) {
|
||||
return;
|
||||
}
|
||||
revision.value = Number(response.data.revision || revision.value);
|
||||
writeShadow(changeSequence !== savedSequence, response.data.expiresAt);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const context = captureSaveContext();
|
||||
return enqueue(() => performSave(context));
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
changeSequence++;
|
||||
writeShadow();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = undefined;
|
||||
void save().catch(() => undefined);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = undefined;
|
||||
}
|
||||
await save();
|
||||
}
|
||||
|
||||
async function clear(deleteRemote = true, deleteUploads = true) {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = undefined;
|
||||
}
|
||||
const scope = {
|
||||
identity: identityScope(),
|
||||
agentId: agentId.value,
|
||||
imageUploadIds: [...images.uploadIds.value],
|
||||
mode,
|
||||
sessionId: sessionId.value,
|
||||
};
|
||||
activation++;
|
||||
changeSequence++;
|
||||
clearShadow(scope.agentId, scope.sessionId, scope.identity);
|
||||
text.value = '';
|
||||
revision.value = 0;
|
||||
images.clear();
|
||||
if (deleteRemote && scope.agentId && scope.sessionId) {
|
||||
await enqueue(async () => {
|
||||
const response = await deleteAgentComposerDraft({
|
||||
agentId: scope.agentId,
|
||||
deleteUploads,
|
||||
imageUploadIds: scope.imageUploadIds,
|
||||
mode: scope.mode,
|
||||
sessionId: scope.sessionId,
|
||||
});
|
||||
if (response.errorCode !== 0) {
|
||||
throw new Error(response.message || '聊天草稿删除失败');
|
||||
}
|
||||
clearShadow(scope.agentId, scope.sessionId, scope.identity);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function markAccepted() {
|
||||
await clear(true, mode === 'FORMAL');
|
||||
}
|
||||
|
||||
async function startNew(targetAgentId: string) {
|
||||
if (
|
||||
agentId.value &&
|
||||
sessionId.value &&
|
||||
(saveTimer ||
|
||||
pendingOperations > 0 ||
|
||||
text.value.trim() ||
|
||||
images.uploadIds.value.length > 0)
|
||||
) {
|
||||
await flush();
|
||||
text.value = '';
|
||||
images.clear();
|
||||
}
|
||||
const response = await allocateAgentComposerSession(mode);
|
||||
if (response.errorCode !== 0 || !response.data?.sessionId) {
|
||||
throw new Error(response.message || '会话创建失败');
|
||||
}
|
||||
await activate(targetAgentId, String(response.data.sessionId));
|
||||
}
|
||||
|
||||
return {
|
||||
activate,
|
||||
agentId,
|
||||
clear,
|
||||
ensureSession,
|
||||
flush,
|
||||
images,
|
||||
markAccepted,
|
||||
revision,
|
||||
scheduleSave,
|
||||
sessionId,
|
||||
startNew,
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { nextTick, watchEffect } from 'vue';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useChatImageUploads } from './useChatImageUploads';
|
||||
|
||||
const mediaApi = vi.hoisted(() => ({
|
||||
deleteAgentChatImage: vi.fn(),
|
||||
uploadAgentChatImage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./mediaApi', () => mediaApi);
|
||||
|
||||
describe('useChatImageUploads', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mediaApi.deleteAgentChatImage.mockReset();
|
||||
mediaApi.uploadAgentChatImage.mockReset();
|
||||
});
|
||||
|
||||
it('reactively exposes the ready state after upload', async () => {
|
||||
let resolveUpload: (value: any) => void = () => undefined;
|
||||
mediaApi.uploadAgentChatImage.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
|
||||
const uploads = useChatImageUploads();
|
||||
const observedStatuses: (string | undefined)[] = [];
|
||||
const stop = watchEffect(() => {
|
||||
observedStatuses.push(uploads.items.value[0]?.status);
|
||||
});
|
||||
const pending = uploads.addFiles(
|
||||
[new File(['image'], 'test.png', { type: 'image/png' })],
|
||||
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
resolveUpload({
|
||||
data: {
|
||||
height: 1,
|
||||
mimeType: 'image/png',
|
||||
name: 'test.png',
|
||||
previewUrl: '/api/v1/agent/media/content?reference=draft%3Atest',
|
||||
size: 5,
|
||||
uploadId: 'upload-1',
|
||||
width: 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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { deleteAgentChatImage, uploadAgentChatImage } from './mediaApi';
|
||||
import type { AgentComposerMode, AgentMediaUpload } from './mediaApi';
|
||||
|
||||
const MAX_IMAGES = 5;
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const ACCEPTED_EXTENSIONS = new Set([
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'webp',
|
||||
]);
|
||||
|
||||
interface UploadContext {
|
||||
agentId: string;
|
||||
mode: AgentComposerMode;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface LocalAttachment extends ChatImageAttachment {
|
||||
file?: File;
|
||||
}
|
||||
|
||||
function createLocalId() {
|
||||
return `agent-image-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function extensionOf(file: File) {
|
||||
const extension = file.name.includes('.')
|
||||
? file.name.split('.').pop()?.toLowerCase() || ''
|
||||
: '';
|
||||
if (extension) return extension;
|
||||
return (
|
||||
{
|
||||
'image/bmp': 'bmp',
|
||||
'image/gif': 'gif',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
}[file.type.toLowerCase()] || ''
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const candidate = error as any;
|
||||
return (
|
||||
candidate?.response?.data?.message || candidate?.message || '图片上传失败'
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatImageUploads() {
|
||||
const items = ref<LocalAttachment[]>([]);
|
||||
const readyItems = computed(() =>
|
||||
items.value.filter(
|
||||
(item): item is LocalAttachment & AgentMediaUpload =>
|
||||
item.status === 'ready' && Boolean(item.uploadId),
|
||||
),
|
||||
);
|
||||
const uploadIds = computed(() =>
|
||||
readyItems.value.map((item) => item.uploadId),
|
||||
);
|
||||
const uploading = computed(() =>
|
||||
items.value.some((item) => item.status === 'uploading'),
|
||||
);
|
||||
|
||||
async function addFiles(files: File[], context: UploadContext) {
|
||||
const available = Math.max(0, MAX_IMAGES - items.value.length);
|
||||
const accepted = files.slice(0, available);
|
||||
const rejectedCount = Math.max(0, files.length - accepted.length);
|
||||
const uploads: Promise<void>[] = [];
|
||||
for (const file of accepted) {
|
||||
const extension = extensionOf(file);
|
||||
const local: LocalAttachment = {
|
||||
file,
|
||||
localId: createLocalId(),
|
||||
mimeType: file.type,
|
||||
name: file.name || '粘贴的图片',
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
size: file.size,
|
||||
status: 'uploading',
|
||||
};
|
||||
items.value.push(local);
|
||||
if (!ACCEPTED_EXTENSIONS.has(extension)) {
|
||||
local.status = 'error';
|
||||
local.error = '仅支持 PNG、JPG、JPEG、WebP、GIF、BMP';
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
local.status = 'error';
|
||||
local.error = '单张图片不能超过 10 MiB';
|
||||
continue;
|
||||
}
|
||||
uploads.push(upload(local, context));
|
||||
}
|
||||
await Promise.all(uploads);
|
||||
return rejectedCount;
|
||||
}
|
||||
|
||||
async function upload(item: LocalAttachment, context: UploadContext) {
|
||||
if (!item.file) return;
|
||||
const current = items.value.find(
|
||||
(candidate) => candidate.localId === item.localId,
|
||||
);
|
||||
if (!current) return;
|
||||
current.status = 'uploading';
|
||||
current.error = undefined;
|
||||
try {
|
||||
const response = await uploadAgentChatImage(current.file!, context);
|
||||
if (response.errorCode !== 0 || !response.data) {
|
||||
throw new Error(response.message || '图片上传失败');
|
||||
}
|
||||
const latest = items.value.find(
|
||||
(candidate) => candidate.localId === item.localId,
|
||||
);
|
||||
if (!latest) {
|
||||
await deleteAgentChatImage(response.data.uploadId);
|
||||
return;
|
||||
}
|
||||
const localPreview = latest.previewUrl;
|
||||
Object.assign(latest, response.data, {
|
||||
file: latest.file,
|
||||
localId: latest.localId,
|
||||
status: 'ready',
|
||||
});
|
||||
if (localPreview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localPreview);
|
||||
}
|
||||
} catch (error) {
|
||||
const latest = items.value.find(
|
||||
(candidate) => candidate.localId === item.localId,
|
||||
);
|
||||
if (latest) {
|
||||
latest.status = 'error';
|
||||
latest.error = errorMessage(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: ChatImageAttachment) {
|
||||
const index = items.value.findIndex(
|
||||
(candidate) =>
|
||||
candidate.localId === item.localId ||
|
||||
(candidate.uploadId && candidate.uploadId === item.uploadId),
|
||||
);
|
||||
if (index < 0) return;
|
||||
const selected = items.value[index];
|
||||
if (selected?.uploadId) {
|
||||
const response = await deleteAgentChatImage(selected.uploadId);
|
||||
if (response.errorCode !== 0) {
|
||||
throw new Error(response.message || '图片删除失败');
|
||||
}
|
||||
}
|
||||
const [removed] = items.value.splice(index, 1);
|
||||
if (removed?.previewUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function retry(item: ChatImageAttachment, context: UploadContext) {
|
||||
const found = items.value.find(
|
||||
(candidate) => candidate.localId === item.localId,
|
||||
);
|
||||
if (found?.file) {
|
||||
await upload(found, context);
|
||||
}
|
||||
}
|
||||
|
||||
function restore(restored: AgentMediaUpload[] = []) {
|
||||
clear();
|
||||
items.value = restored.slice(0, MAX_IMAGES).map((item) => ({
|
||||
...item,
|
||||
status: 'ready',
|
||||
}));
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const item of items.value) {
|
||||
if (item.previewUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
}
|
||||
}
|
||||
items.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
addFiles,
|
||||
clear,
|
||||
items,
|
||||
readyItems,
|
||||
remove,
|
||||
restore,
|
||||
retry,
|
||||
uploadIds,
|
||||
uploading,
|
||||
};
|
||||
}
|
||||
@@ -65,11 +65,11 @@
|
||||
"modelAbility": {
|
||||
"supportThinking": "Thinking",
|
||||
"supportTool": "Tool",
|
||||
"SupportAudio": "Audio",
|
||||
"SupportVideo": "Video",
|
||||
"SupportImage": "Image",
|
||||
"supportAudio": "Audio",
|
||||
"supportVideo": "Video",
|
||||
"supportImage": "Multimodal",
|
||||
"supportFree": "Free",
|
||||
"supportImageB64Only": "ImageB64Only",
|
||||
"supportImageB64Only": "Base64 images only",
|
||||
"supportToolMessage": "SupportToolMessage"
|
||||
},
|
||||
"requestPath": "RequestPath",
|
||||
|
||||
@@ -64,9 +64,9 @@
|
||||
"supportTool": "工具",
|
||||
"supportAudio": "音频",
|
||||
"supportVideo": "视频",
|
||||
"supportImage": "图片",
|
||||
"supportImage": "多模态",
|
||||
"supportFree": "免费",
|
||||
"supportImageB64Only": "仅支持Base64图片",
|
||||
"supportImageB64Only": "仅接受 Base64 图片",
|
||||
"supportToolMessage": "支持Tool消息"
|
||||
},
|
||||
"requestPath": "请求路径",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
buildForcePasswordRoute,
|
||||
shouldForcePasswordChange,
|
||||
} from '#/utils/password-reset';
|
||||
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessStore = useAccessStore();
|
||||
@@ -133,6 +134,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
} catch {
|
||||
// 不做任何处理
|
||||
}
|
||||
clearAgentChatBrowserCache(userStore.userInfo);
|
||||
resetAllStores();
|
||||
accessStore.setLoginExpired(false);
|
||||
|
||||
|
||||
83
easyflow-ui-admin/app/src/utils/agent-chat-cache.ts
Normal file
83
easyflow-ui-admin/app/src/utils/agent-chat-cache.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
const COMPOSER_SHADOW_PREFIX = 'easyflow:agent-composer-shadow';
|
||||
const RUNTIME_STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
|
||||
|
||||
type AccountIdentity =
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
id?: number | string;
|
||||
tenantId?: number | string;
|
||||
};
|
||||
|
||||
const clearListeners = new Set<(identity: string) => void>();
|
||||
|
||||
/**
|
||||
* 生成聊天本地缓存使用的账号隔离标识。
|
||||
*/
|
||||
export function resolveAgentChatIdentity(account: AccountIdentity) {
|
||||
if (!account?.id) {
|
||||
return '';
|
||||
}
|
||||
return `${String(account.tenantId || 'default')}:${String(account.id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册账号聊天缓存清理监听器。
|
||||
*/
|
||||
export function onAgentChatCacheClear(listener: (identity: string) => void) {
|
||||
clearListeners.add(listener);
|
||||
return () => clearListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定账号的草稿影子和运行快照。
|
||||
*/
|
||||
export function clearAgentChatBrowserCache(account: AccountIdentity) {
|
||||
const identity = resolveAgentChatIdentity(account);
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
removeStorageEntries(safeStorage('localStorage'), [
|
||||
`${COMPOSER_SHADOW_PREFIX}:${identity}:`,
|
||||
]);
|
||||
removeStorageEntries(safeStorage('sessionStorage'), [
|
||||
`${RUNTIME_STORAGE_PREFIX}:${identity}:`,
|
||||
]);
|
||||
for (const listener of clearListeners) {
|
||||
listener(identity);
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿影子存储前缀。 */
|
||||
export { COMPOSER_SHADOW_PREFIX, RUNTIME_STORAGE_PREFIX };
|
||||
|
||||
function removeStorageEntries(
|
||||
storage: Storage | undefined,
|
||||
prefixes: string[],
|
||||
) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index++) {
|
||||
const key = storage.key(index);
|
||||
if (key && prefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// 浏览器禁用存储时,登录退出流程仍应继续。
|
||||
}
|
||||
}
|
||||
|
||||
function safeStorage(type: 'localStorage' | 'sessionStorage') {
|
||||
try {
|
||||
return globalThis[type];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
ChatTimelineToolStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import type {AgentChatMessageRecord} from '../api';
|
||||
import type { AgentChatMessageRecord } from '../api';
|
||||
|
||||
export interface AgentSseEnvelope {
|
||||
domain: string;
|
||||
@@ -73,8 +74,7 @@ function isBlankToolName(value: unknown) {
|
||||
function shouldSkipToolProjection(value: unknown) {
|
||||
const normalizedName = normalizeToolName(value).toLowerCase();
|
||||
return (
|
||||
normalizedName === 'context_reload' ||
|
||||
normalizedName === '__fragment__'
|
||||
normalizedName === 'context_reload' || normalizedName === '__fragment__'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,6 +160,26 @@ function normalizeKnowledgeItems(payload: Record<string, any>) {
|
||||
.filter((item) => item.chunkContent || item.title || item.documentName);
|
||||
}
|
||||
|
||||
function normalizeImages(payload: Record<string, any>) {
|
||||
return asArray(payload.images)
|
||||
.map((value): ChatImageAttachment | undefined => {
|
||||
const image = asRecord(value);
|
||||
const previewUrl = asText(image.previewUrl);
|
||||
if (!previewUrl) return undefined;
|
||||
return {
|
||||
height: Number(image.height || 0) || undefined,
|
||||
imageRef: asText(image.imageRef),
|
||||
mimeType: asText(image.mimeType),
|
||||
name: asText(image.name) || '图片',
|
||||
previewUrl,
|
||||
size: Number(image.size || 0) || undefined,
|
||||
status: 'ready',
|
||||
width: Number(image.width || 0) || undefined,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ChatImageAttachment => Boolean(item));
|
||||
}
|
||||
|
||||
function buildApprovalPayload(payload: Record<string, any>) {
|
||||
return {
|
||||
expiresAt: asText(payload.expiresAt),
|
||||
@@ -318,7 +338,10 @@ function appendHistoryRecord(
|
||||
const role = normalizeRole(record.senderRole);
|
||||
const metadata = normalizeMetadata(record);
|
||||
if (role === 'user') {
|
||||
ChatTimelineBuilder.appendUserMessage(items, record.contentText, metadata);
|
||||
ChatTimelineBuilder.appendUserMessage(items, record.contentText, {
|
||||
...metadata,
|
||||
images: normalizeImages(asRecord(record.contentPayload)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (role === 'system') {
|
||||
@@ -446,8 +469,12 @@ export function applyAgentSseEnvelope(
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output: asyncTool
|
||||
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
|
||||
: payload.output ?? payload.result ?? payload.text,
|
||||
? (payload.summary ??
|
||||
payload.label ??
|
||||
payload.output ??
|
||||
payload.result ??
|
||||
payload.text)
|
||||
: (payload.output ?? payload.result ?? payload.text),
|
||||
status: asyncTool
|
||||
? asyncToolTimelineStatus(payload)
|
||||
: type === 'TOOL_RESULT'
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { 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';
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
generateAgentSessionId: vi.fn(),
|
||||
sendAgentChat: vi.fn(),
|
||||
stopAgentChatStream: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('agentChatRuntimeManager', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('replaces draft image URLs and isolates snapshots by account', async () => {
|
||||
let callbacks: any;
|
||||
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||
callbacks = options;
|
||||
return Promise.resolve() as any;
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
const firstAccount = {
|
||||
avatar: '',
|
||||
id: 'user-1',
|
||||
loginName: 'admin',
|
||||
nickname: '管理员',
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
userStore.setUserInfo(firstAccount);
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
images: [
|
||||
{
|
||||
name: 'draft.png',
|
||||
previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1',
|
||||
status: 'ready',
|
||||
uploadId: 'upload-1',
|
||||
},
|
||||
],
|
||||
prompt: '识别图片',
|
||||
sessionId: '101',
|
||||
});
|
||||
callbacks.onMessage({
|
||||
data: JSON.stringify({
|
||||
domain: 'SYSTEM',
|
||||
payload: {
|
||||
images: [
|
||||
{
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
name: 'draft.png',
|
||||
previewUrl:
|
||||
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'INPUT_ACCEPTED',
|
||||
}),
|
||||
});
|
||||
|
||||
const accepted = agentChatRuntimeManager.getSnapshot('101');
|
||||
const userMessage = accepted?.items.find(
|
||||
(item) => item.type === 'message' && item.role === 'user',
|
||||
);
|
||||
expect(
|
||||
userMessage?.type === 'message' ? userMessage.images?.[0] : null,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
previewUrl:
|
||||
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||
}),
|
||||
);
|
||||
|
||||
userStore.setUserInfo({
|
||||
...firstAccount,
|
||||
id: 'user-2',
|
||||
loginName: 'other',
|
||||
nickname: '其他用户',
|
||||
});
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
|
||||
clearAgentChatBrowserCache(firstAccount);
|
||||
userStore.setUserInfo(firstAccount);
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,35 @@
|
||||
import type {ChatTimelineItem} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
import type {
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
} from '@easyflow/common-ui';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import type {AgentChatCapabilityPayload} from './api';
|
||||
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
|
||||
import {
|
||||
onAgentChatCacheClear,
|
||||
resolveAgentChatIdentity,
|
||||
RUNTIME_STORAGE_PREFIX,
|
||||
} from '#/utils/agent-chat-cache';
|
||||
|
||||
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
|
||||
import type { AgentChatCapabilityPayload } from './api';
|
||||
import {
|
||||
generateAgentSessionId,
|
||||
sendAgentChat,
|
||||
stopAgentChatStream,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
applyAgentSseEnvelope,
|
||||
parseAgentSseMessage,
|
||||
} from './adapters/agentTimelineAdapter';
|
||||
|
||||
interface RuntimeSessionState {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
identity: string;
|
||||
items: ChatTimelineItem[];
|
||||
prompt: string;
|
||||
roundId: string;
|
||||
@@ -37,17 +56,18 @@ interface StartOptions {
|
||||
agentName?: string;
|
||||
baseItems?: ChatTimelineItem[];
|
||||
capabilities?: AgentChatCapabilityPayload[];
|
||||
imageUploadIds?: string[];
|
||||
images?: ChatImageAttachment[];
|
||||
onInputAccepted?: () => void | Promise<void>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
|
||||
const LATEST_STORAGE_KEY = `${STORAGE_PREFIX}:latest`;
|
||||
const STORAGE_VERSION = 1;
|
||||
const STORAGE_VERSION = 2;
|
||||
|
||||
const sessions = new Map<string, RuntimeSessionState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let latestSessionId = '';
|
||||
const latestSessionIds = new Map<string, string>();
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
const serialized = JSON.stringify(value);
|
||||
@@ -58,8 +78,20 @@ function createRoundId() {
|
||||
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function storageKey(sessionId: string) {
|
||||
return `${STORAGE_PREFIX}:${sessionId}`;
|
||||
function identityScope() {
|
||||
return resolveAgentChatIdentity(useUserStore().userInfo);
|
||||
}
|
||||
|
||||
function sessionKey(identity: string, sessionId: string) {
|
||||
return `${identity}:${sessionId}`;
|
||||
}
|
||||
|
||||
function storageKey(identity: string, sessionId: string) {
|
||||
return `${RUNTIME_STORAGE_PREFIX}:${identity}:${sessionId}`;
|
||||
}
|
||||
|
||||
function latestStorageKey(identity: string) {
|
||||
return `${RUNTIME_STORAGE_PREFIX}:${identity}:latest`;
|
||||
}
|
||||
|
||||
function safeSessionStorage() {
|
||||
@@ -94,15 +126,22 @@ function persistSession(state: RuntimeSessionState) {
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
try {
|
||||
storage.setItem(storageKey(state.sessionId), JSON.stringify(snapshot));
|
||||
storage.setItem(LATEST_STORAGE_KEY, state.sessionId);
|
||||
storage.setItem(
|
||||
storageKey(state.identity, state.sessionId),
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
storage.setItem(latestStorageKey(state.identity), state.sessionId);
|
||||
} catch {
|
||||
// 缓存失败不影响正式聊天主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSession(sessionId: string) {
|
||||
const existing = sessions.get(sessionId);
|
||||
function restoreSession(identity: string, sessionId: string) {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const scopedSessionKey = sessionKey(identity, sessionId);
|
||||
const existing = sessions.get(scopedSessionKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
@@ -111,7 +150,7 @@ function restoreSession(sessionId: string) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(storageKey(sessionId));
|
||||
const raw = storage.getItem(storageKey(identity, sessionId));
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -124,6 +163,7 @@ function restoreSession(sessionId: string) {
|
||||
agentName: parsed.agentName,
|
||||
completed: parsed.completed,
|
||||
error: parsed.error,
|
||||
identity,
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
prompt: parsed.prompt,
|
||||
roundId: parsed.roundId,
|
||||
@@ -131,7 +171,7 @@ function restoreSession(sessionId: string) {
|
||||
sessionId,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
sessions.set(sessionId, restored);
|
||||
sessions.set(scopedSessionKey, restored);
|
||||
return restored;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -140,25 +180,30 @@ function restoreSession(sessionId: string) {
|
||||
|
||||
function upsertState(state: RuntimeSessionState) {
|
||||
state.updatedAt = Date.now();
|
||||
latestSessionId = state.sessionId;
|
||||
sessions.set(state.sessionId, state);
|
||||
latestSessionIds.set(state.identity, state.sessionId);
|
||||
sessions.set(sessionKey(state.identity, state.sessionId), state);
|
||||
persistSession(state);
|
||||
notify();
|
||||
}
|
||||
|
||||
function runningSession() {
|
||||
return [...sessions.values()].find((session) => session.sending);
|
||||
function runningSession(identity = identityScope()) {
|
||||
return [...sessions.values()].find(
|
||||
(session) => session.identity === identity && session.sending,
|
||||
);
|
||||
}
|
||||
|
||||
function restoreLatestSession() {
|
||||
const running = runningSession();
|
||||
function restoreLatestSession(identity = identityScope()) {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const running = runningSession(identity);
|
||||
if (running) {
|
||||
return running;
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
const storedSessionId = storage?.getItem(LATEST_STORAGE_KEY) || '';
|
||||
const sessionId = latestSessionId || storedSessionId;
|
||||
return sessionId ? restoreSession(sessionId) : undefined;
|
||||
const storedSessionId = storage?.getItem(latestStorageKey(identity)) || '';
|
||||
const sessionId = latestSessionIds.get(identity) || storedSessionId;
|
||||
return sessionId ? restoreSession(identity, sessionId) : undefined;
|
||||
}
|
||||
|
||||
async function resolveSessionId(sessionId?: string) {
|
||||
@@ -176,12 +221,70 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '发送失败,请稍后再试';
|
||||
}
|
||||
|
||||
function normalizeAcceptedImages(payload: Record<string, any>) {
|
||||
if (!Array.isArray(payload.images)) {
|
||||
return [];
|
||||
}
|
||||
return payload.images
|
||||
.map((value): ChatImageAttachment | undefined => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const image = value as Record<string, any>;
|
||||
const previewUrl = String(image.previewUrl || '');
|
||||
if (!previewUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
height: Number(image.height || 0) || undefined,
|
||||
imageRef: String(image.imageRef || ''),
|
||||
mimeType: String(image.mimeType || ''),
|
||||
name: String(image.name || '图片'),
|
||||
previewUrl,
|
||||
size: Number(image.size || 0) || undefined,
|
||||
status: 'ready',
|
||||
width: Number(image.width || 0) || undefined,
|
||||
};
|
||||
})
|
||||
.filter((image): image is ChatImageAttachment => Boolean(image));
|
||||
}
|
||||
|
||||
function replaceAcceptedImages(
|
||||
items: ChatTimelineItem[],
|
||||
roundId: string,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
const acceptedImages = normalizeAcceptedImages(payload);
|
||||
if (acceptedImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
const userMessage = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'user' &&
|
||||
item.roundId === roundId,
|
||||
);
|
||||
if (userMessage) {
|
||||
userMessage.images = acceptedImages;
|
||||
}
|
||||
}
|
||||
|
||||
onAgentChatCacheClear((identity) => {
|
||||
for (const [key, session] of sessions) {
|
||||
if (session.identity === identity) {
|
||||
sessions.delete(key);
|
||||
}
|
||||
}
|
||||
latestSessionIds.delete(identity);
|
||||
notify();
|
||||
});
|
||||
|
||||
export const agentChatRuntimeManager = {
|
||||
getSnapshot(sessionId?: string) {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const state = restoreSession(sessionId);
|
||||
const state = restoreSession(identityScope(), sessionId);
|
||||
return state ? clone(state) : undefined;
|
||||
},
|
||||
|
||||
@@ -195,7 +298,7 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
|
||||
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
|
||||
const state = restoreSession(sessionId);
|
||||
const state = restoreSession(identityScope(), sessionId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +307,11 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
|
||||
async start(options: StartOptions) {
|
||||
const active = runningSession();
|
||||
const identity = identityScope();
|
||||
if (!identity) {
|
||||
throw new Error('当前登录状态失效');
|
||||
}
|
||||
const active = runningSession(identity);
|
||||
if (active) {
|
||||
throw new Error('当前回复完成后再发送新消息');
|
||||
}
|
||||
@@ -214,6 +321,7 @@ export const agentChatRuntimeManager = {
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
completed: false,
|
||||
identity,
|
||||
items: clone(options.baseItems || []),
|
||||
prompt: options.prompt,
|
||||
roundId,
|
||||
@@ -222,6 +330,7 @@ export const agentChatRuntimeManager = {
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
||||
images: options.images,
|
||||
roundId,
|
||||
});
|
||||
upsertState(state);
|
||||
@@ -230,12 +339,13 @@ export const agentChatRuntimeManager = {
|
||||
{
|
||||
agentId: options.agentId,
|
||||
capabilities: options.capabilities,
|
||||
imageUploadIds: options.imageUploadIds,
|
||||
prompt: options.prompt,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
const current = sessions.get(sessionId);
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
@@ -247,7 +357,7 @@ export const agentChatRuntimeManager = {
|
||||
upsertState(current);
|
||||
},
|
||||
onFinished() {
|
||||
const current = sessions.get(sessionId);
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
@@ -257,7 +367,7 @@ export const agentChatRuntimeManager = {
|
||||
upsertState(current);
|
||||
},
|
||||
onMessage(message) {
|
||||
const current = sessions.get(sessionId);
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
@@ -265,6 +375,13 @@ export const agentChatRuntimeManager = {
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
envelope.domain === 'SYSTEM' &&
|
||||
envelope.type === 'INPUT_ACCEPTED'
|
||||
) {
|
||||
replaceAcceptedImages(current.items, roundId, envelope.payload);
|
||||
void options.onInputAccepted?.();
|
||||
}
|
||||
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
||||
upsertState(current);
|
||||
},
|
||||
@@ -275,7 +392,10 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
|
||||
stop(sessionId?: string) {
|
||||
const state = sessionId ? restoreSession(sessionId) : runningSession();
|
||||
const identity = identityScope();
|
||||
const state = sessionId
|
||||
? restoreSession(identity, sessionId)
|
||||
: runningSession(identity);
|
||||
if (!state || !state.sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {AgentInfo} from '../agents/types';
|
||||
import type { AgentInfo } from '../agents/types';
|
||||
|
||||
import {api, SseClient} from '#/api/request';
|
||||
import { api, SseClient } from '#/api/request';
|
||||
|
||||
const agentChatSseClient = new SseClient();
|
||||
|
||||
@@ -170,6 +170,7 @@ export function sendAgentChat(
|
||||
data: {
|
||||
agentId: number | string;
|
||||
capabilities?: AgentChatCapabilityPayload[];
|
||||
imageUploadIds?: string[];
|
||||
prompt: string;
|
||||
sessionId?: number | string;
|
||||
},
|
||||
|
||||
@@ -16,12 +16,17 @@ import type {
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ChatTimeline, ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
import {
|
||||
ChatImageAttachments,
|
||||
ChatTimeline,
|
||||
ChatTimelineBuilder,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
Delete,
|
||||
EditPen,
|
||||
MoreFilled,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Promotion,
|
||||
} from '@element-plus/icons-vue';
|
||||
@@ -41,6 +46,8 @@ import {
|
||||
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';
|
||||
@@ -58,6 +65,10 @@ import {
|
||||
renameAgentSession,
|
||||
saveAgentSessionExtraKnowledges,
|
||||
} from './api';
|
||||
import {
|
||||
isMissingAgentSessionError,
|
||||
resolveAgentSessionErrorMessage,
|
||||
} from './sessionRecovery';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -67,8 +78,11 @@ const sessions = ref<AgentChatSessionView[]>([]);
|
||||
const timelineItems = ref<ChatTimelineItem[]>([]);
|
||||
const selectedAgentId = ref('');
|
||||
const currentSessionId = ref('');
|
||||
const promptText = ref('');
|
||||
const composer = useAgentComposerDraft('FORMAL');
|
||||
const promptText = composer.text;
|
||||
const promptInputRef = ref();
|
||||
const imageFileInputRef = ref<HTMLInputElement>();
|
||||
const composerDragActive = ref(false);
|
||||
const loadingAgents = ref(false);
|
||||
const agentLoadError = ref('');
|
||||
const loadingSessions = ref(false);
|
||||
@@ -88,6 +102,11 @@ let runtimeUnsubscribe: (() => void) | undefined;
|
||||
const selectedAgent = computed(() =>
|
||||
agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
|
||||
);
|
||||
const selectedAgentImageSupport = computed(() =>
|
||||
Boolean(
|
||||
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
|
||||
),
|
||||
);
|
||||
const interactionDisplay = computed(() =>
|
||||
resolveInteractionDisplay(selectedAgent.value),
|
||||
);
|
||||
@@ -99,7 +118,12 @@ const currentSession = computed(() =>
|
||||
const canStopRuntime = computed(() => sending.value || runtimeRunning.value);
|
||||
const canSend = computed(
|
||||
() =>
|
||||
Boolean(promptText.value.trim()) &&
|
||||
(Boolean(promptText.value.trim()) ||
|
||||
composer.images.readyItems.value.length > 0) &&
|
||||
!composer.images.uploading.value &&
|
||||
!composer.images.items.value.some((item) => item.status === 'error') &&
|
||||
(selectedAgentImageSupport.value !== false ||
|
||||
composer.images.readyItems.value.length === 0) &&
|
||||
Boolean(selectedAgentId.value) &&
|
||||
!sending.value &&
|
||||
!runtimeRunning.value,
|
||||
@@ -217,6 +241,25 @@ async function syncSessionRoute(sessionId?: string) {
|
||||
await router.replace({ query: nextQuery });
|
||||
}
|
||||
|
||||
async function removeSessionFromPage(sessionId: string) {
|
||||
sessions.value = sessions.value.filter(
|
||||
(item) => String(item.sessionId) !== sessionId,
|
||||
);
|
||||
const isCurrentSession =
|
||||
currentSessionId.value === sessionId ||
|
||||
String(route.query.sessionId || '') === sessionId;
|
||||
if (isCurrentSession) {
|
||||
try {
|
||||
await composer.clear();
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '会话草稿清理失败',
|
||||
);
|
||||
}
|
||||
await createNewSession();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
loadingAgents.value = true;
|
||||
agentLoadError.value = '';
|
||||
@@ -381,10 +424,10 @@ function buildOptimisticSession(
|
||||
assistantName: selectedAgent.value?.name,
|
||||
continuable: true,
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
lastMessagePreview: prompt,
|
||||
lastMessagePreview: prompt || '发送了图片',
|
||||
messageCount: 1,
|
||||
sessionId,
|
||||
title: prompt.slice(0, 48) || '新对话',
|
||||
title: prompt.slice(0, 48) || '图片对话',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,10 +516,17 @@ async function loadConversation(sessionId: string) {
|
||||
}
|
||||
}
|
||||
currentSessionId.value = sessionId;
|
||||
if (selectedAgentId.value) {
|
||||
await activateComposer(selectedAgentId.value, sessionId);
|
||||
}
|
||||
sending.value = false;
|
||||
await syncSessionRoute(sessionId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '会话加载失败');
|
||||
if (isMissingAgentSessionError(error)) {
|
||||
await removeSessionFromPage(sessionId);
|
||||
return;
|
||||
}
|
||||
ElMessage.error(resolveAgentSessionErrorMessage(error) || '会话加载失败');
|
||||
} finally {
|
||||
loadingConversation.value = false;
|
||||
}
|
||||
@@ -485,9 +535,17 @@ async function loadConversation(sessionId: string) {
|
||||
async function createNewSession() {
|
||||
currentSessionId.value = '';
|
||||
timelineItems.value = [];
|
||||
promptText.value = '';
|
||||
extraKnowledgeIds.value = [];
|
||||
sending.value = false;
|
||||
if (selectedAgentId.value) {
|
||||
try {
|
||||
await composer.startNew(selectedAgentId.value);
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '新会话创建失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
await syncSessionRoute();
|
||||
}
|
||||
|
||||
@@ -508,10 +566,18 @@ async function bindCreatedSession(sessionId: string, prompt: string) {
|
||||
await syncSessionRoute(sessionId);
|
||||
}
|
||||
|
||||
function handleAgentChange() {
|
||||
async function handleAgentChange() {
|
||||
extraKnowledgeIds.value = [];
|
||||
if (timelineItems.value.length > 0 || currentSessionId.value) {
|
||||
void createNewSession();
|
||||
await createNewSession();
|
||||
} else {
|
||||
await activateComposer(selectedAgentId.value);
|
||||
}
|
||||
if (
|
||||
selectedAgentImageSupport.value === false &&
|
||||
composer.images.items.value.length > 0
|
||||
) {
|
||||
ElMessage.warning('当前智能体不支持图片,请先移除图片');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,14 +639,29 @@ function buildCapabilities() {
|
||||
|
||||
async function sendContent(rawContent: string) {
|
||||
const content = rawContent.trim();
|
||||
if (!content || !selectedAgentId.value || sending.value) {
|
||||
if (
|
||||
(!content && composer.images.readyItems.value.length === 0) ||
|
||||
!selectedAgentId.value ||
|
||||
sending.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (runtimeRunning.value) {
|
||||
ElMessage.warning('当前回复完成后再发送新消息');
|
||||
return;
|
||||
}
|
||||
promptText.value = '';
|
||||
if (composer.images.uploading.value) {
|
||||
ElMessage.warning('图片上传完成后再发送');
|
||||
return;
|
||||
}
|
||||
const failedImage = composer.images.items.value.find(
|
||||
(item) => item.status === 'error',
|
||||
);
|
||||
if (failedImage) {
|
||||
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||
return;
|
||||
}
|
||||
await composer.flush();
|
||||
sending.value = true;
|
||||
try {
|
||||
const sessionId = await agentChatRuntimeManager.start({
|
||||
@@ -588,14 +669,19 @@ async function sendContent(rawContent: string) {
|
||||
agentName: selectedAgent.value?.name,
|
||||
baseItems: timelineItems.value,
|
||||
capabilities: buildCapabilities(),
|
||||
imageUploadIds: composer.images.uploadIds.value,
|
||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||
onInputAccepted: () =>
|
||||
composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
}),
|
||||
prompt: content,
|
||||
sessionId: currentSessionId.value,
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
await bindCreatedSession(sessionId, content);
|
||||
syncRuntimeSnapshot(sessionId);
|
||||
} catch (error) {
|
||||
sending.value = false;
|
||||
promptText.value = content;
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : '发送失败,请稍后再试',
|
||||
);
|
||||
@@ -612,6 +698,112 @@ function handleSuggestedQuestion(question: string) {
|
||||
|
||||
function handlePromptInput() {
|
||||
chatInputTrigger.sync();
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
async function activateComposer(agentId: string, sessionId?: string) {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await composer.activate(agentId, sessionId);
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '输入草稿恢复失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImageFiles() {
|
||||
imageFileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function addImageFiles(files: File[]) {
|
||||
if (!selectedAgentId.value) {
|
||||
ElMessage.warning('请先选择智能体');
|
||||
return;
|
||||
}
|
||||
if (selectedAgentImageSupport.value === false) {
|
||||
ElMessage.warning('当前智能体不支持图片');
|
||||
return;
|
||||
}
|
||||
await composer.ensureSession();
|
||||
const rejected = await composer.images.addFiles(files, {
|
||||
agentId: selectedAgentId.value,
|
||||
mode: 'FORMAL',
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
composer.scheduleSave();
|
||||
if (rejected > 0) {
|
||||
ElMessage.warning('每次最多添加 5 张图片');
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = [...(target.files || [])];
|
||||
if (files.length) void addImageFiles(files);
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function handleImagePaste(event: ClipboardEvent) {
|
||||
const files = [...(event.clipboardData?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
void addImageFiles(files);
|
||||
}
|
||||
|
||||
function handleImageDragEnter(event: DragEvent) {
|
||||
if (
|
||||
selectedAgentImageSupport.value !== false &&
|
||||
!capabilityDisabled.value &&
|
||||
composer.images.items.value.length < 5 &&
|
||||
[...(event.dataTransfer?.types || [])].includes('Files')
|
||||
) {
|
||||
composerDragActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageDragLeave(event: DragEvent) {
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!container.contains(event.relatedTarget)
|
||||
) {
|
||||
composerDragActive.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageDrop(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 (files.length) void addImageFiles(files);
|
||||
}
|
||||
|
||||
async function retryImage(item: any) {
|
||||
await composer.images.retry(item, {
|
||||
agentId: selectedAgentId.value,
|
||||
mode: 'FORMAL',
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
async function removeImage(item: any) {
|
||||
try {
|
||||
await composer.images.remove(item);
|
||||
composer.scheduleSave();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePromptKeyup() {
|
||||
@@ -731,16 +923,15 @@ async function handleDeleteSession(session: AgentChatSessionView) {
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || '删除失败');
|
||||
}
|
||||
sessions.value = sessions.value.filter(
|
||||
(item) => String(item.sessionId) !== sessionId,
|
||||
);
|
||||
if (currentSessionId.value === sessionId) {
|
||||
await createNewSession();
|
||||
}
|
||||
await removeSessionFromPage(sessionId);
|
||||
ElMessage.success('已删除');
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败');
|
||||
if (isMissingAgentSessionError(error)) {
|
||||
await removeSessionFromPage(sessionId);
|
||||
return;
|
||||
}
|
||||
ElMessage.error(resolveAgentSessionErrorMessage(error) || '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,7 +992,10 @@ async function bootstrap() {
|
||||
const latestSnapshot = agentChatRuntimeManager.getLatestSnapshot();
|
||||
if (latestSnapshot?.items.length) {
|
||||
syncRuntimeSnapshot(latestSnapshot.sessionId);
|
||||
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
|
||||
return;
|
||||
}
|
||||
await activateComposer(selectedAgentId.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -936,6 +1130,7 @@ onBeforeUnmount(() => {
|
||||
<ChatTimeline
|
||||
v-else
|
||||
:items="timelineItems"
|
||||
:image-loader="loadAgentChatImage"
|
||||
empty-text="选择智能体后开始对话"
|
||||
:approval-loading="Boolean(approvalLoadingKey)"
|
||||
:copyable="canCopyMessage"
|
||||
@@ -947,7 +1142,14 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__composer">
|
||||
<div
|
||||
class="agent-chat__composer"
|
||||
:class="{ 'is-dragging': composerDragActive }"
|
||||
@dragenter.prevent="handleImageDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleImageDragLeave"
|
||||
@drop.prevent="handleImageDrop"
|
||||
>
|
||||
<ChatCapabilityMenu
|
||||
:disabled="capabilityDisabled"
|
||||
:extra-knowledge-ids="extraKnowledgeIds"
|
||||
@@ -967,6 +1169,15 @@ onBeforeUnmount(() => {
|
||||
@select="handleTriggerSelect"
|
||||
@set-active="chatInputTrigger.setActiveIndex"
|
||||
/>
|
||||
<ChatImageAttachments
|
||||
v-if="composer.images.items.value.length"
|
||||
:items="composer.images.items.value"
|
||||
:image-loader="loadAgentChatImage"
|
||||
removable
|
||||
retryable
|
||||
@remove="removeImage"
|
||||
@retry="retryImage"
|
||||
/>
|
||||
<ElInput
|
||||
ref="promptInputRef"
|
||||
v-model="promptText"
|
||||
@@ -980,9 +1191,31 @@ onBeforeUnmount(() => {
|
||||
@input="handlePromptInput"
|
||||
@keydown="handlePromptKeydown"
|
||||
@keyup="handlePromptKeyup"
|
||||
@paste="handleImagePaste"
|
||||
/>
|
||||
<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>
|
||||
<ChatCapabilityMenu
|
||||
class="agent-chat__capability-entry"
|
||||
:disabled="capabilityDisabled"
|
||||
@@ -1177,12 +1410,11 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 176px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap.is-welcome {
|
||||
padding: 0 min(8vw, 96px) 190px;
|
||||
padding: 0 min(8vw, 96px);
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
@@ -1195,20 +1427,24 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.agent-chat__composer {
|
||||
position: absolute;
|
||||
right: min(8vw, 96px);
|
||||
bottom: 24px;
|
||||
left: min(8vw, 96px);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
margin: 0 min(8vw, 96px) 24px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.agent-chat__composer.is-dragging {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.agent-chat__trigger-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
@@ -1241,6 +1477,10 @@ onBeforeUnmount(() => {
|
||||
max-width: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.agent-chat__image-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-chat__capability-entry {
|
||||
flex: none;
|
||||
}
|
||||
@@ -1343,7 +1583,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap {
|
||||
padding-bottom: 184px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap.is-welcome {
|
||||
@@ -1353,7 +1593,7 @@ onBeforeUnmount(() => {
|
||||
.agent-chat__timeline-wrap.is-welcome :deep(.agent-welcome) {
|
||||
flex: 0 0 auto;
|
||||
min-height: 100%;
|
||||
padding-bottom: 206px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
||||
@@ -1361,9 +1601,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.agent-chat__composer {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
margin: 0 16px 16px;
|
||||
}
|
||||
|
||||
.agent-chat__composer-footer {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isMissingAgentSessionError,
|
||||
resolveAgentSessionErrorMessage,
|
||||
} from './sessionRecovery';
|
||||
|
||||
describe('agent chat session recovery', () => {
|
||||
it('识别请求层直接抛出的业务响应', () => {
|
||||
const error = {
|
||||
errorCode: 400,
|
||||
message: 'Agent 会话不存在',
|
||||
};
|
||||
|
||||
expect(isMissingAgentSessionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('识别包含响应体的网络错误', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
message: 'Agent 会话不存在',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(isMissingAgentSessionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('保留其他错误消息供页面展示', () => {
|
||||
const error = new Error('服务暂时不可用');
|
||||
|
||||
expect(isMissingAgentSessionError(error)).toBe(false);
|
||||
expect(resolveAgentSessionErrorMessage(error)).toBe('服务暂时不可用');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const MISSING_AGENT_SESSION_MESSAGE = 'Agent 会话不存在';
|
||||
|
||||
/**
|
||||
* 提取请求层抛出的错误消息。
|
||||
*/
|
||||
export function resolveAgentSessionErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (!error || typeof error !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const payload = error as Record<string, unknown>;
|
||||
if (typeof payload.message === 'string') {
|
||||
return payload.message;
|
||||
}
|
||||
const response = payload.response;
|
||||
if (!response || typeof response !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const data = (response as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const message = (data as Record<string, unknown>).message;
|
||||
return typeof message === 'string' ? message : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求是否因 Agent 会话已不存在而失败。
|
||||
*/
|
||||
export function isMissingAgentSessionError(error: unknown): boolean {
|
||||
return resolveAgentSessionErrorMessage(error).includes(
|
||||
MISSING_AGENT_SESSION_MESSAGE,
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,12 @@ const selectedKnowledge = computed(() => {
|
||||
|
||||
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
|
||||
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
|
||||
const selectedModel = computed(() =>
|
||||
props.models.find((item) => item.value === String(props.state.agent.modelId)),
|
||||
);
|
||||
const tryoutImageEnabled = computed(() =>
|
||||
Boolean(selectedModel.value?.raw?.supportImage),
|
||||
);
|
||||
|
||||
function isInteractionIssue(issue?: AgentValidationIssue) {
|
||||
return issue?.field?.startsWith('interaction.');
|
||||
@@ -95,6 +101,7 @@ const selectedToolOptions = computed(() => {
|
||||
<template v-if="state.panelMode === 'tryout'">
|
||||
<AgentTryoutPanel
|
||||
:agent="state.agent"
|
||||
:image-enabled="tryoutImageEnabled"
|
||||
:tool-bindings="state.toolBindings"
|
||||
:knowledge-bindings="state.knowledgeBindings"
|
||||
@close="emit('closeTryout')"
|
||||
|
||||
@@ -18,6 +18,8 @@ import { BrushCleaning } from '@easyflow/icons';
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
|
||||
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
|
||||
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
|
||||
|
||||
import { approveAgentRun, rejectAgentRun } from '../api';
|
||||
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
|
||||
@@ -26,6 +28,7 @@ import AgentWelcomeState from './AgentWelcomeState.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentInfo;
|
||||
imageEnabled?: boolean;
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
}>();
|
||||
@@ -45,6 +48,7 @@ const {
|
||||
stop,
|
||||
} = useAgentTryoutStream();
|
||||
const approvalLoading = ref(false);
|
||||
const composer = useAgentComposerDraft('DRAFT');
|
||||
const interactionDisplay = computed(() =>
|
||||
resolveInteractionDisplay(props.agent),
|
||||
);
|
||||
@@ -58,18 +62,27 @@ function getDraftContext() {
|
||||
}
|
||||
|
||||
function syncCurrentDraftContext(restore = false) {
|
||||
syncDraftContext(getDraftContext(), restore);
|
||||
syncDraftContext(getDraftContext(), restore, composer.sessionId.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncCurrentDraftContext(true);
|
||||
});
|
||||
async function activateComposer() {
|
||||
const agentId = String(props.agent.id || '');
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await composer.activate(agentId, `agent-draft-${agentId}`);
|
||||
syncCurrentDraftContext(true);
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '输入草稿恢复失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void activateComposer());
|
||||
|
||||
watch(
|
||||
() => [props.agent.id, props.agent.localId],
|
||||
() => {
|
||||
syncCurrentDraftContext(true);
|
||||
},
|
||||
() => void activateComposer(),
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -82,12 +95,35 @@ watch(
|
||||
|
||||
async function handleSend(prompt: string) {
|
||||
if (loading.value || approvalLoading.value) return;
|
||||
if (composer.images.uploading.value) {
|
||||
ElMessage.warning('图片上传完成后再发送');
|
||||
return;
|
||||
}
|
||||
const failedImage = composer.images.items.value.find(
|
||||
(item) => item.status === 'error',
|
||||
);
|
||||
if (failedImage) {
|
||||
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||
return;
|
||||
}
|
||||
await composer.flush();
|
||||
await sendDraft({
|
||||
...getDraftContext(),
|
||||
prompt,
|
||||
imageUploadIds: composer.images.uploadIds.value,
|
||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||
sessionId: composer.sessionId.value,
|
||||
onAccepted: () =>
|
||||
composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function handleDraftTextInput() {
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
function handleSuggestedQuestion(question: string) {
|
||||
void handleSend(question);
|
||||
}
|
||||
@@ -137,12 +173,49 @@ function handleSelectNextVariant(item: ChatTimelineMessageItem) {
|
||||
async function handleClearSession() {
|
||||
try {
|
||||
await clearDraftSession();
|
||||
await composer.clear();
|
||||
await activateComposer();
|
||||
ElMessage.success('已清理会话');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddFiles(files: File[]) {
|
||||
if (!props.agent.id) {
|
||||
ElMessage.warning('请先保存智能体');
|
||||
return;
|
||||
}
|
||||
await composer.ensureSession();
|
||||
const rejected = await composer.images.addFiles(files, {
|
||||
agentId: String(props.agent.id),
|
||||
mode: 'DRAFT',
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
if (rejected > 0) {
|
||||
ElMessage.warning('每次最多添加 5 张图片');
|
||||
}
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
async function handleRetryImage(item: any) {
|
||||
await composer.images.retry(item, {
|
||||
agentId: String(props.agent.id),
|
||||
mode: 'DRAFT',
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
async function handleRemoveImage(item: any) {
|
||||
try {
|
||||
await composer.images.remove(item);
|
||||
composer.scheduleSave();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
if (!loading.value) {
|
||||
return;
|
||||
@@ -189,14 +262,22 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
||||
|
||||
<template>
|
||||
<AiChatPanel
|
||||
v-model="composer.text.value"
|
||||
:title="agent.name || '草稿试运行'"
|
||||
empty-text="输入问题试运行当前智能体"
|
||||
closable
|
||||
:messages="[]"
|
||||
:loading="loading"
|
||||
:images="composer.images.items.value"
|
||||
:image-enabled="imageEnabled"
|
||||
:image-loader="loadAgentChatImage"
|
||||
:placeholder="interactionDisplay.inputPlaceholder"
|
||||
:approval-loading="approvalLoading"
|
||||
@send="handleSend"
|
||||
@update:model-value="handleDraftTextInput"
|
||||
@add-files="handleAddFiles"
|
||||
@remove-image="handleRemoveImage"
|
||||
@retry-image="handleRetryImage"
|
||||
@stop="handleStop"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
@@ -226,6 +307,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
||||
<ChatTimeline
|
||||
v-else
|
||||
:items="timelineItems"
|
||||
:image-loader="loadAgentChatImage"
|
||||
empty-text="输入问题试运行当前智能体"
|
||||
:approval-loading="approvalLoading"
|
||||
:copyable="canCopyMessage"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type {
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
interface AgentTryoutRuntimeEvent {
|
||||
createdAt: number;
|
||||
@@ -25,6 +26,7 @@ interface AgentTryoutRawVariant {
|
||||
|
||||
interface AgentTryoutRawRound {
|
||||
createdAt: number;
|
||||
images?: ChatImageAttachment[];
|
||||
prompt: string;
|
||||
roundId: string;
|
||||
selectedVariantIndex: number;
|
||||
@@ -110,7 +112,9 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimeEvent(value: any): AgentTryoutRuntimeEvent | undefined {
|
||||
function normalizeRuntimeEvent(
|
||||
value: any,
|
||||
): AgentTryoutRuntimeEvent | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
@@ -135,8 +139,9 @@ function normalizeVariant(value: any, index: number) {
|
||||
? value.runtimeEvents
|
||||
.map((item: any) => normalizeRuntimeEvent(item))
|
||||
.filter(
|
||||
(item: AgentTryoutRuntimeEvent | undefined): item is AgentTryoutRuntimeEvent =>
|
||||
Boolean(item),
|
||||
(
|
||||
item: AgentTryoutRuntimeEvent | undefined,
|
||||
): item is AgentTryoutRuntimeEvent => Boolean(item),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
@@ -157,7 +162,8 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
|
||||
}
|
||||
const prompt = asText(value.prompt);
|
||||
const roundId = asText(value.roundId);
|
||||
if (!prompt || !roundId) {
|
||||
const images = Array.isArray(value.images) ? value.images.slice(0, 5) : [];
|
||||
if ((!prompt && images.length === 0) || !roundId) {
|
||||
return undefined;
|
||||
}
|
||||
const variants = Array.isArray(value.variants)
|
||||
@@ -174,6 +180,7 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
|
||||
);
|
||||
return {
|
||||
createdAt: Number(value.createdAt || Date.now()),
|
||||
images,
|
||||
prompt,
|
||||
roundId,
|
||||
selectedVariantIndex,
|
||||
@@ -210,7 +217,10 @@ function restoreSession(mode: string, sessionId: string) {
|
||||
.map((item) => normalizeRound(item))
|
||||
.filter((item): item is AgentTryoutRawRound => Boolean(item))
|
||||
: [];
|
||||
memorySessions.set(key, rounds.map((item) => clone(item)));
|
||||
memorySessions.set(
|
||||
key,
|
||||
rounds.map((item) => clone(item)),
|
||||
);
|
||||
return rounds;
|
||||
} catch {
|
||||
return [];
|
||||
@@ -228,7 +238,10 @@ function persistSession(
|
||||
sessionId,
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
memorySessions.set(key, snapshot.rounds.map((item) => clone(item)));
|
||||
memorySessions.set(
|
||||
key,
|
||||
snapshot.rounds.map((item) => clone(item)),
|
||||
);
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
@@ -269,7 +282,9 @@ function visibleText(item: ChatTimelineMessageItem) {
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isUserMessage(item: ChatTimelineItem): item is ChatTimelineMessageItem {
|
||||
function isUserMessage(
|
||||
item: ChatTimelineItem,
|
||||
): item is ChatTimelineMessageItem {
|
||||
return item.type === 'message' && item.role === 'user';
|
||||
}
|
||||
|
||||
@@ -326,7 +341,10 @@ function normalizeAssistantPartIds(
|
||||
const segment = assistantSegmentIndex(items, roundId);
|
||||
const latest = [...items]
|
||||
.reverse()
|
||||
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item) && item.roundId === roundId);
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
isAssistantMessage(item) && item.roundId === roundId,
|
||||
);
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
@@ -468,7 +486,9 @@ function projectEventToTimeline(
|
||||
metadata: payload.metadata,
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||
),
|
||||
toolDisplayName: asText(payload.toolDisplayName),
|
||||
toolName: asText(payload.toolName),
|
||||
toolType: asText(payload.toolType),
|
||||
@@ -482,7 +502,9 @@ function projectEventToTimeline(
|
||||
ChatTimelineBuilder.markToolApproving(items, {
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -491,7 +513,9 @@ function projectEventToTimeline(
|
||||
reason: asText(payload.reason),
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -508,8 +532,12 @@ function projectEventToTimeline(
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output: asyncTool
|
||||
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
|
||||
: payload.output ?? payload.result ?? payload.text,
|
||||
? (payload.summary ??
|
||||
payload.label ??
|
||||
payload.output ??
|
||||
payload.result ??
|
||||
payload.text)
|
||||
: (payload.output ?? payload.result ?? payload.text),
|
||||
status: asyncTool
|
||||
? asyncToolTimelineStatus(payload)
|
||||
: type === 'TOOL_RESULT'
|
||||
@@ -521,8 +549,17 @@ function projectEventToTimeline(
|
||||
variantIndex,
|
||||
'knowledge-retrieval',
|
||||
),
|
||||
toolCallId: asText(payload.toolCallId ?? payload.taskId ?? payload.tool_call_id ?? payload.id),
|
||||
toolName: asyncTool ? displayToolName : isHiddenToolName(rawToolName) ? rawToolName : displayToolName,
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ??
|
||||
payload.taskId ??
|
||||
payload.tool_call_id ??
|
||||
payload.id,
|
||||
),
|
||||
toolName: asyncTool
|
||||
? displayToolName
|
||||
: isHiddenToolName(rawToolName)
|
||||
? rawToolName
|
||||
: displayToolName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -568,7 +605,9 @@ function projectEventToTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
function asyncToolTimelineStatus(payload: Record<string, unknown>): ChatTimelineToolStatus {
|
||||
function asyncToolTimelineStatus(
|
||||
payload: Record<string, unknown>,
|
||||
): ChatTimelineToolStatus {
|
||||
const status = asText(payload.status).toUpperCase();
|
||||
if (status === 'SUCCEEDED') return 'success';
|
||||
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
|
||||
@@ -612,7 +651,10 @@ export function useAgentTryoutRawRounds(options: {
|
||||
|
||||
function schedulePersist() {
|
||||
const key = storageKey(options.mode, options.sessionId);
|
||||
memorySessions.set(key, [...rounds.values()].map((item) => clone(item)));
|
||||
memorySessions.set(
|
||||
key,
|
||||
[...rounds.values()].map((item) => clone(item)),
|
||||
);
|
||||
if (persistTimer) {
|
||||
return;
|
||||
}
|
||||
@@ -630,11 +672,12 @@ export function useAgentTryoutRawRounds(options: {
|
||||
removeStoredSession(options.mode, options.sessionId);
|
||||
}
|
||||
|
||||
function createRound(prompt: string) {
|
||||
function createRound(prompt: string, images: ChatImageAttachment[] = []) {
|
||||
const now = Date.now();
|
||||
const roundId = createRoundId();
|
||||
rounds.set(roundId, {
|
||||
createdAt: now,
|
||||
images: clone(images),
|
||||
prompt,
|
||||
roundId,
|
||||
selectedVariantIndex: 1,
|
||||
@@ -738,6 +781,7 @@ export function useAgentTryoutRawRounds(options: {
|
||||
for (const round of sortedRounds(rounds)) {
|
||||
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
|
||||
id: `user-${round.roundId}`,
|
||||
images: round.images,
|
||||
roundId: round.roundId,
|
||||
});
|
||||
const variant = selectedVariant(round);
|
||||
@@ -745,7 +789,12 @@ export function useAgentTryoutRawRounds(options: {
|
||||
continue;
|
||||
}
|
||||
for (const event of variant.runtimeEvents) {
|
||||
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
|
||||
projectEventToTimeline(
|
||||
items,
|
||||
event,
|
||||
round.roundId,
|
||||
variant.variantIndex,
|
||||
);
|
||||
}
|
||||
if (variant.status === 'completed' || variant.status === 'error') {
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
@@ -761,10 +810,7 @@ export function useAgentTryoutRawRounds(options: {
|
||||
return items;
|
||||
}
|
||||
|
||||
function selectVariant(
|
||||
roundId: string,
|
||||
direction: 'next' | 'previous',
|
||||
) {
|
||||
function selectVariant(roundId: string, direction: 'next' | 'previous') {
|
||||
const round = rounds.get(roundId);
|
||||
if (!round) {
|
||||
return;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem as ChatTimelineItemType,
|
||||
ChatTimelineMessageItem,
|
||||
} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentKnowledgeBinding,
|
||||
AgentToolBinding,
|
||||
} from '../types';
|
||||
|
||||
import {ref} from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {sseClient} from '#/api/request';
|
||||
import { sseClient } from '#/api/request';
|
||||
|
||||
import {clearAgentDraftSession} from '../api';
|
||||
import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds';
|
||||
import { clearAgentDraftSession } from '../api';
|
||||
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
|
||||
|
||||
function resolveDraftSessionId(agent: AgentInfo) {
|
||||
return `agent-draft-${agent.id || agent.localId || 'unsaved'}`;
|
||||
@@ -99,8 +104,13 @@ export function useAgentTryoutStream() {
|
||||
timelineItems.value = rawRounds?.buildTimelineItems() || [];
|
||||
}
|
||||
|
||||
function syncDraftContext(payload: DraftRuntimeContext, restore = false) {
|
||||
const sessionId = resolveDraftSessionId(payload.agent);
|
||||
function syncDraftContext(
|
||||
payload: DraftRuntimeContext,
|
||||
restore = false,
|
||||
requestedSessionId?: string,
|
||||
) {
|
||||
const sessionId =
|
||||
requestedSessionId || resolveDraftSessionId(payload.agent);
|
||||
const sessionChanged = activeSessionId !== sessionId;
|
||||
activeSessionId = sessionId;
|
||||
if (!rawRounds || sessionChanged) {
|
||||
@@ -206,26 +216,44 @@ export function useAgentTryoutStream() {
|
||||
|
||||
async function runDraft(payload: {
|
||||
agent: AgentInfo;
|
||||
images?: ChatImageAttachment[];
|
||||
imageUploadIds?: string[];
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
onAccepted?: () => void | Promise<void>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
toolBindings: AgentToolBinding[];
|
||||
}) {
|
||||
syncDraftContext(payload);
|
||||
syncDraftContext(payload, false, payload.sessionId);
|
||||
if (!rawRounds) {
|
||||
return;
|
||||
}
|
||||
activeRoundId = rawRounds.createRound(payload.prompt);
|
||||
activeRoundId = rawRounds.createRound(payload.prompt, payload.images);
|
||||
rebuildTimeline();
|
||||
loading.value = true;
|
||||
userStopped = false;
|
||||
let accepted = false;
|
||||
await sseClient.post(
|
||||
'/api/v1/agent/chat/draft',
|
||||
{
|
||||
...payload,
|
||||
agent: payload.agent,
|
||||
imageUploadIds: payload.imageUploadIds,
|
||||
knowledgeBindings: payload.knowledgeBindings,
|
||||
prompt: payload.prompt,
|
||||
sessionId: activeSessionId,
|
||||
toolBindings: payload.toolBindings,
|
||||
},
|
||||
{
|
||||
onMessage: handleMessage,
|
||||
onMessage: (message) => {
|
||||
const envelope = resolveEnvelope(parseEventData(message));
|
||||
const domain = String(envelope.domain || '').toUpperCase();
|
||||
const type = String(envelope.type || '').toUpperCase();
|
||||
if (!accepted && domain === 'SYSTEM' && type === 'INPUT_ACCEPTED') {
|
||||
accepted = true;
|
||||
void payload.onAccepted?.();
|
||||
}
|
||||
handleMessage(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (shouldIgnoreStoppedError(error)) {
|
||||
return;
|
||||
@@ -257,8 +285,12 @@ export function useAgentTryoutStream() {
|
||||
|
||||
async function sendDraft(payload: {
|
||||
agent: AgentInfo;
|
||||
images?: ChatImageAttachment[];
|
||||
imageUploadIds?: string[];
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
onAccepted?: () => void | Promise<void>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
toolBindings: AgentToolBinding[];
|
||||
}) {
|
||||
await runDraft(payload);
|
||||
|
||||
@@ -88,6 +88,11 @@ const formData = reactive<FormData>({
|
||||
});
|
||||
|
||||
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
|
||||
const visibleModelAbility = computed(() =>
|
||||
modelAbility.value.filter(
|
||||
(item) => item.field !== 'supportImageB64Only' || formData.supportImage,
|
||||
),
|
||||
);
|
||||
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
|
||||
|
||||
const selectedModelType = ref<SelectableModelType>('');
|
||||
@@ -129,6 +134,13 @@ const handleTagClick = (item: ModelAbilityItem) => {
|
||||
}
|
||||
item.selected = !item.selected;
|
||||
formData[item.field] = item.selected;
|
||||
if (item.field === 'supportImage' && !item.selected) {
|
||||
formData.supportImageB64Only = false;
|
||||
const base64Ability = modelAbility.value.find(
|
||||
(ability) => ability.field === 'supportImageB64Only',
|
||||
);
|
||||
if (base64Ability) base64Ability.selected = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelTypeChipClick = (
|
||||
@@ -353,7 +365,7 @@ const save = async () => {
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
v-for="item in modelAbility"
|
||||
v-for="item in visibleModelAbility"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="model-modal__ability-chip"
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatImageAttachment, ChatImageLoader } from './types';
|
||||
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { useEasyFlowModal } from '@easyflow-core/popup-ui';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatImageAttachment[];
|
||||
removable?: boolean;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
imageLoader: undefined,
|
||||
removable: false,
|
||||
retryable: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [item: ChatImageAttachment];
|
||||
retry: [item: ChatImageAttachment];
|
||||
}>();
|
||||
|
||||
const resolvedUrls = ref<Record<string, string>>({});
|
||||
const failedSources = ref<Set<string>>(new Set());
|
||||
const previewItem = ref<ChatImageAttachment>();
|
||||
const ownedUrls = new Set<string>();
|
||||
let resolutionVersion = 0;
|
||||
|
||||
const [ImagePreviewModal, imagePreviewModalApi] = useEasyFlowModal({
|
||||
onOpenChange(open) {
|
||||
if (!open) previewItem.value = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
function sourceOf(item: ChatImageAttachment) {
|
||||
return item.previewUrl || '';
|
||||
}
|
||||
|
||||
function resolvedUrl(item: ChatImageAttachment) {
|
||||
return resolvedUrls.value[sourceOf(item)] || '';
|
||||
}
|
||||
|
||||
const previewUrl = computed(() =>
|
||||
previewItem.value ? resolvedUrl(previewItem.value) : '',
|
||||
);
|
||||
|
||||
function canPreview(item: ChatImageAttachment) {
|
||||
const source = sourceOf(item);
|
||||
return (
|
||||
Boolean(resolvedUrl(item)) &&
|
||||
item.status !== 'error' &&
|
||||
item.status !== 'uploading' &&
|
||||
!failedSources.value.has(source)
|
||||
);
|
||||
}
|
||||
|
||||
function openPreview(item: ChatImageAttachment) {
|
||||
if (!canPreview(item)) return;
|
||||
previewItem.value = item;
|
||||
imagePreviewModalApi.open();
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewItem.value = undefined;
|
||||
imagePreviewModalApi.close();
|
||||
}
|
||||
|
||||
function releaseOwnedUrl(url?: string) {
|
||||
if (!url || !ownedUrls.delete(url)) return;
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function resolveImages() {
|
||||
const version = ++resolutionVersion;
|
||||
const activeSources = new Set(
|
||||
props.items.map((item) => sourceOf(item)).filter(Boolean),
|
||||
);
|
||||
if (previewItem.value && !activeSources.has(sourceOf(previewItem.value))) {
|
||||
closePreview();
|
||||
}
|
||||
const nextUrls = { ...resolvedUrls.value };
|
||||
for (const [source, url] of Object.entries(nextUrls)) {
|
||||
if (!activeSources.has(source)) {
|
||||
releaseOwnedUrl(url);
|
||||
delete nextUrls[source];
|
||||
}
|
||||
}
|
||||
resolvedUrls.value = nextUrls;
|
||||
failedSources.value = new Set(
|
||||
[...failedSources.value].filter((source) => activeSources.has(source)),
|
||||
);
|
||||
|
||||
for (const source of activeSources) {
|
||||
if (resolvedUrls.value[source]) continue;
|
||||
if (!props.imageLoader || /^(?:blob:|data:)/i.test(source)) {
|
||||
resolvedUrls.value = { ...resolvedUrls.value, [source]: source };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const url = await props.imageLoader(source);
|
||||
if (version !== resolutionVersion || !activeSources.has(source)) {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
continue;
|
||||
}
|
||||
if (url.startsWith('blob:')) ownedUrls.add(url);
|
||||
resolvedUrls.value = { ...resolvedUrls.value, [source]: url };
|
||||
} catch {
|
||||
if (version === resolutionVersion) {
|
||||
failedSources.value = new Set([source, ...failedSources.value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
props.items.map((item) => sourceOf(item)).join('|'),
|
||||
props.imageLoader,
|
||||
] as const,
|
||||
() => void resolveImages(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resolutionVersion += 1;
|
||||
for (const url of ownedUrls) URL.revokeObjectURL(url);
|
||||
ownedUrls.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="items.length > 0"
|
||||
class="chat-image-attachments"
|
||||
:class="{ 'is-compact': compact }"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.uploadId || item.imageRef || item.localId || item.previewUrl"
|
||||
class="chat-image-attachments__item"
|
||||
:class="`is-${item.status || 'ready'}`"
|
||||
>
|
||||
<button
|
||||
v-if="resolvedUrl(item)"
|
||||
type="button"
|
||||
class="chat-image-attachments__preview-trigger"
|
||||
:disabled="!canPreview(item)"
|
||||
:aria-label="`查看完整图片:${item.name}`"
|
||||
title="查看完整图片"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<img
|
||||
class="chat-image-attachments__image"
|
||||
:src="resolvedUrl(item)"
|
||||
:alt="item.name"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="item.status === 'uploading'"
|
||||
class="chat-image-attachments__state"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="chat-image-attachments__spinner"></span>
|
||||
上传中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'error'"
|
||||
class="chat-image-attachments__state is-error"
|
||||
:title="item.error || '上传失败'"
|
||||
>
|
||||
上传失败
|
||||
</div>
|
||||
<div
|
||||
v-else-if="failedSources.has(sourceOf(item))"
|
||||
class="chat-image-attachments__state is-error"
|
||||
>
|
||||
加载失败
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!resolvedUrl(item)"
|
||||
class="chat-image-attachments__state"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="chat-image-attachments__spinner"></span>
|
||||
加载中
|
||||
</div>
|
||||
<div class="chat-image-attachments__name" :title="item.name">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="chat-image-attachments__actions">
|
||||
<button
|
||||
v-if="retryable && item.status === 'error'"
|
||||
type="button"
|
||||
class="chat-image-attachments__action"
|
||||
aria-label="重新上传"
|
||||
title="重新上传"
|
||||
@click.stop="emit('retry', item)"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
class="chat-image-attachments__action"
|
||||
aria-label="移除图片"
|
||||
title="移除图片"
|
||||
@click.stop="emit('remove', item)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImagePreviewModal
|
||||
:bordered="false"
|
||||
centered
|
||||
class="!max-h-[calc(100vh-24px)] !w-fit max-w-[calc(100vw-24px)]"
|
||||
close-on-click-modal
|
||||
content-class="!p-0 !overflow-hidden"
|
||||
destroy-on-close
|
||||
:footer="false"
|
||||
:fullscreen-button="false"
|
||||
:header="false"
|
||||
>
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
class="chat-image-preview__image"
|
||||
:src="previewUrl"
|
||||
:alt="previewItem?.name || '聊天图片'"
|
||||
/>
|
||||
</ImagePreviewModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-image-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 8px);
|
||||
align-items: flex-start;
|
||||
width: min(100%, 640px);
|
||||
}
|
||||
|
||||
.chat-image-attachments__item {
|
||||
position: relative;
|
||||
flex: 0 1 auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger:not(:disabled):hover {
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
}
|
||||
|
||||
.chat-image-attachments__preview-trigger:not(:disabled):focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline: 2px solid
|
||||
color-mix(in srgb, var(--el-color-primary) 48%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-image-attachments__image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
height: auto;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.chat-image-attachments__name {
|
||||
max-width: 160px;
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-image-attachments__state {
|
||||
position: absolute;
|
||||
inset: 0 0 25px;
|
||||
display: flex;
|
||||
gap: var(--space-1, 4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-bg-color);
|
||||
background: color-mix(in srgb, var(--el-bg-color) 84%, transparent);
|
||||
}
|
||||
|
||||
.chat-image-attachments__state.is-error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.chat-image-attachments__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--el-border-color);
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: chat-image-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.chat-image-attachments__actions {
|
||||
position: absolute;
|
||||
top: var(--space-1, 4px);
|
||||
right: var(--space-1, 4px);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-image-attachments__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
font-size: 17px;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
background: var(--el-bg-color);
|
||||
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chat-image-attachments__action:hover,
|
||||
.chat-image-attachments__action:focus-visible {
|
||||
color: var(--el-color-primary);
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline: 2px solid
|
||||
color-mix(in srgb, var(--el-color-primary) 36%, transparent);
|
||||
}
|
||||
|
||||
.chat-image-attachments.is-compact .chat-image-attachments__image {
|
||||
max-width: 240px;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.chat-image-attachments.is-compact .chat-image-attachments__name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-image-attachments.is-compact .chat-image-attachments__state {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.chat-image-preview__image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: calc(100vw - var(--space-6, 24px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--space-6, 24px));
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
@keyframes chat-image-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatTimelineItem as ChatTimelineItemType,
|
||||
ChatImageLoader,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
} from './types';
|
||||
|
||||
import {nextTick, onBeforeUnmount, ref, watch} from 'vue';
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import ChatTimelineItem from './ChatTimelineItem.vue';
|
||||
|
||||
@@ -13,6 +14,7 @@ const props = defineProps<{
|
||||
approvalLoading?: boolean;
|
||||
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
emptyText?: string;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatTimelineItemType[];
|
||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
regenerateDisabled?: boolean;
|
||||
@@ -153,6 +155,7 @@ watch(
|
||||
:key="item.id"
|
||||
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
|
||||
:item="item"
|
||||
:image-loader="imageLoader"
|
||||
:approval-loading="approvalLoading"
|
||||
:copyable="canCopyMessage(item)"
|
||||
:regenerable="canRegenerateMessage(item)"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatTimelineItem,
|
||||
ChatImageLoader,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineMessagePart,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
} from './types';
|
||||
|
||||
import {computed} from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
|
||||
import ChatErrorNotice from './ChatErrorNotice.vue';
|
||||
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
|
||||
import ChatImageAttachments from './ChatImageAttachments.vue';
|
||||
import ChatMessageToolbar from './ChatMessageToolbar.vue';
|
||||
import ChatTextBlock from './ChatTextBlock.vue';
|
||||
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
|
||||
@@ -22,6 +24,7 @@ const props = defineProps<{
|
||||
regenerable?: boolean;
|
||||
regenerateDisabled?: boolean;
|
||||
assistantActionsVisible?: boolean;
|
||||
imageLoader?: ChatImageLoader;
|
||||
item: ChatTimelineItem;
|
||||
variantLoading?: boolean;
|
||||
}>();
|
||||
@@ -127,6 +130,12 @@ function updateThinkingExpanded(partId: string, expanded: boolean) {
|
||||
{ 'has-variant-navigator': showVariantNavigator },
|
||||
]"
|
||||
>
|
||||
<ChatImageAttachments
|
||||
v-if="messageItem.images?.length"
|
||||
:items="messageItem.images"
|
||||
:image-loader="imageLoader"
|
||||
compact
|
||||
/>
|
||||
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
|
||||
<ChatThinkingBlock
|
||||
v-if="part.type === 'thinking'"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ChatImageAttachment } from '../types';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import ChatImageAttachments from '../ChatImageAttachments.vue';
|
||||
|
||||
const image: ChatImageAttachment = {
|
||||
name: '界面截图.png',
|
||||
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
||||
status: 'ready',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('chat image attachments', () => {
|
||||
it('opens the complete image preview from a ready thumbnail', async () => {
|
||||
const wrapper = mount(ChatImageAttachments, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
items: [image],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const trigger = wrapper.get('[aria-label="查看完整图片:界面截图.png"]');
|
||||
expect(trigger.attributes('disabled')).toBeUndefined();
|
||||
expect(
|
||||
wrapper.get('.chat-image-attachments__image').attributes('src'),
|
||||
).toBe(image.previewUrl);
|
||||
|
||||
await trigger.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
const preview = document.body.querySelector<HTMLImageElement>(
|
||||
'.chat-image-preview__image',
|
||||
);
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(preview?.src).toBe(image.previewUrl);
|
||||
expect(preview?.alt).toBe(image.name);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps an uploading image unavailable for preview', async () => {
|
||||
const wrapper = mount(ChatImageAttachments, {
|
||||
props: {
|
||||
items: [{ ...image, status: 'uploading' }],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const trigger = wrapper.get('[aria-label="查看完整图片:界面截图.png"]');
|
||||
expect(trigger.attributes('disabled')).toBeDefined();
|
||||
|
||||
await trigger.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the preview when its image is removed', async () => {
|
||||
const wrapper = mount(ChatImageAttachments, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
items: [image],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper
|
||||
.get('[aria-label="查看完整图片:界面截图.png"]')
|
||||
.trigger('click');
|
||||
await flushPromises();
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
await wrapper.setProps({ items: [] });
|
||||
await flushPromises();
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -319,7 +319,7 @@ export const ChatTimelineBuilder = {
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const text = normalizeText(content);
|
||||
if (!text) {
|
||||
if (!text && !metadata?.images?.length) {
|
||||
return;
|
||||
}
|
||||
const item: ChatTimelineMessageItem = {
|
||||
@@ -327,13 +327,15 @@ export const ChatTimelineBuilder = {
|
||||
role: 'user',
|
||||
status: 'done',
|
||||
createdAt: Date.now(),
|
||||
parts: [
|
||||
{
|
||||
id: createId('text'),
|
||||
content: text,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
parts: text
|
||||
? [
|
||||
{
|
||||
id: createId('text'),
|
||||
content: text,
|
||||
type: 'text' as const,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
type: 'message',
|
||||
...metadata,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { ChatTimelineBuilder } from './builder';
|
||||
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
|
||||
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
|
||||
export { default as ChatImageAttachments } from './ChatImageAttachments.vue';
|
||||
export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue';
|
||||
export { default as ChatTextBlock } from './ChatTextBlock.vue';
|
||||
export { default as ChatTimeline } from './ChatTimeline.vue';
|
||||
@@ -11,6 +12,8 @@ export { default as ChatToolCard } from './ChatToolCard.vue';
|
||||
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
|
||||
export type {
|
||||
ChatTimelineErrorItem,
|
||||
ChatImageAttachment,
|
||||
ChatImageLoader,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineItemStatus,
|
||||
ChatTimelineKnowledgeHit,
|
||||
|
||||
@@ -12,6 +12,22 @@ export type ChatTimelineToolStatus =
|
||||
export type ChatTimelineStatusStatus = 'done' | 'running';
|
||||
export type ChatTimelineStatusTone = 'muted';
|
||||
|
||||
export interface ChatImageAttachment {
|
||||
error?: string;
|
||||
height?: number;
|
||||
imageRef?: string;
|
||||
localId?: string;
|
||||
mimeType?: string;
|
||||
name: string;
|
||||
previewUrl: string;
|
||||
size?: number;
|
||||
status?: 'error' | 'ready' | 'uploading';
|
||||
uploadId?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export type ChatImageLoader = (previewUrl: string) => Promise<string>;
|
||||
|
||||
export interface ChatTimelineToolApprovalPayload {
|
||||
requestId: string;
|
||||
resumeToken: string;
|
||||
@@ -50,6 +66,7 @@ export interface ChatTimelineItemBase {
|
||||
}
|
||||
|
||||
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
|
||||
images?: ChatImageAttachment[];
|
||||
knowledgeItems?: ChatTimelineKnowledgeHit[];
|
||||
parts: ChatTimelineMessagePart[];
|
||||
regenerable?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user