Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts
陈子默 b08eb009bb perf: 优化 Agent 流式会话处理
- 合并流式通知与持久化写入,限制浏览器会话缓存

- 增量投影轮次事件并稳定关联异步工具任务
2026-07-27 19:41:11 +08:00

516 lines
13 KiB
TypeScript

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<void>;
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<string, RuntimeSessionState>();
const listeners = new Set<() => void>();
const latestSessionIds = new Map<string, string>();
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
function clone<T>(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<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) {
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);
};
},
};