diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 7677c4c98..f6ca7d59e 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -5725,12 +5725,15 @@ export async function POST(request: Request) { sourceImageUrls: string[]; streamLabel: string; promptForPersistence?: string | null; + isFallback?: boolean; }) => { const persistedImageSize = String(params.imageSizeLabel || "").trim() || resolvedImageSize; const assistantText = prependImageModelBadge( [ ``, + ``, + ``, `### ${params.reference.label}`, String(params.bodyText || "").trim(), buildAgentToolImagesMarkdown([ @@ -5766,26 +5769,28 @@ export async function POST(request: Request) { ], }); - await saveGeneratedImages( - assistantText, - session.user.id, - id, - assistantMessageId, - params.promptForPersistence || baseReplacementPrompt, - resolvedModelRef, - { - sourceAssetIds, - sourceImageUrls: params.sourceImageUrls, - relationType: "edited_from", - sourceType: "detail_replacement", - projectId: contextPayload?.projectId || null, - userPrompt: requestTextContent, - optimizedPrompt: params.promptForPersistence || baseReplacementPrompt, - requestedImageCount: 1, - aspectRatio: resolvedAspectRatio, - imageSize: persistedImageSize, - }, - ); + if (!params.isFallback) { + await saveGeneratedImages( + assistantText, + session.user.id, + id, + assistantMessageId, + params.promptForPersistence || baseReplacementPrompt, + resolvedModelRef, + { + sourceAssetIds, + sourceImageUrls: params.sourceImageUrls, + relationType: "edited_from", + sourceType: "detail_replacement", + projectId: contextPayload?.projectId || null, + userPrompt: requestTextContent, + optimizedPrompt: params.promptForPersistence || baseReplacementPrompt, + requestedImageCount: 1, + aspectRatio: resolvedAspectRatio, + imageSize: persistedImageSize, + }, + ); + } } catch (persistError) { chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", { chatId: id, @@ -6143,8 +6148,8 @@ export async function POST(request: Request) { modelDisplayName: null, sourceImageUrls: referenceSourceImageUrls, streamLabel: "agent-mode:image:detail-replacement:item-fallback", + isFallback: true, }); - successCount += 1; } } catch (replacementPlanError) { chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", { @@ -6167,8 +6172,8 @@ export async function POST(request: Request) { modelDisplayName: null, sourceImageUrls: fallbackSourceImageUrls, streamLabel: "agent-mode:image:detail-replacement:item-fallback", + isFallback: true, }); - successCount += 1; } }); } finally { diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 98e40f04d..e5b1ee61a 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -1,6 +1,7 @@ "use client"; import { useChat } from "@ai-sdk/react"; +import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; import { DefaultChatTransport } from "ai"; import { Fragment, @@ -588,33 +589,7 @@ function areAttachmentValuesEqual( ); } -function mergePersistedHistoryMessages( - currentMessages: ChatMessage[], - persistedMessages: ChatMessage[] -) { - if (persistedMessages.length === 0) { - return currentMessages; - } - - if (currentMessages.length === 0) { - return persistedMessages; - } - - const persistedIds = new Set(persistedMessages.map((message) => message.id)); - const extraLocalMessages = currentMessages.filter( - (message) => !persistedIds.has(message.id) - ); - const mergedMessages = [...persistedMessages, ...extraLocalMessages]; - - if ( - mergedMessages.length === currentMessages.length && - mergedMessages.every((message, index) => currentMessages[index]?.id === message.id) - ) { - return currentMessages; - } - - return mergedMessages; -} +const mergePersistedHistoryMessages = mergeGenerationHistory; function getAttachmentIdentityKey( attachment: @@ -723,7 +698,7 @@ function normalizeAssistantText(value: string) { .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") + .replace(/\n*\n*/g, "") .replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "") .trim(); } @@ -2588,6 +2563,7 @@ interface LocalGenerationPendingState { modeType: AgentGenerationResultModeType; expectedCount: number; userTurnCountAtStart: number; + requestMessageId?: string | null; timestamp: number; requestedCardIds?: string[] | null; } @@ -2642,6 +2618,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType, expectedCount: Number(record.expectedCount), userTurnCountAtStart: Number(record.userTurnCountAtStart), + requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null, timestamp: Number(record.timestamp), requestedCardIds: Array.isArray(record.requestedCardIds) ? record.requestedCardIds @@ -3429,7 +3406,7 @@ export function AgentGenerationWorkspaceDarkV2({ } }, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]); - const { data: historyData } = useSWR<{ messages: ChatMessage[] }>( + const { data: historyData, mutate: refreshHistory } = useSWR<{ messages: ChatMessage[] }>( rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher, { @@ -4170,6 +4147,16 @@ export function AgentGenerationWorkspaceDarkV2({ } = useChat({ id: chatId, messages: historyMessages, + onData(part) { + if (part.type !== "data-appendMessage") return; + try { + const appended = parseAppendedGenerationMessage(part.data); + if (appended) setMessages((current) => appendGenerationMessage(current, appended as ChatMessage)); + } catch { + void refreshHistory(); + } + }, + onFinish() { void refreshHistory(); }, experimental_throttle: 100, generateId: generateUUID, transport: new DefaultChatTransport({ @@ -4303,6 +4290,20 @@ export function AgentGenerationWorkspaceDarkV2({ }), }); + useEffect(() => { + const reconcile = () => { + if (document.visibilityState === "visible") void refreshHistory(); + }; + document.addEventListener("visibilitychange", reconcile); + window.addEventListener("online", reconcile); + window.addEventListener("pageshow", reconcile); + return () => { + document.removeEventListener("visibilitychange", reconcile); + window.removeEventListener("online", reconcile); + window.removeEventListener("pageshow", reconcile); + }; + }, [refreshHistory]); + useEffect(() => { if (!historyMessages.length) { return; @@ -4523,8 +4524,7 @@ export function AgentGenerationWorkspaceDarkV2({ if (!hasLikelyGenerationErrorText(text)) { return false; } - const imageUrls = extractImageUrlsFromText(text); - return imageUrls.length === 0; + return true; }), [assistantMessagesAfterLastUser] ); @@ -4832,6 +4832,12 @@ export function AgentGenerationWorkspaceDarkV2({ : currentResultModeType === "sku_replacement" ? skuReplacementResultUrls : currentSceneGeneratedUrls; + const replacementRunRecovery = useMemo(() => { + if (!pendingGeneration || !["detail_replacement", "sku_replacement"].includes(pendingGeneration.modeType)) { + return { completedCardIds: [] as string[], failedCardIds: [] as string[], done: false }; + } + return recoverReplacementRun({ messages, ...pendingGeneration }); + }, [messages, pendingGeneration]); const transportIsGenerating = status === "streaming" || status === "submitted"; const pendingGenerationProgressCount = useMemo(() => { if (!pendingGeneration) { @@ -4853,13 +4859,14 @@ export function AgentGenerationWorkspaceDarkV2({ return detailGenerationRunResults.length; } - return latestReplacementResults.length; + return replacementRunRecovery.completedCardIds.length; }, [ currentSceneGeneratedUrls.length, detailGenerationRunResults.length, latestReplacementResults.length, pendingGeneration, userMessageCount, + replacementRunRecovery.completedCardIds.length, ]); const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration); const isSceneResultModeGenerating = @@ -4996,7 +5003,7 @@ export function AgentGenerationWorkspaceDarkV2({ generatedCount: pendingGeneration?.modeType === "detail_replacement" ? latestReplacementResults.length : 0, - isGenerating: isDetailResultModeGenerating, + isGenerating: isDetailResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id), activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, }), }; @@ -5004,6 +5011,7 @@ export function AgentGenerationWorkspaceDarkV2({ [ detailReplacementReferenceCards, isDetailResultModeGenerating, + replacementRunRecovery.completedCardIds, latestReplacementResults, normalizedDeletedResultCollections.detail_replacement, pendingGeneration?.modeType, @@ -5055,13 +5063,14 @@ export function AgentGenerationWorkspaceDarkV2({ generatedCount: pendingGeneration?.modeType === "sku_replacement" ? latestReplacementResults.length : 0, - isGenerating: isSkuResultModeGenerating, + isGenerating: isSkuResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id), activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, }), }; }), [ isSkuResultModeGenerating, + replacementRunRecovery.completedCardIds, latestReplacementResults, normalizedDeletedResultCollections.sku_replacement, pendingGeneration?.modeType, @@ -5429,6 +5438,14 @@ export function AgentGenerationWorkspaceDarkV2({ setActiveResultModeType(currentResultModeType); }, [currentResultModeType]); + useEffect(() => { + if (!pendingGeneration || !replacementRunRecovery.done) return; + // The backend has settled every requested card; an open SSE connection is no longer work. + stop(); + transportGenerationStartedAtRef.current = null; + setPendingGeneration(null); + }, [pendingGeneration, replacementRunRecovery.done, stop]); + useEffect(() => { if (!pendingGeneration) { return; @@ -6385,8 +6402,10 @@ export function AgentGenerationWorkspaceDarkV2({ ); setActiveResultModeType(currentResultModeType); setSelectedResultUrl(null); + const requestMessageId = generateUUID(); setPendingGeneration({ chatId, + requestMessageId, modeType: currentResultModeType, expectedCount: currentResultModeType === "scene_generation" @@ -6407,7 +6426,7 @@ export function AgentGenerationWorkspaceDarkV2({ toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`); } try { - void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => { + void Promise.resolve(sendMessage({ id: requestMessageId, role: "user", parts })).catch((error) => { generationRequestSnapshotRef.current = { hiddenPrompt: null, replacementExecutionCardIds: null, diff --git a/components/image-params-bar.tsx b/components/image-params-bar.tsx index 0e1692634..7ef5f2b09 100644 --- a/components/image-params-bar.tsx +++ b/components/image-params-bar.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react"; import { getGrsaiGptImageOutputSize } from "@/lib/ai/grsai-gpt-image-sizes"; +import { resolveVip98kImageTaskSize } from "@/lib/ai/vip98k-task-invocation"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -51,6 +52,7 @@ interface ImageParamsBarProps { supportsQuality?: boolean; supportedAspectRatios?: string[]; supportedImageSizes?: string[]; + vip98k?: Record; }; } @@ -334,6 +336,11 @@ export function ImageParamsBar({ const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt; const getComputedOutputSize = (aspectRatio: string, imageSize: string) => { + if (providerType === "vip98k" && imageGenConfig?.vip98k?.resolutionSizing === "gpt-area") { + const size = resolveVip98kImageTaskSize({ imageSize, aspectRatio, useGptImage25AspectSizing: true }); + const [width, height] = (size || "").split("x").map(Number); + if (width && height) return { width, height }; + } if (providerType === "grsai-gpt-image") { return getGrsaiGptImageOutputSize(aspectRatio, imageSize); } @@ -439,24 +446,17 @@ export function ImageParamsBar({ const currentAspectRatio = isAspectRatioAllowed(params.aspectRatio) ? params.aspectRatio : getFallbackAspectRatio(); - const sizeOptionKeySet = new Set(); - const sizeOptions = [ - ...availableAspectRatioOptions.map((option) => ({ + const parameterGroups = [{ title: "比例", kind: "ratio", options: + availableAspectRatioOptions.map((option) => ({ label: option.value === "auto" ? "auto" : option.label, aspectRatio: option.value, imageSize: currentImageSize, - })), - ...availableSizeOptions.map((option) => ({ + })) }, { title: "分辨率", kind: "resolution", options: + availableSizeOptions.map((option) => ({ label: option.label, aspectRatio: currentAspectRatio, imageSize: option.value, - })), - ].filter((option) => { - const key = `${option.aspectRatio}:${option.imageSize}`; - if (sizeOptionKeySet.has(key)) return false; - sizeOptionKeySet.add(key); - return true; - }); + })) }]; const imageCountOptions = getImageCountOptions(); const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio; const selectedImageCount = params.imageCount || 1; @@ -495,8 +495,9 @@ export function ImageParamsBar({ variant="ghost" className="h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]" title="生成参数" + aria-label={`比例:${params.aspectRatio},分辨率:${params.imageSize}`} > - {qualityLabel} · {selectedAspectLabel} · {selectedImageCount} img + {qualityLabel} · {selectedAspectLabel} · {currentImageSize} · {selectedImageCount} img {minimalParamsOpen ? ( ) : ( @@ -533,7 +534,7 @@ export function ImageParamsBar({
-
尺寸
+
预计尺寸
W @@ -547,14 +548,15 @@ export function ImageParamsBar({
- {sizeOptions.length > 0 && ( -
-
Size
+ {parameterGroups.filter((group) => group.options.length > 0).map((group) => ( +
+
{group.title}
- {sizeOptions.map((option) => { + {group.options.map((option) => { const selected = - params.aspectRatio === option.aspectRatio && - (option.aspectRatio === "auto" || params.imageSize === option.imageSize); + group.kind === "ratio" + ? params.aspectRatio === option.aspectRatio + : params.imageSize === option.imageSize; return ( @@ -581,7 +583,7 @@ export function ImageParamsBar({ })}
- )} + ))}
Image
diff --git a/lib/agents/replacement-run-recovery.ts b/lib/agents/replacement-run-recovery.ts new file mode 100644 index 000000000..87dccc05f --- /dev/null +++ b/lib/agents/replacement-run-recovery.ts @@ -0,0 +1,73 @@ +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, + }; +} diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index e391fb561..27aee94ce 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -8,6 +8,10 @@ import { } from "@aws-sdk/client-s3"; import { createOpenAI } from "@ai-sdk/openai"; import sharp from "sharp"; +import { + isVip98kReferenceSizeAllowed, + normalizeVip98kReferenceImage, +} from "@/lib/ai/vip98k-reference-image"; import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries"; import { bindVip98kImages, @@ -33,7 +37,6 @@ import { getVip98kStagedMediaRetryDelay, getVip98kSubmissionRetryDelay, isVip98kStagedReferenceUrl, - shouldStageVip98kReferenceUrl, VIP98K_REFERENCE_STAGING_KEY_PREFIX, } from "@/lib/ai/vip98k-reference-staging"; import { createLogger } from "@/lib/observability/logger"; @@ -650,6 +653,7 @@ async function findStagedReference( }), { abortSignal: AbortSignal.timeout(REFERENCE_CACHE_LOOKUP_TIMEOUT_MS) }, ); + if (!isVip98kReferenceSizeAllowed(Number(response.ContentLength))) return null; if ( response.LastModified && Date.now() - response.LastModified.getTime() > @@ -801,23 +805,14 @@ async function uploadReferenceImage( return buildS3PublicUrl(stagingConfig.storageConfig, key); } -async function normalizeReferenceImage(buffer: Buffer, mimeType: string) { +async function normalizeReferenceImage(buffer: Buffer, _mimeType: string) { try { - const normalized = await sharp(buffer, { failOn: "none" }) - .rotate() - .resize(1920, 1920, { fit: "inside", withoutEnlargement: true }) - .flatten({ background: "#ffffff" }) - .jpeg({ quality: 92, chromaSubsampling: "4:4:4", mozjpeg: true }) - .toBuffer(); - return { - buffer: normalized, - extension: "jpg", - }; + return await normalizeVip98kReferenceImage(buffer); } catch (error) { - logger.warn("reference image normalization failed; preserving original bytes", { + logger.warn("reference image normalization failed", { message: error instanceof Error ? error.message : String(error), }); - return { buffer, extension: detectExtension(mimeType) }; + throw new Error("参考图处理失败,未提交生图;请重新上传可读取的 JPG、PNG 或 WebP 图片"); } } @@ -892,14 +887,20 @@ async function ensurePublicMediaUrl( const sourceObjectKey = stagingConfig ? getStorageObjectKeyFromPublicUrl(normalized, stagingConfig.sourcePublicUrl) : null; - if ( - !stagingConfig || - sourceObjectKey?.startsWith(VIP98K_REFERENCE_STAGING_KEY_PREFIX) || - !shouldStageVip98kReferenceUrl({ - mediaUrl: normalized, - sourcePublicUrl: stagingConfig.sourcePublicUrl, - }) - ) { + if (!stagingConfig) { + const downloaded = await downloadReferenceImage(normalized); + const prepared = await normalizeReferenceImage( + downloaded.buffer, + downloaded.mimeType, + ); + const uploaded = await uploadFile( + `vip98k-ref-${randomUUID()}.jpg`, + toArrayBuffer(prepared.buffer), + ); + return uploaded.url; + } + // Only images produced by the current, byte-checked pipeline can bypass staging. + if (sourceObjectKey?.startsWith(`${VIP98K_REFERENCE_STAGING_KEY_PREFIX}cache-v2/`)) { return normalized; } @@ -937,9 +938,12 @@ async function ensurePublicMediaUrl( ); return uploadReferenceImage(normalizedImage.buffer, stagingConfig); } + const prepared = mediaKind === "image" + ? await normalizeReferenceImage(parsed.buffer, parsed.mimeType) + : { buffer: parsed.buffer, extension }; const uploaded = await uploadFile( - `vip98k-ref-${suffix}.${extension}`, - toArrayBuffer(parsed.buffer), + `vip98k-ref-${suffix}.${prepared.extension}`, + toArrayBuffer(prepared.buffer), ); return uploaded.url; } @@ -1118,6 +1122,7 @@ async function submitTask(params: { path, attempt, status: response.status, + providerRequestId: response.headers.get("x-request-id"), attemptDurationMs: Date.now() - attemptStartedAt, totalDurationMs: Date.now() - submissionStartedAt, }); @@ -1271,6 +1276,9 @@ export async function submitVip98kExternalTask(params: { ), maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts, bodyKeys: Object.keys(body).sort(), + requestedSize: body.size, + requestedAspectRatio: body.aspect_ratio || body.aspectRatio || body.ratio, + requestedQuality: body.quality, }); const submitted = await submitTask({ apiKey: params.apiKey, diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 9a516fa8b..ed709b15d 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -670,6 +670,9 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) { }); return { modelType: type, + ...(type === "image" && hasVerifiedVip98kOpenAiImageArrayContract(getModelId(model)) + ? { resolutionSizing: "gpt-area" } + : {}), taskApi: String(invocation?.kind || "").toLowerCase() === "tool" ? "external-tool" @@ -728,8 +731,12 @@ function createImagePreset( const supportsNativeAspectRatio = [ "ratio", "aspect-ratio", - ].some((key) => invocationKeys.has(key)) || hasVerifiedResolutionTiers; + ].some((key) => invocationKeys.has(key)) || hasVerifiedVip98kOpenAiImageArrayContract(modelId); const supportsQuality = invocationKeys.has("quality"); + // Flare repeatedly returns 3:4/4:3 for 4:5/5:4, even with aspect_ratio supplied. + const supportedAspectRatios = supportsNativeAspectRatio + ? DEFAULT_ASPECT_RATIOS.filter((ratio) => modelId !== "gpt-image-2.5-flare" || !["4:5", "5:4"].includes(ratio)) + : ["auto"]; const supportedModes = [ ...(capabilities.includes("text-to-image") || capabilities.length === 0 ? ["text-to-image"] @@ -764,9 +771,7 @@ function createImagePreset( inputModes, outputModes, supportedImageSizes: supportsNativeSize ? supportedImageSizes : [], - supportedAspectRatios: supportsNativeAspectRatio - ? [...DEFAULT_ASPECT_RATIOS] - : ["auto"], + supportedAspectRatios, supportsMultiImageReference, maxReferenceImages, vip98k: getVip98kMetadata(model, "image"), @@ -792,9 +797,7 @@ function createImagePreset( supports2K: supportsNativeSize && supportedImageSizes.includes("2K"), supports4K: supportsNativeSize && supportedImageSizes.includes("4K"), supportedImageSizes: supportsNativeSize ? supportedImageSizes : [], - supportedAspectRatios: supportsNativeAspectRatio - ? [...DEFAULT_ASPECT_RATIOS] - : ["auto"], + supportedAspectRatios, supportsPromptExtend: false, supportsNegativePrompt: false, supportsWatermark: false, diff --git a/lib/ai/vip98k-reference-image.ts b/lib/ai/vip98k-reference-image.ts new file mode 100644 index 000000000..aa03711df --- /dev/null +++ b/lib/ai/vip98k-reference-image.ts @@ -0,0 +1,23 @@ +import sharp from "sharp"; + +// This is the provider's per-reference limit, separate from our 20 MiB upload limit. +export const VIP98K_REFERENCE_MAX_BYTES = 10 * 1024 * 1024; + +export function isVip98kReferenceSizeAllowed(bytes: number) { + return Number.isFinite(bytes) && bytes > 0 && bytes <= VIP98K_REFERENCE_MAX_BYTES; +} + +export async function normalizeVip98kReferenceImage(buffer: Buffer) { + for (const quality of [92, 82, 70]) { + const normalized = await sharp(buffer, { failOn: "none" }) + .rotate() + .resize(1920, 1920, { fit: "inside", withoutEnlargement: true }) + .flatten({ background: "#ffffff" }) + .jpeg({ quality, chromaSubsampling: "4:4:4", mozjpeg: true }) + .toBuffer(); + if (isVip98kReferenceSizeAllowed(normalized.byteLength)) { + return { buffer: normalized, extension: "jpg" }; + } + } + throw new Error("参考图压缩后仍超过服务商单张 10 MiB 限制,请缩小图片后重试"); +} diff --git a/lib/ai/vip98k-reference-staging.ts b/lib/ai/vip98k-reference-staging.ts index d768e53af..645dd87ce 100644 --- a/lib/ai/vip98k-reference-staging.ts +++ b/lib/ai/vip98k-reference-staging.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { isMediaInputFailure } from "@/lib/ai/runtime/request-failure-classification"; export const VIP98K_REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-"; -const VIP98K_REFERENCE_CACHE_OBJECT_PREFIX = "uploads/vip98k-cache"; +const VIP98K_REFERENCE_CACHE_OBJECT_PREFIX = "uploads/vip98k-cache-v2"; function parseUrl(value: string) { try { @@ -85,6 +85,7 @@ export function getVip98kStagedMediaRetryDelay(params: { params.status !== 400 || params.stagedReferenceCount < 1 || params.attempt >= params.maxAttempts || + /(?:larger than|too large|exceeds?|maximum).*(?:MiB|MB|bytes|size)/i.test(params.message) || !isMediaInputFailure(params.message) ) { return null; diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index 70836d753..cd4af6f36 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -61,6 +61,10 @@ export function resolveVip98kImageTaskSize(params: { .match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/); const sizeBucket = String(params.imageSize || "").trim().toUpperCase(); const longestEdge = VIP98K_IMAGE_SIZE_LONGEST_EDGE[sizeBucket]; + if (params.useGptImage25AspectSizing && sizeBucket in GPT_IMAGE_25_TARGET_PIXELS && (!ratioMatch || Number(ratioMatch[1]) === Number(ratioMatch[2]))) { + const edge = Math.sqrt(GPT_IMAGE_25_TARGET_PIXELS[sizeBucket]); + return `${edge}x${edge}`; + } if ( params.useGptImage25AspectSizing && ratioMatch?.[1] && @@ -85,9 +89,14 @@ export function resolveVip98kImageTaskSize(params: { clampDimension(Math.floor(value / 16) * 16); const shouldFloorWidth = sizeBucket !== "4K" && width < height; const shouldFloorHeight = sizeBucket !== "4K" && height < width; - return `${shouldFloorWidth ? floorToSixteen(width) : roundToSixteen(width)}x${ - shouldFloorHeight ? floorToSixteen(height) : roundToSixteen(height) - }`; + let outputWidth = shouldFloorWidth ? floorToSixteen(width) : roundToSixteen(width); + let outputHeight = shouldFloorHeight ? floorToSixteen(height) : roundToSixteen(height); + // Flare promotes even a small area overflow to the next tier (2736x1536 -> 4K). + if (sizeBucket !== "4K" && outputWidth * outputHeight > targetPixels) { + if (outputWidth >= outputHeight) outputWidth -= 16; + else outputHeight -= 16; + } + return `${outputWidth}x${outputHeight}`; } } if (longestEdge && params.preserveResolutionBucket) return sizeBucket; @@ -136,11 +145,14 @@ const KNOWN_IMAGE_TOOL_INPUTS: Record = { }; const VERIFIED_OPENAI_IMAGE_ARRAY_MODEL_IDS = new Set([ + "gpt-image-2-vip", "gpt-image-2.5", "gpt-image-2.5-flare", ]); const VERIFIED_GPT_IMAGE_25_RESOLUTION_MODEL_IDS = new Set([ + // Paid two-reference probes on 2026-09-11; 4K square is 2880px, not 4096px. + "gpt-image-2-vip", "gpt-image-2.5-flare", ]); diff --git a/scripts/vip98k-matrix-probe.cjs b/scripts/vip98k-matrix-probe.cjs new file mode 100644 index 000000000..5086c189b --- /dev/null +++ b/scripts/vip98k-matrix-probe.cjs @@ -0,0 +1,74 @@ +/* Run on the application host; credentials stay in memory and reports contain no secrets. */ +const fs=require('node:fs');const path=require('node:path');const {randomUUID,createHash}=require('node:crypto'); +require('dotenv').config({path:'.env.local',quiet:true});require('dotenv').config({path:'.env',quiet:true}); +const sharp=require('sharp');const postgres=require('postgres');const {S3Client,PutObjectCommand}=require('@aws-sdk/client-s3'); +const out=path.resolve(process.env.PROBE_DIR||'tmp/vip98k-matrix-0911');fs.mkdirSync(out,{recursive:true}); +const delay=ms=>new Promise(r=>setTimeout(r,ms));const sanitize=s=>String(s).replace(/https?:\/\/[^\s"<]+/g,'[url]').slice(0,1800); +const ratios=(process.env.PROBE_RATIOS||'1:1').split(',');const tiers=(process.env.PROBE_TIERS||'1K,2K,4K').split(','); +function urls(x){if(!x||typeof x!=='object')return [];if(Array.isArray(x))return x.flatMap(urls);return Object.entries(x).flatMap(([k,v])=>['url','image_url','file_download_url','download_url','output_url'].includes(k)&&typeof v==='string'&&v.startsWith('http')?[v]:typeof v==='object'?urls(v):[]);} +function b64(x){if(!x||typeof x!=='object')return null;if(x.b64_json)return x.b64_json;for(const v of Object.values(x)){const s=b64(v);if(s)return s;}return null;} +(async()=>{ + const sql=postgres(process.env.POSTGRES_URL,{max:1});const [p]=await sql`select "apiKey","baseUrl" from "AiProvider" where type='vip98k' and "isEnabled"=true limit 1`; + const [s]=await sql`select * from "StorageConfig" where "isDefault"=true and "isEnabled"=true limit 1`;await sql.end(); + const base=p.baseUrl.replace(/\/+$/,'').replace(/\/v1$/,''); + async function request(url,body,headers={},method='POST'){ + const r=await fetch(url,{method,headers:{Authorization:'Bearer '+p.apiKey,...(body&&! (body instanceof FormData)?{'Content-Type':'application/json'}:{}),...headers},body:body instanceof FormData?body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(240000)}); + const txt=await r.text();let data;try{data=JSON.parse(txt)}catch{data={message:txt}}; + if(!r.ok){const err=new Error(sanitize(data.error?.message||data.message||txt));err.status=r.status;err.requestId=r.headers.get('x-request-id');throw err;} + return {data,requestId:r.headers.get('x-request-id')}; + } + const catalog=(await request(base+'/api/v2/catalog/models',null,{},'GET')).data; + const auth=(await request(base+'/v1/models',null,{},'GET')).data; + const allowed=new Set(auth.data.map(m=>m.id));let models=catalog.data.sections.flatMap(x=>x.models||[]).filter(m=>allowed.has(m.id)&&(/^(gpt-image|nano-banana)/.test(m.id))); + if(process.env.PROBE_MODELS){const ids=new Set(process.env.PROBE_MODELS.split(','));models=models.filter(m=>ids.has(m.id));} + const client=new S3Client({endpoint:s.endpoint,region:s.region||'auto',forcePathStyle:true,credentials:{accessKeyId:s.accessKeyId,secretAccessKey:s.secretAccessKey}}); + const refs=[];const refBuffers=[]; + for(const [color,label] of [['#175bdd','A'],['#df2828','B']]){ + const buf=await sharp(Buffer.from(`${label}`)).png().toBuffer(); + const key='uploads/vip98k-test-0911/'+createHash('sha256').update(buf).digest('hex')+'.png'; + await client.send(new PutObjectCommand({Bucket:s.bucket,Key:key,Body:buf,ContentType:'image/png'}));refs.push(s.publicUrl.replace(/\/+$/,'')+'/'+key);refBuffers.push(buf); + } + fs.writeFileSync(path.join(out,'catalog-summary.json'),JSON.stringify(models.map(m=>({id:m.id,resolutions:m.supported_resolutions,invocations:m.invocations,invocation:m.invocation})),null,2)); + let resolver; if(process.env.PROBE_VARIANT==='production'){if(process.env.PROBE_RESOLVER_MODULE)resolver=require(path.resolve(process.env.PROBE_RESOLVER_MODULE));else{require('tsx/cjs');resolver=require('../lib/ai/vip98k-task-invocation.ts');}} + const jobs=[];for(const m of models)for(const tier of tiers)for(const ratio of ratios)jobs.push({m,tier,ratio,variant:process.env.PROBE_VARIANT||'pixel'}); + console.log(JSON.stringify({event:'start',models:models.map(m=>m.id),jobs:jobs.length,out})); + let cursor=0;const results=[]; + async function run(job){const {m,tier,ratio,variant}=job;const started=Date.now();const record={model:m.id,tier,ratio,variant,startedAt:new Date().toISOString()}; + try{ + const list=m.invocations||[m.invocation];let inv=list.find(v=>v.path==='/extensions/v1/tasks')||list.find(v=>v.path==='/v1/images/edits')||m.invocation||list[0]; + const headers={};const chosen=m.invocation?.path===inv.path?m.invocation:inv;for(const [k,v]of Object.entries(chosen.headers||{}))if(k.toLowerCase().startsWith('x-relay-'))headers[k]=v; + const [w,h]=ratio.split(':').map(Number);const edge=tier==='1K'?1024:tier==='2K'?2048:m.id.includes('2.5')?2880:4096; + const dims=ratio==='1:1'?[edge,edge]:[Math.round(edge*Math.sqrt(w/h)/16)*16,Math.round(edge/Math.sqrt(w/h)/16)*16]; + let size=dims.join('x');let body={...(inv.request_example||{}),model:m.id,prompt:`Use both image references. Put the blue A card on the left and the red B card on the right on a neutral studio background. Preserve both letters and colors. Output aspect ratio ${ratio}.`}; + if(inv.path==='/extensions/v1/tasks'){body.file_urls=refs;body.resolution=tier;body.aspect_ratio=ratio;body.size='auto';} + else {body.image=refs;body.size=variant==='bucket'?tier:size;if(variant==='bucket'||variant==='pixel-ratio')body.aspect_ratio=ratio;} + if(variant==='production'&&m.id.startsWith('gpt-image'))body.size=resolver.resolveVip98kImageTaskSize({imageSize:tier,aspectRatio:ratio,useGptImage25AspectSizing:true,preserveResolutionBucket:inv.path==='/v1/images/generations'}); + if(process.env.PROBE_TEXT_ONLY==='1'){delete body.image;delete body.file_urls;body.prompt=`Two cards, a blue A on the left and a red B on the right, on a neutral studio background. Aspect ratio ${ratio}.`;} + record.path=inv.path;record.requestedSize=body.size;record.referenceCount=process.env.PROBE_TEXT_ONLY==='1'?0:2;record.route=headers['X-Relay-Route-Option']||null; + if(inv.content_type==='multipart/form-data'){ + const form=new FormData();for(const [k,v]of Object.entries(body))if(!['image','image[]','images'].includes(k)&&v!==null&&typeof v!=='object')form.append(k,String(v)); + refBuffers.forEach((buf,i)=>form.append('image[]',new Blob([buf],{type:'image/png'}),`ref-${i}.png`));body=form; + } + let response=await request(base+inv.path,body,{...headers,'Idempotency-Key':randomUUID()});record.requestId=response.requestId;let data=response.data;record.submitMs=Date.now()-started; + let imageUrl=urls(data)[0];let encoded=b64(data);let task=data.data?.task_id||data.task_id||data.data?.id||data.id; + const pollStart=Date.now();while(!imageUrl&&!encoded&&task&&Date.now()-pollStart<900000){ + await delay(5000);const rp=inv.result_path||data.result_path||'/extensions/v1/tasks/{task_id}';const poll=rp.replaceAll('{task_id}',encodeURIComponent(task)); + data=(await request(poll.startsWith('http')?poll:base+poll,null,{},'GET')).data; + const state=String(data.data?.status||data.data?.state||data.status||'').toLowerCase();if(['failed','error','cancelled'].includes(state))throw new Error(sanitize(JSON.stringify(data))); + imageUrl=urls(data)[0];encoded=b64(data); + if(!imageUrl&&!encoded&&['succeeded','completed','success'].includes(state)){ + const delivery=(await request(base+'/api/external/task/result?taskId='+encodeURIComponent(task),null,{},'GET')).data;imageUrl=urls(delivery)[0];encoded=b64(delivery); + } + } + if(!imageUrl&&!encoded)throw new Error('No output image after polling'); + record.generationMs=Date.now()-started; + let buf;if(encoded)buf=Buffer.from(encoded,'base64');else{const r=await fetch(imageUrl,{signal:AbortSignal.timeout(120000)});if(!r.ok)throw new Error('Output download HTTP '+r.status);buf=Buffer.from(await r.arrayBuffer());} + const meta=await sharp(buf).metadata();const {data:pix,info}=await sharp(buf).removeAlpha().resize(256,256,{fit:'fill'}).raw().toBuffer({resolveWithObject:true});let red=0,blue=0;for(let i=0;i100&&r>b*1.25&&r>g*1.15)red++;if(b>100&&b>r*1.25&&b>g*1.1)blue++;} + record.actual={width:meta.width,height:meta.height,bytes:buf.length,redShare:red/65536,blueShare:blue/65536};record.ratioMatches=Math.abs(meta.width/meta.height-w/h)/(w/h)<0.025;record.bothReferences=red/65536>.03&&blue/65536>.03; + record.status='generated';record.file=`${m.id}-${tier}-${ratio.replace(':','x')}-${variant}.${meta.format==='jpeg'?'jpg':meta.format}`;fs.writeFileSync(path.join(out,record.file),buf); + }catch(e){record.status='failed';record.error=sanitize(e.message);record.httpStatus=e.status;record.requestId=e.requestId||record.requestId;} + record.durationMs=Date.now()-started;results.push(record);fs.appendFileSync(path.join(out,'results.jsonl'),JSON.stringify(record)+'\n');console.log(JSON.stringify(record)); + } + await Promise.all(Array.from({length:Number(process.env.PROBE_CONCURRENCY||3)},async()=>{while(cursor{console.error(sanitize(e.message));process.exit(1)}); diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index cbe5f97d3..7f47309d4 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -1,3 +1,11 @@ +import { mergeGenerationHistory, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; +import { randomBytes } from "node:crypto"; +import sharp from "sharp"; +import { + isVip98kReferenceSizeAllowed, + normalizeVip98kReferenceImage, + VIP98K_REFERENCE_MAX_BYTES, +} from "@/lib/ai/vip98k-reference-image"; import { expect, test } from "@playwright/test"; import { bindVip98kImages, @@ -126,7 +134,7 @@ test.describe("VIP98K reference-image routing", () => { const source = "https://storage.example/uploads/reference.png"; const key = getVip98kReferenceStagingObjectKey(source); - expect(key).toMatch(/^uploads\/vip98k-cache\/[a-f0-9]{64}\.jpg$/); + expect(key).toMatch(/^uploads\/vip98k-cache-v2\/[a-f0-9]{64}\.jpg$/); expect( getVip98kReferenceStagingObjectKey(`${source}?sg_media_retry=retry-2`), ).toBe(key); @@ -557,10 +565,10 @@ test.describe("VIP98K reference-image routing", () => { ).toBe(false); }); - test("excludes text-only VIP98K models from the image picker", () => { + test("excludes unverified text-only VIP98K models from the image picker", () => { const presets = buildVip98kModelPresets([ { - id: "gpt-image-2-vip", + id: "unverified-image-model", type: "image", capabilities: ["openai.images.generations", "text_to_image"], invocation: { @@ -568,7 +576,7 @@ test.describe("VIP98K reference-image routing", () => { method: "POST", path: "/v1/images/generations", content_type: "application/json", - request_example: { model: "gpt-image-2-vip", n: 1, prompt: "Generate" }, + request_example: { model: "unverified-image-model", n: 1, prompt: "Generate" }, }, }, ]); @@ -615,14 +623,14 @@ test.describe("VIP98K reference-image routing", () => { capabilities: { supportsMultiImageReference: true, maxReferenceImages: 8, - supportedImageSizes: ["1K"], + supportedImageSizes: ["1K", "2K", "4K"], }, imageGenConfig: { supportsSize: true, supports1K: true, - supports2K: false, - supports4K: false, - supportedImageSizes: ["1K"], + supports2K: true, + supports4K: true, + supportedImageSizes: ["1K", "2K", "4K"], }, }); }); @@ -674,7 +682,7 @@ test.describe("VIP98K reference-image routing", () => { isHidden: false, supportsQuality: true, quality: "auto", - supportedAspectRatios: ["auto"], + supportedAspectRatios: ["auto", "1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "4:5", "5:4"], }, { modelId: "gpt-image-2.5-flare", @@ -690,8 +698,6 @@ test.describe("VIP98K reference-image routing", () => { "9:16", "3:2", "2:3", - "4:5", - "5:4", ], }, { @@ -744,8 +750,6 @@ test.describe("VIP98K reference-image routing", () => { "9:16", "3:2", "2:3", - "4:5", - "5:4", ], }, defaultParams: { imageSize: "2K" }, @@ -871,7 +875,7 @@ test.describe("VIP98K reference-image routing", () => { preserveResolutionBucket: true, useGptImage25AspectSizing: true, }), - ).toBe("2K"); + ).toBe("2048x2048"); expect( resolveVip98kImageTaskSize({ imageSize: "2K", @@ -892,6 +896,15 @@ test.describe("VIP98K reference-image routing", () => { ).toBe("2352x3520"); }); + test("keeps rounded GPT 2K requests inside their pixel tier", () => { + for (const ratio of ["16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4"]) { + const size = resolveVip98kImageTaskSize({ imageSize: "2K", aspectRatio: ratio, useGptImage25AspectSizing: true }); + const [w, h] = size!.split("x").map(Number); + expect(w * h).toBeLessThanOrEqual(2048 * 2048); + } + expect(resolveVip98kImageTaskSize({ imageSize: "2K", aspectRatio: "16:9", useGptImage25AspectSizing: true })).toBe("2720x1536"); + }); + test("advertises verified Flare resolutions and aspect-ratio controls", () => { const [preset] = buildVip98kModelPresets([ { @@ -934,9 +947,88 @@ test.describe("VIP98K reference-image routing", () => { "9:16", "3:2", "2:3", - "4:5", - "5:4", ], }); }); }); + + +test.describe("VIP98K reference byte limits", () => { + test("compresses a valid upload above the provider limit without cropping", async () => { + const source = await sharp(randomBytes(2300 * 2000 * 3), { + raw: { width: 2300, height: 2000, channels: 3 }, + }).png().toBuffer(); + expect(source.byteLength).toBeGreaterThan(VIP98K_REFERENCE_MAX_BYTES); + const normalized = await normalizeVip98kReferenceImage(source); + expect(isVip98kReferenceSizeAllowed(normalized.buffer.byteLength)).toBe(true); + const meta = await sharp(normalized.buffer).metadata(); + expect(meta.format).toBe("jpeg"); + expect(meta.width).toBe(1920); + expect((meta.width || 0) / (meta.height || 1)).toBeCloseTo(2300 / 2000, 2); + }); + + test("never falls back to unprocessed bytes after decode failure", async () => { + await expect(normalizeVip98kReferenceImage(Buffer.alloc(VIP98K_REFERENCE_MAX_BYTES + 1))).rejects.toThrow(); + expect(isVip98kReferenceSizeAllowed(VIP98K_REFERENCE_MAX_BYTES)).toBe(true); + expect(isVip98kReferenceSizeAllowed(VIP98K_REFERENCE_MAX_BYTES + 1)).toBe(false); + expect(isVip98kReferenceSizeAllowed(0)).toBe(false); + }); + + test("does not retry a permanently oversized reference as propagation lag", () => { + expect(getVip98kStagedMediaRetryDelay({ + status: 400, + message: "reference image at example.top is larger than 10 MiB", + attempt: 1, + maxAttempts: 3, + stagedReferenceCount: 1, + })).toBeNull(); + }); +}); + + +test.describe("replacement run recovery after backgrounding", () => { + const user = (id: string) => ({ id, role: "user", parts: [{ type: "text", text: "replace" }] }); + const result = (id: string, run: string, card: string, failed = false) => ({ + id, role: "assistant", parts: [{ type: "text", text: + `\n\n${failed ? "生成失败,当前先沿用参考图" : "完成"}\n![image](https://example.com/${card}.png)` }], + }); + const pending = { requestMessageId: "new", userTurnCountAtStart: 1, requestedCardIds: ["a", "b"], expectedCount: 2 }; + + test("settles each card by ID when parallel responses arrive out of order", () => { + const messages = [user("old"), result("old-a", "old", "a"), user("new"), result("new-b", "new", "b")]; + const partial = recoverReplacementRun({ messages, ...pending }); + expect(partial.completedCardIds).toEqual(["b"]); + expect(partial.done).toBe(false); + const finished = recoverReplacementRun({ messages: [...messages, result("new-a", "new", "a", true)], ...pending }); + expect(finished.done).toBe(true); + expect(finished.failedCardIds).toEqual(["a"]); + }); + + test("recovers missed SSE results from persisted history after tab return", () => { + const local = [user("old"), user("new")]; + const history = [...local, result("a", "new", "a"), result("b", "new", "b", true)]; + const merged = mergeGenerationHistory(local, history); + expect(recoverReplacementRun({ messages: merged, ...pending }).done).toBe(true); + expect(mergeGenerationHistory(merged, history)).toBe(merged); + }); + + test("updates persisted content even if message IDs did not change", () => { + const partial = { id: "a", role: "assistant", parts: [{ type: "text", text: "处理中" }] }; + const final = result("a", "new", "a", true); + expect(mergeGenerationHistory([partial], [final])).toEqual([final]); + expect(mergeGenerationHistory([final], [partial])).toEqual([final]); + }); + + test("does not use an old result or duplicate delivery to finish a rerun", () => { + const stale = result("a-old", "old", "a"); + const messages = [user("old"), stale, user("new"), stale, result("b", "new", "b"), result("b-copy", "new", "b")]; + const progress = recoverReplacementRun({ messages, ...pending }); + expect(progress.done).toBe(false); + expect(progress.completedCardIds).toEqual(["b"]); + }); + + test("recovers legacy pending runs without a request ID", () => { + const messages = [user("old"), result("old-a", "old", "a"), user("new"), result("a", "new", "a")]; + expect(recoverReplacementRun({ messages, userTurnCountAtStart: 1, requestedCardIds: ["a"], expectedCount: 1 }).done).toBe(true); + }); +});