import type { ChatImageAttachment, ChatTimelineItem, ChatTimelineMessageItem, } from '@easyflow/common-ui'; import { ChatTimelineBuilder } from '@easyflow/common-ui'; import { useUserStore } from '@easyflow/stores'; import { onAgentChatCacheClear, resolveAgentChatIdentity, RUNTIME_STORAGE_PREFIX, } from '#/utils/agent-chat-cache'; 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; sending: boolean; sessionId: string; updatedAt: number; } interface StoredRuntimeSession { agentId: string; agentName?: string; completed: boolean; error?: string; items: ChatTimelineItem[]; prompt: string; roundId: string; sessionId: string; updatedAt: number; version: number; } interface StartOptions { agentId: string; agentName?: string; baseItems?: ChatTimelineItem[]; capabilities?: AgentChatCapabilityPayload[]; imageUploadIds?: string[]; images?: ChatImageAttachment[]; onInputAccepted?: () => void | Promise; prompt: string; sessionId?: string; } const STORAGE_VERSION = 2; const STREAM_NOTIFY_INTERVAL_MS = 50; const STREAM_PERSIST_INTERVAL_MS = 300; const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10; const sessions = new Map(); const listeners = new Set<() => void>(); const latestSessionIds = new Map(); const persistTimers = new Map>(); let notifyTimer: ReturnType | undefined; function clone(value: T): T { const serialized = JSON.stringify(value); return JSON.parse(serialized) as T; } function createRoundId() { return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } 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() { try { return globalThis.sessionStorage; } catch { return undefined; } } function notify() { for (const listener of listeners) { listener(); } } function notifyNow() { if (notifyTimer) { clearTimeout(notifyTimer); notifyTimer = undefined; } notify(); } function scheduleNotify() { if (notifyTimer || listeners.size === 0) { return; } notifyTimer = setTimeout(() => { notifyTimer = undefined; notify(); }, STREAM_NOTIFY_INTERVAL_MS); } function persistSession(state: RuntimeSessionState) { const storage = safeSessionStorage(); if (!storage) { return; } const snapshot: StoredRuntimeSession = { agentId: state.agentId, agentName: state.agentName, completed: state.completed, error: state.error, items: state.items, prompt: state.prompt, roundId: state.roundId, sessionId: state.sessionId, updatedAt: state.updatedAt, version: STORAGE_VERSION, }; try { storage.setItem( storageKey(state.identity, state.sessionId), JSON.stringify(snapshot), ); storage.setItem(latestStorageKey(state.identity), state.sessionId); } catch { // 缓存失败不影响正式聊天主流程。 } } function cancelPersistTimer(scopedSessionKey: string) { const timer = persistTimers.get(scopedSessionKey); if (!timer) { return; } clearTimeout(timer); persistTimers.delete(scopedSessionKey); } function removeSessionCache( scopedSessionKey: string, state: RuntimeSessionState, ) { cancelPersistTimer(scopedSessionKey); sessions.delete(scopedSessionKey); try { safeSessionStorage()?.removeItem( storageKey(state.identity, state.sessionId), ); } catch { // 本地缓存清理失败不影响服务端会话。 } } function pruneRuntimeSessions(identity: string, currentSessionId: string) { const scopedSessions = [...sessions.entries()] .filter(([, session]) => session.identity === identity) .sort((first, second) => second[1].updatedAt - first[1].updatedAt); if (scopedSessions.length <= MAX_RUNTIME_SESSIONS_PER_IDENTITY) { return; } const protectedKeys = new Set( scopedSessions .filter( ([, session]) => session.sending || session.sessionId === currentSessionId, ) .map(([key]) => key), ); let retainedCount = protectedKeys.size; for (const [key, session] of scopedSessions) { if (protectedKeys.has(key)) { continue; } if (retainedCount < MAX_RUNTIME_SESSIONS_PER_IDENTITY) { retainedCount += 1; continue; } removeSessionCache(key, session); } } function touchState(state: RuntimeSessionState) { state.updatedAt = Date.now(); latestSessionIds.set(state.identity, state.sessionId); sessions.set(sessionKey(state.identity, state.sessionId), state); } function scheduleStateUpdate(state: RuntimeSessionState) { touchState(state); const scopedSessionKey = sessionKey(state.identity, state.sessionId); if (!persistTimers.has(scopedSessionKey)) { persistTimers.set( scopedSessionKey, setTimeout(() => { persistTimers.delete(scopedSessionKey); const current = sessions.get(scopedSessionKey); if (current) { persistSession(current); } }, STREAM_PERSIST_INTERVAL_MS), ); } scheduleNotify(); } function restoreSession(identity: string, sessionId: string) { if (!identity) { return undefined; } const scopedSessionKey = sessionKey(identity, sessionId); const existing = sessions.get(scopedSessionKey); if (existing) { return existing; } const storage = safeSessionStorage(); if (!storage) { return undefined; } try { const raw = storage.getItem(storageKey(identity, sessionId)); if (!raw) { return undefined; } const parsed = JSON.parse(raw) as StoredRuntimeSession; if (parsed.version !== STORAGE_VERSION || parsed.sessionId !== sessionId) { return undefined; } const restored: RuntimeSessionState = { agentId: parsed.agentId, agentName: parsed.agentName, completed: parsed.completed, error: parsed.error, identity, items: Array.isArray(parsed.items) ? parsed.items : [], prompt: parsed.prompt, roundId: parsed.roundId, sending: false, sessionId, updatedAt: parsed.updatedAt, }; sessions.set(scopedSessionKey, restored); return restored; } catch { return undefined; } } function upsertState(state: RuntimeSessionState) { touchState(state); cancelPersistTimer(sessionKey(state.identity, state.sessionId)); persistSession(state); pruneRuntimeSessions(state.identity, state.sessionId); notifyNow(); } function runningSession(identity = identityScope()) { return [...sessions.values()].find( (session) => session.identity === identity && session.sending, ); } function restoreLatestSession(identity = identityScope()) { if (!identity) { return undefined; } const running = runningSession(identity); if (running) { return running; } const storage = safeSessionStorage(); const storedSessionId = storage?.getItem(latestStorageKey(identity)) || ''; const sessionId = latestSessionIds.get(identity) || storedSessionId; return sessionId ? restoreSession(identity, sessionId) : undefined; } async function resolveSessionId(sessionId?: string) { if (sessionId) { return sessionId; } const res = await generateAgentSessionId(); if (res.errorCode !== 0 || !res.data) { throw new Error(res.message || '会话创建失败'); } return String(res.data); } function errorMessage(error: unknown) { return error instanceof Error ? error.message : '发送失败,请稍后再试'; } function normalizeAcceptedImages(payload: Record) { 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; 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, ) { 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) { cancelPersistTimer(key); sessions.delete(key); } } latestSessionIds.delete(identity); notifyNow(); }); export const agentChatRuntimeManager = { getSnapshot(sessionId?: string) { if (!sessionId) { return undefined; } const state = restoreSession(identityScope(), sessionId); return state ? clone(state) : undefined; }, getLatestSnapshot() { const state = restoreLatestSession(); return state ? clone(state) : undefined; }, hasRunning() { return Boolean(runningSession()); }, replaceItems(sessionId: string, items: ChatTimelineItem[]) { const state = restoreSession(identityScope(), sessionId); if (!state) { return; } state.items = clone(items); upsertState(state); }, async start(options: StartOptions) { const identity = identityScope(); if (!identity) { throw new Error('当前登录状态失效'); } const active = runningSession(identity); if (active) { throw new Error('当前回复完成后再发送新消息'); } const sessionId = await resolveSessionId(options.sessionId); const roundId = createRoundId(); const state: RuntimeSessionState = { agentId: options.agentId, agentName: options.agentName, completed: false, identity, items: clone(options.baseItems || []), prompt: options.prompt, roundId, sending: true, sessionId, updatedAt: Date.now(), }; ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, { images: options.images, roundId, }); upsertState(state); void sendAgentChat( { agentId: options.agentId, capabilities: options.capabilities, imageUploadIds: options.imageUploadIds, prompt: options.prompt, sessionId, }, { onError(error) { const current = sessions.get(sessionKey(identity, sessionId)); if (!current || !current.sending) { return; } current.error = errorMessage(error); current.sending = false; current.completed = true; ChatTimelineBuilder.appendError(current.items, current.error); ChatTimelineBuilder.finalize(current.items); upsertState(current); }, onFinished() { const current = sessions.get(sessionKey(identity, sessionId)); if (!current) { return; } current.sending = false; current.completed = true; ChatTimelineBuilder.finalize(current.items); upsertState(current); }, onMessage(message) { const current = sessions.get(sessionKey(identity, sessionId)); if (!current || !current.sending) { return; } const envelope = parseAgentSseMessage(message); 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 }); scheduleStateUpdate(current); }, }, ); return sessionId; }, stop(sessionId?: string) { const identity = identityScope(); const state = sessionId ? restoreSession(identity, sessionId) : runningSession(identity); if (!state || !state.sending) { return; } stopAgentChatStream(); state.sending = false; state.completed = true; ChatTimelineBuilder.finalize(state.items); upsertState(state); }, subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, };