516 lines
13 KiB
TypeScript
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);
|
|
};
|
|
},
|
|
};
|