feat: 完善智能体图片聊天与会话恢复

- 增加私有图片上传、绑定、历史回显与生命周期清理

- 支持输入草稿恢复、图片交互和模型图片能力约束

- 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
2026-07-17 19:54:26 +08:00
parent 62d763199f
commit 1e6158be77
62 changed files with 5333 additions and 189 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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