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(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(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(); const failed = new Set(); 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(//)?.[1]?.trim(); if (runId && runId !== requestId) continue; const cardId = text.match(//)?.[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, }; }