feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理 - 支持输入草稿恢复、图片交互和模型图片能力约束 - 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
ChatTimelineToolStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import type {AgentChatMessageRecord} from '../api';
|
||||
import type { AgentChatMessageRecord } from '../api';
|
||||
|
||||
export interface AgentSseEnvelope {
|
||||
domain: string;
|
||||
@@ -73,8 +74,7 @@ function isBlankToolName(value: unknown) {
|
||||
function shouldSkipToolProjection(value: unknown) {
|
||||
const normalizedName = normalizeToolName(value).toLowerCase();
|
||||
return (
|
||||
normalizedName === 'context_reload' ||
|
||||
normalizedName === '__fragment__'
|
||||
normalizedName === 'context_reload' || normalizedName === '__fragment__'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,6 +160,26 @@ function normalizeKnowledgeItems(payload: Record<string, any>) {
|
||||
.filter((item) => item.chunkContent || item.title || item.documentName);
|
||||
}
|
||||
|
||||
function normalizeImages(payload: Record<string, any>) {
|
||||
return asArray(payload.images)
|
||||
.map((value): ChatImageAttachment | undefined => {
|
||||
const image = asRecord(value);
|
||||
const previewUrl = asText(image.previewUrl);
|
||||
if (!previewUrl) return undefined;
|
||||
return {
|
||||
height: Number(image.height || 0) || undefined,
|
||||
imageRef: asText(image.imageRef),
|
||||
mimeType: asText(image.mimeType),
|
||||
name: asText(image.name) || '图片',
|
||||
previewUrl,
|
||||
size: Number(image.size || 0) || undefined,
|
||||
status: 'ready',
|
||||
width: Number(image.width || 0) || undefined,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ChatImageAttachment => Boolean(item));
|
||||
}
|
||||
|
||||
function buildApprovalPayload(payload: Record<string, any>) {
|
||||
return {
|
||||
expiresAt: asText(payload.expiresAt),
|
||||
@@ -318,7 +338,10 @@ function appendHistoryRecord(
|
||||
const role = normalizeRole(record.senderRole);
|
||||
const metadata = normalizeMetadata(record);
|
||||
if (role === 'user') {
|
||||
ChatTimelineBuilder.appendUserMessage(items, record.contentText, metadata);
|
||||
ChatTimelineBuilder.appendUserMessage(items, record.contentText, {
|
||||
...metadata,
|
||||
images: normalizeImages(asRecord(record.contentPayload)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (role === 'system') {
|
||||
@@ -446,8 +469,12 @@ export function applyAgentSseEnvelope(
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output: asyncTool
|
||||
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
|
||||
: payload.output ?? payload.result ?? payload.text,
|
||||
? (payload.summary ??
|
||||
payload.label ??
|
||||
payload.output ??
|
||||
payload.result ??
|
||||
payload.text)
|
||||
: (payload.output ?? payload.result ?? payload.text),
|
||||
status: asyncTool
|
||||
? asyncToolTimelineStatus(payload)
|
||||
: type === 'TOOL_RESULT'
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
||||
|
||||
import { sendAgentChat } from './api';
|
||||
import { agentChatRuntimeManager } from './agentChatRuntimeManager';
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
generateAgentSessionId: vi.fn(),
|
||||
sendAgentChat: vi.fn(),
|
||||
stopAgentChatStream: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('agentChatRuntimeManager', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('replaces draft image URLs and isolates snapshots by account', async () => {
|
||||
let callbacks: any;
|
||||
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||
callbacks = options;
|
||||
return Promise.resolve() as any;
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
const firstAccount = {
|
||||
avatar: '',
|
||||
id: 'user-1',
|
||||
loginName: 'admin',
|
||||
nickname: '管理员',
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
userStore.setUserInfo(firstAccount);
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
images: [
|
||||
{
|
||||
name: 'draft.png',
|
||||
previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1',
|
||||
status: 'ready',
|
||||
uploadId: 'upload-1',
|
||||
},
|
||||
],
|
||||
prompt: '识别图片',
|
||||
sessionId: '101',
|
||||
});
|
||||
callbacks.onMessage({
|
||||
data: JSON.stringify({
|
||||
domain: 'SYSTEM',
|
||||
payload: {
|
||||
images: [
|
||||
{
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
name: 'draft.png',
|
||||
previewUrl:
|
||||
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'INPUT_ACCEPTED',
|
||||
}),
|
||||
});
|
||||
|
||||
const accepted = agentChatRuntimeManager.getSnapshot('101');
|
||||
const userMessage = accepted?.items.find(
|
||||
(item) => item.type === 'message' && item.role === 'user',
|
||||
);
|
||||
expect(
|
||||
userMessage?.type === 'message' ? userMessage.images?.[0] : null,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
previewUrl:
|
||||
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||
}),
|
||||
);
|
||||
|
||||
userStore.setUserInfo({
|
||||
...firstAccount,
|
||||
id: 'user-2',
|
||||
loginName: 'other',
|
||||
nickname: '其他用户',
|
||||
});
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
|
||||
clearAgentChatBrowserCache(firstAccount);
|
||||
userStore.setUserInfo(firstAccount);
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,35 @@
|
||||
import type {ChatTimelineItem} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
import type {
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
} from '@easyflow/common-ui';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import type {AgentChatCapabilityPayload} from './api';
|
||||
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
|
||||
import {
|
||||
onAgentChatCacheClear,
|
||||
resolveAgentChatIdentity,
|
||||
RUNTIME_STORAGE_PREFIX,
|
||||
} from '#/utils/agent-chat-cache';
|
||||
|
||||
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
|
||||
import type { AgentChatCapabilityPayload } from './api';
|
||||
import {
|
||||
generateAgentSessionId,
|
||||
sendAgentChat,
|
||||
stopAgentChatStream,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
applyAgentSseEnvelope,
|
||||
parseAgentSseMessage,
|
||||
} from './adapters/agentTimelineAdapter';
|
||||
|
||||
interface RuntimeSessionState {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
identity: string;
|
||||
items: ChatTimelineItem[];
|
||||
prompt: string;
|
||||
roundId: string;
|
||||
@@ -37,17 +56,18 @@ interface StartOptions {
|
||||
agentName?: string;
|
||||
baseItems?: ChatTimelineItem[];
|
||||
capabilities?: AgentChatCapabilityPayload[];
|
||||
imageUploadIds?: string[];
|
||||
images?: ChatImageAttachment[];
|
||||
onInputAccepted?: () => void | Promise<void>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
|
||||
const LATEST_STORAGE_KEY = `${STORAGE_PREFIX}:latest`;
|
||||
const STORAGE_VERSION = 1;
|
||||
const STORAGE_VERSION = 2;
|
||||
|
||||
const sessions = new Map<string, RuntimeSessionState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let latestSessionId = '';
|
||||
const latestSessionIds = new Map<string, string>();
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
const serialized = JSON.stringify(value);
|
||||
@@ -58,8 +78,20 @@ function createRoundId() {
|
||||
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function storageKey(sessionId: string) {
|
||||
return `${STORAGE_PREFIX}:${sessionId}`;
|
||||
function identityScope() {
|
||||
return resolveAgentChatIdentity(useUserStore().userInfo);
|
||||
}
|
||||
|
||||
function sessionKey(identity: string, sessionId: string) {
|
||||
return `${identity}:${sessionId}`;
|
||||
}
|
||||
|
||||
function storageKey(identity: string, sessionId: string) {
|
||||
return `${RUNTIME_STORAGE_PREFIX}:${identity}:${sessionId}`;
|
||||
}
|
||||
|
||||
function latestStorageKey(identity: string) {
|
||||
return `${RUNTIME_STORAGE_PREFIX}:${identity}:latest`;
|
||||
}
|
||||
|
||||
function safeSessionStorage() {
|
||||
@@ -94,15 +126,22 @@ function persistSession(state: RuntimeSessionState) {
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
try {
|
||||
storage.setItem(storageKey(state.sessionId), JSON.stringify(snapshot));
|
||||
storage.setItem(LATEST_STORAGE_KEY, state.sessionId);
|
||||
storage.setItem(
|
||||
storageKey(state.identity, state.sessionId),
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
storage.setItem(latestStorageKey(state.identity), state.sessionId);
|
||||
} catch {
|
||||
// 缓存失败不影响正式聊天主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSession(sessionId: string) {
|
||||
const existing = sessions.get(sessionId);
|
||||
function restoreSession(identity: string, sessionId: string) {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const scopedSessionKey = sessionKey(identity, sessionId);
|
||||
const existing = sessions.get(scopedSessionKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
@@ -111,7 +150,7 @@ function restoreSession(sessionId: string) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(storageKey(sessionId));
|
||||
const raw = storage.getItem(storageKey(identity, sessionId));
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -124,6 +163,7 @@ function restoreSession(sessionId: string) {
|
||||
agentName: parsed.agentName,
|
||||
completed: parsed.completed,
|
||||
error: parsed.error,
|
||||
identity,
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
prompt: parsed.prompt,
|
||||
roundId: parsed.roundId,
|
||||
@@ -131,7 +171,7 @@ function restoreSession(sessionId: string) {
|
||||
sessionId,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
sessions.set(sessionId, restored);
|
||||
sessions.set(scopedSessionKey, restored);
|
||||
return restored;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -140,25 +180,30 @@ function restoreSession(sessionId: string) {
|
||||
|
||||
function upsertState(state: RuntimeSessionState) {
|
||||
state.updatedAt = Date.now();
|
||||
latestSessionId = state.sessionId;
|
||||
sessions.set(state.sessionId, state);
|
||||
latestSessionIds.set(state.identity, state.sessionId);
|
||||
sessions.set(sessionKey(state.identity, state.sessionId), state);
|
||||
persistSession(state);
|
||||
notify();
|
||||
}
|
||||
|
||||
function runningSession() {
|
||||
return [...sessions.values()].find((session) => session.sending);
|
||||
function runningSession(identity = identityScope()) {
|
||||
return [...sessions.values()].find(
|
||||
(session) => session.identity === identity && session.sending,
|
||||
);
|
||||
}
|
||||
|
||||
function restoreLatestSession() {
|
||||
const running = runningSession();
|
||||
function restoreLatestSession(identity = identityScope()) {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const running = runningSession(identity);
|
||||
if (running) {
|
||||
return running;
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
const storedSessionId = storage?.getItem(LATEST_STORAGE_KEY) || '';
|
||||
const sessionId = latestSessionId || storedSessionId;
|
||||
return sessionId ? restoreSession(sessionId) : undefined;
|
||||
const storedSessionId = storage?.getItem(latestStorageKey(identity)) || '';
|
||||
const sessionId = latestSessionIds.get(identity) || storedSessionId;
|
||||
return sessionId ? restoreSession(identity, sessionId) : undefined;
|
||||
}
|
||||
|
||||
async function resolveSessionId(sessionId?: string) {
|
||||
@@ -176,12 +221,70 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '发送失败,请稍后再试';
|
||||
}
|
||||
|
||||
function normalizeAcceptedImages(payload: Record<string, any>) {
|
||||
if (!Array.isArray(payload.images)) {
|
||||
return [];
|
||||
}
|
||||
return payload.images
|
||||
.map((value): ChatImageAttachment | undefined => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const image = value as Record<string, any>;
|
||||
const previewUrl = String(image.previewUrl || '');
|
||||
if (!previewUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
height: Number(image.height || 0) || undefined,
|
||||
imageRef: String(image.imageRef || ''),
|
||||
mimeType: String(image.mimeType || ''),
|
||||
name: String(image.name || '图片'),
|
||||
previewUrl,
|
||||
size: Number(image.size || 0) || undefined,
|
||||
status: 'ready',
|
||||
width: Number(image.width || 0) || undefined,
|
||||
};
|
||||
})
|
||||
.filter((image): image is ChatImageAttachment => Boolean(image));
|
||||
}
|
||||
|
||||
function replaceAcceptedImages(
|
||||
items: ChatTimelineItem[],
|
||||
roundId: string,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
const acceptedImages = normalizeAcceptedImages(payload);
|
||||
if (acceptedImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
const userMessage = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'user' &&
|
||||
item.roundId === roundId,
|
||||
);
|
||||
if (userMessage) {
|
||||
userMessage.images = acceptedImages;
|
||||
}
|
||||
}
|
||||
|
||||
onAgentChatCacheClear((identity) => {
|
||||
for (const [key, session] of sessions) {
|
||||
if (session.identity === identity) {
|
||||
sessions.delete(key);
|
||||
}
|
||||
}
|
||||
latestSessionIds.delete(identity);
|
||||
notify();
|
||||
});
|
||||
|
||||
export const agentChatRuntimeManager = {
|
||||
getSnapshot(sessionId?: string) {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const state = restoreSession(sessionId);
|
||||
const state = restoreSession(identityScope(), sessionId);
|
||||
return state ? clone(state) : undefined;
|
||||
},
|
||||
|
||||
@@ -195,7 +298,7 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
|
||||
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
|
||||
const state = restoreSession(sessionId);
|
||||
const state = restoreSession(identityScope(), sessionId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +307,11 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
|
||||
async start(options: StartOptions) {
|
||||
const active = runningSession();
|
||||
const identity = identityScope();
|
||||
if (!identity) {
|
||||
throw new Error('当前登录状态失效');
|
||||
}
|
||||
const active = runningSession(identity);
|
||||
if (active) {
|
||||
throw new Error('当前回复完成后再发送新消息');
|
||||
}
|
||||
@@ -214,6 +321,7 @@ export const agentChatRuntimeManager = {
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
completed: false,
|
||||
identity,
|
||||
items: clone(options.baseItems || []),
|
||||
prompt: options.prompt,
|
||||
roundId,
|
||||
@@ -222,6 +330,7 @@ export const agentChatRuntimeManager = {
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
||||
images: options.images,
|
||||
roundId,
|
||||
});
|
||||
upsertState(state);
|
||||
@@ -230,12 +339,13 @@ export const agentChatRuntimeManager = {
|
||||
{
|
||||
agentId: options.agentId,
|
||||
capabilities: options.capabilities,
|
||||
imageUploadIds: options.imageUploadIds,
|
||||
prompt: options.prompt,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
const current = sessions.get(sessionId);
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
@@ -247,7 +357,7 @@ export const agentChatRuntimeManager = {
|
||||
upsertState(current);
|
||||
},
|
||||
onFinished() {
|
||||
const current = sessions.get(sessionId);
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
@@ -257,7 +367,7 @@ export const agentChatRuntimeManager = {
|
||||
upsertState(current);
|
||||
},
|
||||
onMessage(message) {
|
||||
const current = sessions.get(sessionId);
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
@@ -265,6 +375,13 @@ export const agentChatRuntimeManager = {
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
envelope.domain === 'SYSTEM' &&
|
||||
envelope.type === 'INPUT_ACCEPTED'
|
||||
) {
|
||||
replaceAcceptedImages(current.items, roundId, envelope.payload);
|
||||
void options.onInputAccepted?.();
|
||||
}
|
||||
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
||||
upsertState(current);
|
||||
},
|
||||
@@ -275,7 +392,10 @@ export const agentChatRuntimeManager = {
|
||||
},
|
||||
|
||||
stop(sessionId?: string) {
|
||||
const state = sessionId ? restoreSession(sessionId) : runningSession();
|
||||
const identity = identityScope();
|
||||
const state = sessionId
|
||||
? restoreSession(identity, sessionId)
|
||||
: runningSession(identity);
|
||||
if (!state || !state.sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {AgentInfo} from '../agents/types';
|
||||
import type { AgentInfo } from '../agents/types';
|
||||
|
||||
import {api, SseClient} from '#/api/request';
|
||||
import { api, SseClient } from '#/api/request';
|
||||
|
||||
const agentChatSseClient = new SseClient();
|
||||
|
||||
@@ -170,6 +170,7 @@ export function sendAgentChat(
|
||||
data: {
|
||||
agentId: number | string;
|
||||
capabilities?: AgentChatCapabilityPayload[];
|
||||
imageUploadIds?: string[];
|
||||
prompt: string;
|
||||
sessionId?: number | string;
|
||||
},
|
||||
|
||||
@@ -16,12 +16,17 @@ import type {
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ChatTimeline, ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
import {
|
||||
ChatImageAttachments,
|
||||
ChatTimeline,
|
||||
ChatTimelineBuilder,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
Delete,
|
||||
EditPen,
|
||||
MoreFilled,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Promotion,
|
||||
} from '@element-plus/icons-vue';
|
||||
@@ -41,6 +46,8 @@ import {
|
||||
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
|
||||
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
|
||||
import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
|
||||
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
|
||||
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
|
||||
|
||||
import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue';
|
||||
import { resolveInteractionDisplay } from '../agents/interaction-config';
|
||||
@@ -58,6 +65,10 @@ import {
|
||||
renameAgentSession,
|
||||
saveAgentSessionExtraKnowledges,
|
||||
} from './api';
|
||||
import {
|
||||
isMissingAgentSessionError,
|
||||
resolveAgentSessionErrorMessage,
|
||||
} from './sessionRecovery';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -67,8 +78,11 @@ const sessions = ref<AgentChatSessionView[]>([]);
|
||||
const timelineItems = ref<ChatTimelineItem[]>([]);
|
||||
const selectedAgentId = ref('');
|
||||
const currentSessionId = ref('');
|
||||
const promptText = ref('');
|
||||
const composer = useAgentComposerDraft('FORMAL');
|
||||
const promptText = composer.text;
|
||||
const promptInputRef = ref();
|
||||
const imageFileInputRef = ref<HTMLInputElement>();
|
||||
const composerDragActive = ref(false);
|
||||
const loadingAgents = ref(false);
|
||||
const agentLoadError = ref('');
|
||||
const loadingSessions = ref(false);
|
||||
@@ -88,6 +102,11 @@ let runtimeUnsubscribe: (() => void) | undefined;
|
||||
const selectedAgent = computed(() =>
|
||||
agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
|
||||
);
|
||||
const selectedAgentImageSupport = computed(() =>
|
||||
Boolean(
|
||||
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
|
||||
),
|
||||
);
|
||||
const interactionDisplay = computed(() =>
|
||||
resolveInteractionDisplay(selectedAgent.value),
|
||||
);
|
||||
@@ -99,7 +118,12 @@ const currentSession = computed(() =>
|
||||
const canStopRuntime = computed(() => sending.value || runtimeRunning.value);
|
||||
const canSend = computed(
|
||||
() =>
|
||||
Boolean(promptText.value.trim()) &&
|
||||
(Boolean(promptText.value.trim()) ||
|
||||
composer.images.readyItems.value.length > 0) &&
|
||||
!composer.images.uploading.value &&
|
||||
!composer.images.items.value.some((item) => item.status === 'error') &&
|
||||
(selectedAgentImageSupport.value !== false ||
|
||||
composer.images.readyItems.value.length === 0) &&
|
||||
Boolean(selectedAgentId.value) &&
|
||||
!sending.value &&
|
||||
!runtimeRunning.value,
|
||||
@@ -217,6 +241,25 @@ async function syncSessionRoute(sessionId?: string) {
|
||||
await router.replace({ query: nextQuery });
|
||||
}
|
||||
|
||||
async function removeSessionFromPage(sessionId: string) {
|
||||
sessions.value = sessions.value.filter(
|
||||
(item) => String(item.sessionId) !== sessionId,
|
||||
);
|
||||
const isCurrentSession =
|
||||
currentSessionId.value === sessionId ||
|
||||
String(route.query.sessionId || '') === sessionId;
|
||||
if (isCurrentSession) {
|
||||
try {
|
||||
await composer.clear();
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '会话草稿清理失败',
|
||||
);
|
||||
}
|
||||
await createNewSession();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
loadingAgents.value = true;
|
||||
agentLoadError.value = '';
|
||||
@@ -381,10 +424,10 @@ function buildOptimisticSession(
|
||||
assistantName: selectedAgent.value?.name,
|
||||
continuable: true,
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
lastMessagePreview: prompt,
|
||||
lastMessagePreview: prompt || '发送了图片',
|
||||
messageCount: 1,
|
||||
sessionId,
|
||||
title: prompt.slice(0, 48) || '新对话',
|
||||
title: prompt.slice(0, 48) || '图片对话',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,10 +516,17 @@ async function loadConversation(sessionId: string) {
|
||||
}
|
||||
}
|
||||
currentSessionId.value = sessionId;
|
||||
if (selectedAgentId.value) {
|
||||
await activateComposer(selectedAgentId.value, sessionId);
|
||||
}
|
||||
sending.value = false;
|
||||
await syncSessionRoute(sessionId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '会话加载失败');
|
||||
if (isMissingAgentSessionError(error)) {
|
||||
await removeSessionFromPage(sessionId);
|
||||
return;
|
||||
}
|
||||
ElMessage.error(resolveAgentSessionErrorMessage(error) || '会话加载失败');
|
||||
} finally {
|
||||
loadingConversation.value = false;
|
||||
}
|
||||
@@ -485,9 +535,17 @@ async function loadConversation(sessionId: string) {
|
||||
async function createNewSession() {
|
||||
currentSessionId.value = '';
|
||||
timelineItems.value = [];
|
||||
promptText.value = '';
|
||||
extraKnowledgeIds.value = [];
|
||||
sending.value = false;
|
||||
if (selectedAgentId.value) {
|
||||
try {
|
||||
await composer.startNew(selectedAgentId.value);
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '新会话创建失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
await syncSessionRoute();
|
||||
}
|
||||
|
||||
@@ -508,10 +566,18 @@ async function bindCreatedSession(sessionId: string, prompt: string) {
|
||||
await syncSessionRoute(sessionId);
|
||||
}
|
||||
|
||||
function handleAgentChange() {
|
||||
async function handleAgentChange() {
|
||||
extraKnowledgeIds.value = [];
|
||||
if (timelineItems.value.length > 0 || currentSessionId.value) {
|
||||
void createNewSession();
|
||||
await createNewSession();
|
||||
} else {
|
||||
await activateComposer(selectedAgentId.value);
|
||||
}
|
||||
if (
|
||||
selectedAgentImageSupport.value === false &&
|
||||
composer.images.items.value.length > 0
|
||||
) {
|
||||
ElMessage.warning('当前智能体不支持图片,请先移除图片');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,14 +639,29 @@ function buildCapabilities() {
|
||||
|
||||
async function sendContent(rawContent: string) {
|
||||
const content = rawContent.trim();
|
||||
if (!content || !selectedAgentId.value || sending.value) {
|
||||
if (
|
||||
(!content && composer.images.readyItems.value.length === 0) ||
|
||||
!selectedAgentId.value ||
|
||||
sending.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (runtimeRunning.value) {
|
||||
ElMessage.warning('当前回复完成后再发送新消息');
|
||||
return;
|
||||
}
|
||||
promptText.value = '';
|
||||
if (composer.images.uploading.value) {
|
||||
ElMessage.warning('图片上传完成后再发送');
|
||||
return;
|
||||
}
|
||||
const failedImage = composer.images.items.value.find(
|
||||
(item) => item.status === 'error',
|
||||
);
|
||||
if (failedImage) {
|
||||
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||
return;
|
||||
}
|
||||
await composer.flush();
|
||||
sending.value = true;
|
||||
try {
|
||||
const sessionId = await agentChatRuntimeManager.start({
|
||||
@@ -588,14 +669,19 @@ async function sendContent(rawContent: string) {
|
||||
agentName: selectedAgent.value?.name,
|
||||
baseItems: timelineItems.value,
|
||||
capabilities: buildCapabilities(),
|
||||
imageUploadIds: composer.images.uploadIds.value,
|
||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||
onInputAccepted: () =>
|
||||
composer.markAccepted().catch(() => {
|
||||
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||
}),
|
||||
prompt: content,
|
||||
sessionId: currentSessionId.value,
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
await bindCreatedSession(sessionId, content);
|
||||
syncRuntimeSnapshot(sessionId);
|
||||
} catch (error) {
|
||||
sending.value = false;
|
||||
promptText.value = content;
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : '发送失败,请稍后再试',
|
||||
);
|
||||
@@ -612,6 +698,112 @@ function handleSuggestedQuestion(question: string) {
|
||||
|
||||
function handlePromptInput() {
|
||||
chatInputTrigger.sync();
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
async function activateComposer(agentId: string, sessionId?: string) {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await composer.activate(agentId, sessionId);
|
||||
} catch (error) {
|
||||
ElMessage.warning(
|
||||
error instanceof Error ? error.message : '输入草稿恢复失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImageFiles() {
|
||||
imageFileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function addImageFiles(files: File[]) {
|
||||
if (!selectedAgentId.value) {
|
||||
ElMessage.warning('请先选择智能体');
|
||||
return;
|
||||
}
|
||||
if (selectedAgentImageSupport.value === false) {
|
||||
ElMessage.warning('当前智能体不支持图片');
|
||||
return;
|
||||
}
|
||||
await composer.ensureSession();
|
||||
const rejected = await composer.images.addFiles(files, {
|
||||
agentId: selectedAgentId.value,
|
||||
mode: 'FORMAL',
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
composer.scheduleSave();
|
||||
if (rejected > 0) {
|
||||
ElMessage.warning('每次最多添加 5 张图片');
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = [...(target.files || [])];
|
||||
if (files.length) void addImageFiles(files);
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function handleImagePaste(event: ClipboardEvent) {
|
||||
const files = [...(event.clipboardData?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
void addImageFiles(files);
|
||||
}
|
||||
|
||||
function handleImageDragEnter(event: DragEvent) {
|
||||
if (
|
||||
selectedAgentImageSupport.value !== false &&
|
||||
!capabilityDisabled.value &&
|
||||
composer.images.items.value.length < 5 &&
|
||||
[...(event.dataTransfer?.types || [])].includes('Files')
|
||||
) {
|
||||
composerDragActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageDragLeave(event: DragEvent) {
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!container.contains(event.relatedTarget)
|
||||
) {
|
||||
composerDragActive.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageDrop(event: DragEvent) {
|
||||
composerDragActive.value = false;
|
||||
if (
|
||||
selectedAgentImageSupport.value === false ||
|
||||
capabilityDisabled.value ||
|
||||
composer.images.items.value.length >= 5
|
||||
)
|
||||
return;
|
||||
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
if (files.length) void addImageFiles(files);
|
||||
}
|
||||
|
||||
async function retryImage(item: any) {
|
||||
await composer.images.retry(item, {
|
||||
agentId: selectedAgentId.value,
|
||||
mode: 'FORMAL',
|
||||
sessionId: composer.sessionId.value,
|
||||
});
|
||||
composer.scheduleSave();
|
||||
}
|
||||
|
||||
async function removeImage(item: any) {
|
||||
try {
|
||||
await composer.images.remove(item);
|
||||
composer.scheduleSave();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePromptKeyup() {
|
||||
@@ -731,16 +923,15 @@ async function handleDeleteSession(session: AgentChatSessionView) {
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || '删除失败');
|
||||
}
|
||||
sessions.value = sessions.value.filter(
|
||||
(item) => String(item.sessionId) !== sessionId,
|
||||
);
|
||||
if (currentSessionId.value === sessionId) {
|
||||
await createNewSession();
|
||||
}
|
||||
await removeSessionFromPage(sessionId);
|
||||
ElMessage.success('已删除');
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败');
|
||||
if (isMissingAgentSessionError(error)) {
|
||||
await removeSessionFromPage(sessionId);
|
||||
return;
|
||||
}
|
||||
ElMessage.error(resolveAgentSessionErrorMessage(error) || '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,7 +992,10 @@ async function bootstrap() {
|
||||
const latestSnapshot = agentChatRuntimeManager.getLatestSnapshot();
|
||||
if (latestSnapshot?.items.length) {
|
||||
syncRuntimeSnapshot(latestSnapshot.sessionId);
|
||||
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
|
||||
return;
|
||||
}
|
||||
await activateComposer(selectedAgentId.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -936,6 +1130,7 @@ onBeforeUnmount(() => {
|
||||
<ChatTimeline
|
||||
v-else
|
||||
:items="timelineItems"
|
||||
:image-loader="loadAgentChatImage"
|
||||
empty-text="选择智能体后开始对话"
|
||||
:approval-loading="Boolean(approvalLoadingKey)"
|
||||
:copyable="canCopyMessage"
|
||||
@@ -947,7 +1142,14 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__composer">
|
||||
<div
|
||||
class="agent-chat__composer"
|
||||
:class="{ 'is-dragging': composerDragActive }"
|
||||
@dragenter.prevent="handleImageDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleImageDragLeave"
|
||||
@drop.prevent="handleImageDrop"
|
||||
>
|
||||
<ChatCapabilityMenu
|
||||
:disabled="capabilityDisabled"
|
||||
:extra-knowledge-ids="extraKnowledgeIds"
|
||||
@@ -967,6 +1169,15 @@ onBeforeUnmount(() => {
|
||||
@select="handleTriggerSelect"
|
||||
@set-active="chatInputTrigger.setActiveIndex"
|
||||
/>
|
||||
<ChatImageAttachments
|
||||
v-if="composer.images.items.value.length"
|
||||
:items="composer.images.items.value"
|
||||
:image-loader="loadAgentChatImage"
|
||||
removable
|
||||
retryable
|
||||
@remove="removeImage"
|
||||
@retry="retryImage"
|
||||
/>
|
||||
<ElInput
|
||||
ref="promptInputRef"
|
||||
v-model="promptText"
|
||||
@@ -980,9 +1191,31 @@ onBeforeUnmount(() => {
|
||||
@input="handlePromptInput"
|
||||
@keydown="handlePromptKeydown"
|
||||
@keyup="handlePromptKeyup"
|
||||
@paste="handleImagePaste"
|
||||
/>
|
||||
<div class="agent-chat__composer-footer">
|
||||
<div class="agent-chat__composer-tools">
|
||||
<template v-if="selectedAgentImageSupport !== false">
|
||||
<input
|
||||
ref="imageFileInputRef"
|
||||
class="agent-chat__image-file-input"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp"
|
||||
multiple
|
||||
@change="handleImageFiles"
|
||||
/>
|
||||
<ElButton
|
||||
:icon="Paperclip"
|
||||
circle
|
||||
text
|
||||
:disabled="
|
||||
capabilityDisabled || composer.images.items.value.length >= 5
|
||||
"
|
||||
aria-label="添加图片"
|
||||
title="添加图片"
|
||||
@click="chooseImageFiles"
|
||||
/>
|
||||
</template>
|
||||
<ChatCapabilityMenu
|
||||
class="agent-chat__capability-entry"
|
||||
:disabled="capabilityDisabled"
|
||||
@@ -1177,12 +1410,11 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 176px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap.is-welcome {
|
||||
padding: 0 min(8vw, 96px) 190px;
|
||||
padding: 0 min(8vw, 96px);
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
@@ -1195,20 +1427,24 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.agent-chat__composer {
|
||||
position: absolute;
|
||||
right: min(8vw, 96px);
|
||||
bottom: 24px;
|
||||
left: min(8vw, 96px);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
margin: 0 min(8vw, 96px) 24px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.agent-chat__composer.is-dragging {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.agent-chat__trigger-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
@@ -1241,6 +1477,10 @@ onBeforeUnmount(() => {
|
||||
max-width: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.agent-chat__image-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-chat__capability-entry {
|
||||
flex: none;
|
||||
}
|
||||
@@ -1343,7 +1583,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap {
|
||||
padding-bottom: 184px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap.is-welcome {
|
||||
@@ -1353,7 +1593,7 @@ onBeforeUnmount(() => {
|
||||
.agent-chat__timeline-wrap.is-welcome :deep(.agent-welcome) {
|
||||
flex: 0 0 auto;
|
||||
min-height: 100%;
|
||||
padding-bottom: 206px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
||||
@@ -1361,9 +1601,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.agent-chat__composer {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
margin: 0 16px 16px;
|
||||
}
|
||||
|
||||
.agent-chat__composer-footer {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isMissingAgentSessionError,
|
||||
resolveAgentSessionErrorMessage,
|
||||
} from './sessionRecovery';
|
||||
|
||||
describe('agent chat session recovery', () => {
|
||||
it('识别请求层直接抛出的业务响应', () => {
|
||||
const error = {
|
||||
errorCode: 400,
|
||||
message: 'Agent 会话不存在',
|
||||
};
|
||||
|
||||
expect(isMissingAgentSessionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('识别包含响应体的网络错误', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
message: 'Agent 会话不存在',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(isMissingAgentSessionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('保留其他错误消息供页面展示', () => {
|
||||
const error = new Error('服务暂时不可用');
|
||||
|
||||
expect(isMissingAgentSessionError(error)).toBe(false);
|
||||
expect(resolveAgentSessionErrorMessage(error)).toBe('服务暂时不可用');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const MISSING_AGENT_SESSION_MESSAGE = 'Agent 会话不存在';
|
||||
|
||||
/**
|
||||
* 提取请求层抛出的错误消息。
|
||||
*/
|
||||
export function resolveAgentSessionErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (!error || typeof error !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const payload = error as Record<string, unknown>;
|
||||
if (typeof payload.message === 'string') {
|
||||
return payload.message;
|
||||
}
|
||||
const response = payload.response;
|
||||
if (!response || typeof response !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const data = (response as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const message = (data as Record<string, unknown>).message;
|
||||
return typeof message === 'string' ? message : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求是否因 Agent 会话已不存在而失败。
|
||||
*/
|
||||
export function isMissingAgentSessionError(error: unknown): boolean {
|
||||
return resolveAgentSessionErrorMessage(error).includes(
|
||||
MISSING_AGENT_SESSION_MESSAGE,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user