feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理 - 支持输入草稿恢复、图片交互和模型图片能力约束 - 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user