import type { ChatTimelineItem, ChatTimelineKnowledgeHit, ChatTimelineMessageItem, ChatTimelineToolStatus, } from '@easyflow/common-ui'; import {ChatTimelineBuilder} from '@easyflow/common-ui'; interface AgentTryoutRuntimeEvent { createdAt: number; domain: string; payload: Record; type: string; } type AgentTryoutRoundStatus = 'completed' | 'error' | 'running'; interface AgentTryoutRawVariant { createdAt: number; runtimeEvents: AgentTryoutRuntimeEvent[]; status: AgentTryoutRoundStatus; updatedAt: number; variantIndex: number; } interface AgentTryoutRawRound { createdAt: number; 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(); 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 { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } 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(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); if (!prompt || !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()), 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) { 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, 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): 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) { 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(); let persistTimer: ReturnType | 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) { const now = Date.now(); const roundId = createRoundId(); rounds.set(roundId, { createdAt: now, 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; 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}`, 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, };