ai-chatbot/lib/agents/replacement-run-recovery.ts

74 lines
3.4 KiB
TypeScript

type Message = { id: string; role: string; parts?: unknown[] };
export function parseAppendedGenerationMessage(data: unknown): Message | null {
const message = typeof data === "string" ? JSON.parse(data) : data;
if (!message || typeof message !== "object") return null;
const candidate = message as Message;
return candidate.role === "assistant" && typeof candidate.id === "string" && Array.isArray(candidate.parts)
? candidate
: null;
}
export function appendGenerationMessage<T extends Message>(current: T[], appended: T): T[] {
const index = current.findIndex((message) => message.id === appended.id);
if (index < 0) return [...current, appended];
const next = [...current];
next[index] = appended;
return next;
}
export function messageText(message: Message) {
return (message.parts || []).flatMap((part) => {
const value = part as { type?: string; text?: string };
return value?.type === "text" && typeof value.text === "string" ? [value.text] : [];
}).join("\n\n");
}
export function mergeGenerationHistory<T extends Message>(current: T[], persisted: T[]): T[] {
if (!persisted.length) return current;
const localById = new Map(current.map((message) => [message.id, message]));
const persistedIds = new Set(persisted.map((message) => message.id));
const merged = persisted.map((message) => {
const local = localById.get(message.id);
// Polling must not replace a richer live chunk with an older database snapshot.
return local && messageText(local).length > messageText(message).length ? local : message;
}).concat(current.filter((message) => !persistedIds.has(message.id)));
return JSON.stringify(merged) === JSON.stringify(current) ? current : merged;
}
export function recoverReplacementRun(params: {
messages: Message[];
requestMessageId?: string | null;
userTurnCountAtStart: number;
requestedCardIds?: string[] | null;
expectedCount: number;
}) {
const requested = new Set(params.requestedCardIds || []);
const users = params.messages.filter((message) => message.role === "user");
const requestId = params.requestMessageId || users[params.userTurnCountAtStart]?.id;
const requestIndex = params.messages.findIndex((message) => message.id === requestId);
const completed = new Set<string>();
const failed = new Set<string>();
if (requestIndex < 0) return { completedCardIds: [], failedCardIds: [], done: false };
for (const message of params.messages.slice(requestIndex + 1)) {
if (message.role === "user") break;
if (message.role !== "assistant") continue;
const text = messageText(message);
const runId = text.match(/<!--\s*AGENT_REPLACEMENT_RUN_ID:([^>]+?)\s*-->/)?.[1]?.trim();
if (runId && runId !== requestId) continue;
const cardId = text.match(/<!--\s*AGENT_REPLACEMENT_CARD_ID:([^>]+?)\s*-->/)?.[1]?.trim();
if (!cardId || (requested.size > 0 && !requested.has(cardId))) continue;
// A start/partial message does not settle a card. Failure is terminal even with no image.
const failure = text.includes("生成失败") || text.includes("AGENT_REPLACEMENT_STATUS:failed");
if (!failure && !/!\[[^\]]*\]\(https?:\/\/[^)]+\)/.test(text)) continue;
completed.add(cardId);
if (failure) failed.add(cardId); else failed.delete(cardId);
}
const expectedCount = requested.size || params.expectedCount;
return {
completedCardIds: [...completed],
failedCardIds: [...failed],
done: expectedCount > 0 && completed.size >= expectedCount,
};
}