864 lines
24 KiB
TypeScript
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,
|
|
};
|