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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user