Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts
陈子默 1e6158be77 feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理

- 支持输入草稿恢复、图片交互和模型图片能力约束

- 修复旧脏会话幂等删除与前端会话恢复
2026-07-17 19:54:26 +08:00

864 lines
24 KiB
TypeScript

import type {
ChatImageAttachment,
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import { ChatTimelineBuilder } from '@easyflow/common-ui';
interface AgentTryoutRuntimeEvent {
createdAt: number;
domain: string;
payload: Record<string, unknown>;
type: string;
}
type AgentTryoutRoundStatus = 'completed' | 'error' | 'running';
interface AgentTryoutRawVariant {
createdAt: number;
runtimeEvents: AgentTryoutRuntimeEvent[];
status: AgentTryoutRoundStatus;
updatedAt: number;
variantIndex: number;
}
interface AgentTryoutRawRound {
createdAt: number;
images?: ChatImageAttachment[];
prompt: string;
roundId: string;
selectedVariantIndex: number;
status: AgentTryoutRoundStatus;
updatedAt: number;
variants: AgentTryoutRawVariant[];
}
interface AgentTryoutRawSessionRecord {
rounds: AgentTryoutRawRound[];
sessionId: string;
version: number;
}
const STORAGE_VERSION = 1;
const MAX_ROUNDS = 50;
const MAX_VARIANTS = 10;
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
const PERSIST_DEBOUNCE_MS = 300;
const memorySessions = new Map<string, AgentTryoutRawRound[]>();
function createRoundId() {
return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function asText(value: unknown) {
return value === null || value === undefined ? '' : String(value);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asBoolean(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return Boolean(value);
}
function normalizeToolName(value: unknown) {
return asText(value).trim().toLowerCase();
}
function isHiddenToolName(value: unknown) {
const normalizedName = normalizeToolName(value);
return (
normalizedName === 'retrieve_knowledge' ||
normalizedName === 'context_reload' ||
normalizedName === '__fragment__'
);
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function storageKey(mode: string, sessionId: string) {
return `${STORAGE_PREFIX}:${mode}:${sessionId}`;
}
function safeSessionStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
function createVariant(variantIndex: number): AgentTryoutRawVariant {
const now = Date.now();
return {
createdAt: now,
runtimeEvents: [],
status: 'running',
updatedAt: now,
variantIndex,
};
}
function normalizeRuntimeEvent(
value: any,
): AgentTryoutRuntimeEvent | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const domain = asText(value.domain).toUpperCase();
const type = asText(value.type).toUpperCase();
if (!domain || !type) {
return undefined;
}
return {
createdAt: Number(value.createdAt || Date.now()),
domain,
payload: asRecord(value.payload),
type,
};
}
function normalizeVariant(value: any, index: number) {
if (!value || typeof value !== 'object') {
return createVariant(index);
}
const runtimeEvents = Array.isArray(value.runtimeEvents)
? value.runtimeEvents
.map((item: any) => normalizeRuntimeEvent(item))
.filter(
(
item: AgentTryoutRuntimeEvent | undefined,
): item is AgentTryoutRuntimeEvent => Boolean(item),
)
: [];
return {
createdAt: Number(value.createdAt || Date.now()),
runtimeEvents,
status:
value.status === 'completed' || value.status === 'error'
? value.status
: 'running',
updatedAt: Number(value.updatedAt || value.createdAt || Date.now()),
variantIndex: index,
};
}
function normalizeRound(value: any): AgentTryoutRawRound | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const prompt = asText(value.prompt);
const roundId = asText(value.roundId);
const images = Array.isArray(value.images) ? value.images.slice(0, 5) : [];
if ((!prompt && images.length === 0) || !roundId) {
return undefined;
}
const variants = Array.isArray(value.variants)
? value.variants
.slice(-MAX_VARIANTS)
.map((item: any, index: number) => normalizeVariant(item, index + 1))
: [];
if (variants.length === 0) {
variants.push(createVariant(1));
}
const selectedVariantIndex = Math.min(
Math.max(Number(value.selectedVariantIndex || variants.length), 1),
variants.length,
);
return {
createdAt: Number(value.createdAt || Date.now()),
images,
prompt,
roundId,
selectedVariantIndex,
status:
value.status === 'completed' || value.status === 'error'
? value.status
: 'running',
updatedAt: Number(value.updatedAt || value.createdAt || Date.now()),
variants,
};
}
function restoreSession(mode: string, sessionId: string) {
const key = storageKey(mode, sessionId);
const memoryRecords = memorySessions.get(key);
if (memoryRecords) {
return memoryRecords.map((item) => clone(item));
}
const storage = safeSessionStorage();
if (!storage) {
return [];
}
try {
const raw = storage.getItem(key);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as AgentTryoutRawSessionRecord;
if (parsed.sessionId !== sessionId || parsed.version !== STORAGE_VERSION) {
return [];
}
const rounds = Array.isArray(parsed.rounds)
? parsed.rounds
.map((item) => normalizeRound(item))
.filter((item): item is AgentTryoutRawRound => Boolean(item))
: [];
memorySessions.set(
key,
rounds.map((item) => clone(item)),
);
return rounds;
} catch {
return [];
}
}
function persistSession(
mode: string,
sessionId: string,
rounds: AgentTryoutRawRound[],
) {
const key = storageKey(mode, sessionId);
const snapshot: AgentTryoutRawSessionRecord = {
rounds: rounds.slice(-MAX_ROUNDS).map((item) => clone(item)),
sessionId,
version: STORAGE_VERSION,
};
memorySessions.set(
key,
snapshot.rounds.map((item) => clone(item)),
);
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.setItem(key, JSON.stringify(snapshot));
} catch {
// 试运行缓存失败不影响当前聊天主流程。
}
}
function removeStoredSession(mode: string, sessionId: string) {
const key = storageKey(mode, sessionId);
memorySessions.delete(key);
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(key);
} catch {
// 清理缓存失败不影响界面重置。
}
}
function selectedVariant(round: AgentTryoutRawRound) {
return (
round.variants.find(
(variant) => variant.variantIndex === round.selectedVariantIndex,
) || round.variants[round.variants.length - 1]
);
}
function visibleText(item: ChatTimelineMessageItem) {
return item.parts
.filter((part) => part.type === 'text')
.map((part) => part.content)
.join('');
}
function isUserMessage(
item: ChatTimelineItem,
): item is ChatTimelineMessageItem {
return item.type === 'message' && item.role === 'user';
}
function isAssistantMessage(
item: ChatTimelineItem,
): item is ChatTimelineMessageItem {
return item.type === 'message' && item.role === 'assistant';
}
function findRoundResponseRange(items: ChatTimelineItem[], roundId: string) {
const userIndex = items.findIndex(
(item) => isUserMessage(item) && item.roundId === roundId,
);
if (userIndex < 0) {
return undefined;
}
const nextUserIndex = items.findIndex(
(item, index) => index > userIndex && isUserMessage(item),
);
return {
end: nextUserIndex >= 0 ? nextUserIndex : items.length,
start: userIndex + 1,
};
}
function assistantSegmentIndex(items: ChatTimelineItem[], roundId: string) {
return items.filter(
(item) => isAssistantMessage(item) && item.roundId === roundId,
).length;
}
function nextAssistantId(
items: ChatTimelineItem[],
roundId: string,
variantIndex: number,
) {
const last = items[items.length - 1];
if (
last &&
isAssistantMessage(last) &&
last.roundId === roundId &&
last.status !== 'done'
) {
return last.id;
}
return `assistant-${roundId}-${variantIndex}-${assistantSegmentIndex(items, roundId) + 1}`;
}
function normalizeAssistantPartIds(
items: ChatTimelineItem[],
roundId: string,
variantIndex: number,
) {
const segment = assistantSegmentIndex(items, roundId);
const latest = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
isAssistantMessage(item) && item.roundId === roundId,
);
if (!latest) {
return;
}
latest.id = `assistant-${roundId}-${variantIndex}-${segment}`;
latest.parts.forEach((part, index) => {
part.id = `${part.type}-${roundId}-${variantIndex}-${segment}-${index + 1}`;
});
}
function normalizeLatestItemId(
items: ChatTimelineItem[],
prefix: string,
roundId: string,
variantIndex: number,
) {
const last = items[items.length - 1];
if (!last) {
return;
}
last.id = `${prefix}-${roundId}-${variantIndex}`;
}
function markRoundCompleted(
items: ChatTimelineItem[],
roundId: string,
variant: AgentTryoutRawVariant,
variantCount: number,
selectedVariantIndex: number,
) {
const range = findRoundResponseRange(items, roundId);
const source = range ? items.slice(range.start, range.end) : items;
const latest = [...source]
.reverse()
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item));
if (!latest) {
return;
}
latest.roundCompleted = true;
latest.status = latest.status === 'error' ? 'error' : 'done';
latest.regenerable = true;
latest.switchable = variantCount > 1;
latest.variantCount = variantCount;
latest.variantIndex = variant.variantIndex;
latest.selectedVariantIndex = selectedVariantIndex;
}
function normalizeKnowledgeItems(payload: Record<string, unknown>) {
const source =
payload.items ||
payload.hits ||
payload.documents ||
payload.knowledgeResults ||
[];
if (!Array.isArray(source)) {
return [];
}
const topLevelKnowledgeType = asText(payload.knowledgeType);
const topLevelFaqCollection =
payload.faqCollection === undefined
? topLevelKnowledgeType.toUpperCase() === 'FAQ'
: asBoolean(payload.faqCollection);
return source.map((item: any) => {
const metadata = asRecord(item.metadata);
const sourceFileName = asText(
item.sourceFileName ?? metadata.sourceFileName,
);
const documentName = asText(
item.documentName ?? item.documentTitle ?? item.title,
);
const chunkId = asText(item.chunkId ?? metadata.chunkId ?? item.id);
const documentId = asText(
item.documentId ?? metadata.documentId ?? item.id,
);
return {
...item,
id: asText(item.id || chunkId || documentId),
knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId),
knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName),
knowledgeType: asText(item.knowledgeType ?? payload.knowledgeType),
faqCollection:
item.faqCollection === undefined
? topLevelFaqCollection
: asBoolean(item.faqCollection),
documentId,
documentName,
chunkId,
score: item.score ?? item.similarity,
source: item.source,
sourceFileName,
sourceUri: asText(item.sourceUri ?? metadata.sourceUri),
metadata,
chunkContent: asText(
item.chunkContent ?? item.content ?? item.text ?? item.summary,
),
content: asText(item.content ?? item.text ?? item.summary),
title: documentName || sourceFileName || item.source,
} satisfies ChatTimelineKnowledgeHit;
});
}
function statusKeyForProjection(
payload: Record<string, unknown>,
roundId: string,
variantIndex: number,
fallback = 'status',
) {
const statusKey = asText(payload.statusKey) || fallback;
return `${statusKey}:${roundId}:${variantIndex}`;
}
function projectEventToTimeline(
items: ChatTimelineItem[],
event: AgentTryoutRuntimeEvent,
roundId: string,
variantIndex: number,
) {
const { domain, payload, type } = event;
if (domain === 'LLM' && type === 'MESSAGE') {
ChatTimelineBuilder.appendMessageDelta(items, payload.delta, {
id: nextAssistantId(items, roundId, variantIndex),
roundId,
});
normalizeAssistantPartIds(items, roundId, variantIndex);
return;
}
if (domain === 'LLM' && type === 'THINKING') {
const text = asText(payload.reasoning ?? payload.delta ?? payload.text);
ChatTimelineBuilder.appendThinkingDelta(items, text, {
id: nextAssistantId(items, roundId, variantIndex),
roundId,
});
normalizeAssistantPartIds(items, roundId, variantIndex);
return;
}
if (domain === 'TOOL' && type === 'FORM_REQUEST') {
ChatTimelineBuilder.appendToolApproval(items, {
expiresAt: asText(payload.expiresAt),
input: payload.input,
metadata: payload.metadata,
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
),
toolDisplayName: asText(payload.toolDisplayName),
toolName: asText(payload.toolName),
toolType: asText(payload.toolType),
});
if (items[items.length - 1]?.type === 'tool') {
normalizeLatestItemId(items, 'tool-approval', roundId, variantIndex);
}
return;
}
if (domain === 'TOOL' && type === 'FORM_APPROVING') {
ChatTimelineBuilder.markToolApproving(items, {
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
),
});
return;
}
if (domain === 'TOOL' && type === 'FORM_REJECTED') {
ChatTimelineBuilder.markToolRejected(items, {
reason: asText(payload.reason),
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
),
});
return;
}
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
const rawToolName = asText(payload.toolName ?? payload.name);
const normalizedToolName = normalizeToolName(rawToolName);
if (!normalizedToolName && type === 'TOOL_CALL') {
return;
}
const displayToolName = asText(
payload.toolDisplayName ?? rawToolName ?? '工具',
);
const asyncTool = payload.asyncTool === true;
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),
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'
? 'success'
: 'running',
statusKey: statusKeyForProjection(
payload,
roundId,
variantIndex,
'knowledge-retrieval',
),
toolCallId: asText(
payload.toolCallId ??
payload.taskId ??
payload.tool_call_id ??
payload.id,
),
toolName: asyncTool
? displayToolName
: isHiddenToolName(rawToolName)
? rawToolName
: displayToolName,
});
return;
}
if (domain === 'BUSINESS' && type === 'CITATIONS') {
const itemsToAppend = normalizeKnowledgeItems(payload);
if (itemsToAppend.length > 0) {
ChatTimelineBuilder.appendKnowledge(items, itemsToAppend);
if (items[items.length - 1]?.type === 'knowledge') {
normalizeLatestItemId(items, 'knowledge', roundId, variantIndex);
}
}
return;
}
if (domain === 'BUSINESS' && type === 'STATUS') {
if (asText(payload.statusKey) === 'memory-compression') {
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
compressed:
typeof payload.compressed === 'boolean'
? payload.compressed
: undefined,
label: asText(payload.label),
phase: asText(payload.phase),
status: asText(payload.status),
statusKey: statusKeyForProjection(payload, roundId, variantIndex),
});
return;
}
if (asText(payload.statusKey) === 'knowledge-retrieval') {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
asText(payload.status) === 'running' ? 'running' : 'done',
statusKeyForProjection(payload, roundId, variantIndex),
);
}
return;
}
if (type === 'ERROR' || domain === 'ERROR') {
ChatTimelineBuilder.appendError(
items,
payload.message ?? payload.error ?? '试运行失败',
);
normalizeLatestItemId(items, 'error', roundId, variantIndex);
}
}
function asyncToolTimelineStatus(
payload: Record<string, unknown>,
): ChatTimelineToolStatus {
const status = asText(payload.status).toUpperCase();
if (status === 'SUCCEEDED') return 'success';
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
return 'error';
}
return 'running';
}
function sortedRounds(rounds: Map<string, AgentTryoutRawRound>) {
return [...rounds.values()].sort(
(first, second) =>
(first.createdAt || first.updatedAt) -
(second.createdAt || second.updatedAt),
);
}
export function useAgentTryoutRawRounds(options: {
mode: 'draft';
sessionId: string;
}) {
const rounds = new Map<string, AgentTryoutRawRound>();
let persistTimer: ReturnType<typeof setTimeout> | undefined;
for (const round of restoreSession(options.mode, options.sessionId)) {
rounds.set(round.roundId, round);
}
function persistNow() {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
const overflow = rounds.size - MAX_ROUNDS;
if (overflow > 0) {
for (const round of sortedRounds(rounds).slice(0, overflow)) {
rounds.delete(round.roundId);
}
}
persistSession(options.mode, options.sessionId, [...rounds.values()]);
}
function schedulePersist() {
const key = storageKey(options.mode, options.sessionId);
memorySessions.set(
key,
[...rounds.values()].map((item) => clone(item)),
);
if (persistTimer) {
return;
}
persistTimer = setTimeout(() => {
persistNow();
}, PERSIST_DEBOUNCE_MS);
}
function clear() {
rounds.clear();
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
removeStoredSession(options.mode, options.sessionId);
}
function createRound(prompt: string, images: ChatImageAttachment[] = []) {
const now = Date.now();
const roundId = createRoundId();
rounds.set(roundId, {
createdAt: now,
images: clone(images),
prompt,
roundId,
selectedVariantIndex: 1,
status: 'running',
updatedAt: now,
variants: [createVariant(1)],
});
persistNow();
return roundId;
}
function regenerateRound(roundId: string) {
const round = rounds.get(roundId);
if (!round) {
return undefined;
}
const nextVariantIndex = Math.min(round.variants.length + 1, MAX_VARIANTS);
round.variants.push(createVariant(round.variants.length + 1));
if (round.variants.length > MAX_VARIANTS) {
round.variants.splice(0, round.variants.length - MAX_VARIANTS);
round.variants.forEach((variant, index) => {
variant.variantIndex = index + 1;
});
}
round.selectedVariantIndex = nextVariantIndex;
round.status = 'running';
round.updatedAt = Date.now();
persistNow();
return round.roundId;
}
function getPrompt(roundId?: string) {
return roundId ? rounds.get(roundId)?.prompt || '' : '';
}
function currentVariant(roundId: string) {
const round = rounds.get(roundId);
return round ? selectedVariant(round) : undefined;
}
function recordEvent(
roundId: string,
event: {
domain: string;
payload?: Record<string, unknown>;
type: string;
},
) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) {
return;
}
const runtimeEvent: AgentTryoutRuntimeEvent = {
createdAt: Date.now(),
domain: event.domain.toUpperCase(),
payload: event.payload || {},
type: event.type.toUpperCase(),
};
variant.runtimeEvents.push(runtimeEvent);
if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') {
variant.status = 'completed';
round.status = 'completed';
}
if (runtimeEvent.type === 'ERROR' || runtimeEvent.domain === 'ERROR') {
variant.status = 'error';
round.status = 'error';
}
variant.updatedAt = Date.now();
round.updatedAt = variant.updatedAt;
if (
(runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') ||
runtimeEvent.type === 'ERROR' ||
runtimeEvent.domain === 'ERROR'
) {
persistNow();
return;
}
schedulePersist();
}
function completeRound(roundId: string) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) {
return;
}
if (variant.status === 'error' || round.status === 'error') {
persistNow();
return;
}
variant.status = 'completed';
round.status = 'completed';
variant.updatedAt = Date.now();
round.updatedAt = variant.updatedAt;
persistNow();
}
function buildTimelineItems() {
const items: ChatTimelineItem[] = [];
for (const round of sortedRounds(rounds)) {
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
id: `user-${round.roundId}`,
images: round.images,
roundId: round.roundId,
});
const variant = selectedVariant(round);
if (!variant) {
continue;
}
for (const event of variant.runtimeEvents) {
projectEventToTimeline(
items,
event,
round.roundId,
variant.variantIndex,
);
}
if (variant.status === 'completed' || variant.status === 'error') {
ChatTimelineBuilder.finalize(items);
markRoundCompleted(
items,
round.roundId,
variant,
round.variants.length,
round.selectedVariantIndex,
);
}
}
return items;
}
function selectVariant(roundId: string, direction: 'next' | 'previous') {
const round = rounds.get(roundId);
if (!round) {
return;
}
const next =
direction === 'previous'
? round.selectedVariantIndex - 1
: round.selectedVariantIndex + 1;
if (next < 1 || next > round.variants.length) {
return;
}
round.selectedVariantIndex = next;
round.updatedAt = Date.now();
persistNow();
}
function canSwitch(
item: ChatTimelineMessageItem,
direction: 'next' | 'previous',
) {
const current = Number(item.selectedVariantIndex || item.variantIndex || 1);
const total = Number(item.variantCount || 1);
return direction === 'previous' ? current > 1 : current < total;
}
function copyText(item: ChatTimelineMessageItem) {
return visibleText(item);
}
return {
buildTimelineItems,
canSwitch,
clear,
completeRound,
copyText,
createRound,
currentVariant,
getPrompt,
recordEvent,
regenerateRound,
selectVariant,
flush: persistNow,
};
}
export type {
AgentTryoutRawRound,
AgentTryoutRawVariant,
AgentTryoutRuntimeEvent,
};