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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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