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

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