fix: verify VIP98K sizes and recover replacement results
This commit is contained in:
parent
a2a9e7d8b2
commit
b26e265083
@ -5725,12 +5725,15 @@ export async function POST(request: Request) {
|
|||||||
sourceImageUrls: string[];
|
sourceImageUrls: string[];
|
||||||
streamLabel: string;
|
streamLabel: string;
|
||||||
promptForPersistence?: string | null;
|
promptForPersistence?: string | null;
|
||||||
|
isFallback?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const persistedImageSize =
|
const persistedImageSize =
|
||||||
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
|
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
|
||||||
const assistantText = prependImageModelBadge(
|
const assistantText = prependImageModelBadge(
|
||||||
[
|
[
|
||||||
`<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`,
|
`<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`,
|
||||||
|
`<!-- AGENT_REPLACEMENT_RUN_ID:${message.id} -->`,
|
||||||
|
`<!-- AGENT_REPLACEMENT_STATUS:${params.isFallback ? "failed" : "completed"} -->`,
|
||||||
`### ${params.reference.label}`,
|
`### ${params.reference.label}`,
|
||||||
String(params.bodyText || "").trim(),
|
String(params.bodyText || "").trim(),
|
||||||
buildAgentToolImagesMarkdown([
|
buildAgentToolImagesMarkdown([
|
||||||
@ -5766,26 +5769,28 @@ export async function POST(request: Request) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await saveGeneratedImages(
|
if (!params.isFallback) {
|
||||||
assistantText,
|
await saveGeneratedImages(
|
||||||
session.user.id,
|
assistantText,
|
||||||
id,
|
session.user.id,
|
||||||
assistantMessageId,
|
id,
|
||||||
params.promptForPersistence || baseReplacementPrompt,
|
assistantMessageId,
|
||||||
resolvedModelRef,
|
params.promptForPersistence || baseReplacementPrompt,
|
||||||
{
|
resolvedModelRef,
|
||||||
sourceAssetIds,
|
{
|
||||||
sourceImageUrls: params.sourceImageUrls,
|
sourceAssetIds,
|
||||||
relationType: "edited_from",
|
sourceImageUrls: params.sourceImageUrls,
|
||||||
sourceType: "detail_replacement",
|
relationType: "edited_from",
|
||||||
projectId: contextPayload?.projectId || null,
|
sourceType: "detail_replacement",
|
||||||
userPrompt: requestTextContent,
|
projectId: contextPayload?.projectId || null,
|
||||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
userPrompt: requestTextContent,
|
||||||
requestedImageCount: 1,
|
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||||
aspectRatio: resolvedAspectRatio,
|
requestedImageCount: 1,
|
||||||
imageSize: persistedImageSize,
|
aspectRatio: resolvedAspectRatio,
|
||||||
},
|
imageSize: persistedImageSize,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (persistError) {
|
} catch (persistError) {
|
||||||
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
||||||
chatId: id,
|
chatId: id,
|
||||||
@ -6143,8 +6148,8 @@ export async function POST(request: Request) {
|
|||||||
modelDisplayName: null,
|
modelDisplayName: null,
|
||||||
sourceImageUrls: referenceSourceImageUrls,
|
sourceImageUrls: referenceSourceImageUrls,
|
||||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||||
|
isFallback: true,
|
||||||
});
|
});
|
||||||
successCount += 1;
|
|
||||||
}
|
}
|
||||||
} catch (replacementPlanError) {
|
} catch (replacementPlanError) {
|
||||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
||||||
@ -6167,8 +6172,8 @@ export async function POST(request: Request) {
|
|||||||
modelDisplayName: null,
|
modelDisplayName: null,
|
||||||
sourceImageUrls: fallbackSourceImageUrls,
|
sourceImageUrls: fallbackSourceImageUrls,
|
||||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||||
|
isFallback: true,
|
||||||
});
|
});
|
||||||
successCount += 1;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@ -588,33 +589,7 @@ function areAttachmentValuesEqual(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePersistedHistoryMessages(
|
const mergePersistedHistoryMessages = mergeGenerationHistory;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAttachmentIdentityKey(
|
function getAttachmentIdentityKey(
|
||||||
attachment:
|
attachment:
|
||||||
@ -723,7 +698,7 @@ function normalizeAssistantText(value: string) {
|
|||||||
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
|
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
|
||||||
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
|
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
|
||||||
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
|
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
|
||||||
.replace(/\n*<!-- AGENT_REPLACEMENT_CARD_ID:[^>]*-->\n*/g, "")
|
.replace(/\n*<!-- AGENT_REPLACEMENT_(?:CARD_ID|RUN_ID|STATUS):[^>]*-->\n*/g, "")
|
||||||
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
|
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@ -2588,6 +2563,7 @@ interface LocalGenerationPendingState {
|
|||||||
modeType: AgentGenerationResultModeType;
|
modeType: AgentGenerationResultModeType;
|
||||||
expectedCount: number;
|
expectedCount: number;
|
||||||
userTurnCountAtStart: number;
|
userTurnCountAtStart: number;
|
||||||
|
requestMessageId?: string | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
requestedCardIds?: string[] | null;
|
requestedCardIds?: string[] | null;
|
||||||
}
|
}
|
||||||
@ -2642,6 +2618,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
|||||||
modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType,
|
modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType,
|
||||||
expectedCount: Number(record.expectedCount),
|
expectedCount: Number(record.expectedCount),
|
||||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||||
|
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||||
timestamp: Number(record.timestamp),
|
timestamp: Number(record.timestamp),
|
||||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||||
? record.requestedCardIds
|
? record.requestedCardIds
|
||||||
@ -3429,7 +3406,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}
|
}
|
||||||
}, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]);
|
}, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]);
|
||||||
|
|
||||||
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
|
const { data: historyData, mutate: refreshHistory } = useSWR<{ messages: ChatMessage[] }>(
|
||||||
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
||||||
fetcher,
|
fetcher,
|
||||||
{
|
{
|
||||||
@ -4170,6 +4147,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
} = useChat<ChatMessage>({
|
} = useChat<ChatMessage>({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: historyMessages,
|
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,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
transport: new DefaultChatTransport({
|
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(() => {
|
useEffect(() => {
|
||||||
if (!historyMessages.length) {
|
if (!historyMessages.length) {
|
||||||
return;
|
return;
|
||||||
@ -4523,8 +4524,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
if (!hasLikelyGenerationErrorText(text)) {
|
if (!hasLikelyGenerationErrorText(text)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const imageUrls = extractImageUrlsFromText(text);
|
return true;
|
||||||
return imageUrls.length === 0;
|
|
||||||
}),
|
}),
|
||||||
[assistantMessagesAfterLastUser]
|
[assistantMessagesAfterLastUser]
|
||||||
);
|
);
|
||||||
@ -4832,6 +4832,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
: currentResultModeType === "sku_replacement"
|
: currentResultModeType === "sku_replacement"
|
||||||
? skuReplacementResultUrls
|
? skuReplacementResultUrls
|
||||||
: currentSceneGeneratedUrls;
|
: 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 transportIsGenerating = status === "streaming" || status === "submitted";
|
||||||
const pendingGenerationProgressCount = useMemo(() => {
|
const pendingGenerationProgressCount = useMemo(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration) {
|
||||||
@ -4853,13 +4859,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return detailGenerationRunResults.length;
|
return detailGenerationRunResults.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return latestReplacementResults.length;
|
return replacementRunRecovery.completedCardIds.length;
|
||||||
}, [
|
}, [
|
||||||
currentSceneGeneratedUrls.length,
|
currentSceneGeneratedUrls.length,
|
||||||
detailGenerationRunResults.length,
|
detailGenerationRunResults.length,
|
||||||
latestReplacementResults.length,
|
latestReplacementResults.length,
|
||||||
pendingGeneration,
|
pendingGeneration,
|
||||||
userMessageCount,
|
userMessageCount,
|
||||||
|
replacementRunRecovery.completedCardIds.length,
|
||||||
]);
|
]);
|
||||||
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
|
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
|
||||||
const isSceneResultModeGenerating =
|
const isSceneResultModeGenerating =
|
||||||
@ -4996,7 +5003,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
generatedCount: pendingGeneration?.modeType === "detail_replacement"
|
generatedCount: pendingGeneration?.modeType === "detail_replacement"
|
||||||
? latestReplacementResults.length
|
? latestReplacementResults.length
|
||||||
: 0,
|
: 0,
|
||||||
isGenerating: isDetailResultModeGenerating,
|
isGenerating: isDetailResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
|
||||||
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@ -5004,6 +5011,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
isDetailResultModeGenerating,
|
isDetailResultModeGenerating,
|
||||||
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
normalizedDeletedResultCollections.detail_replacement,
|
normalizedDeletedResultCollections.detail_replacement,
|
||||||
pendingGeneration?.modeType,
|
pendingGeneration?.modeType,
|
||||||
@ -5055,13 +5063,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
generatedCount: pendingGeneration?.modeType === "sku_replacement"
|
generatedCount: pendingGeneration?.modeType === "sku_replacement"
|
||||||
? latestReplacementResults.length
|
? latestReplacementResults.length
|
||||||
: 0,
|
: 0,
|
||||||
isGenerating: isSkuResultModeGenerating,
|
isGenerating: isSkuResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
|
||||||
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isSkuResultModeGenerating,
|
isSkuResultModeGenerating,
|
||||||
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
normalizedDeletedResultCollections.sku_replacement,
|
normalizedDeletedResultCollections.sku_replacement,
|
||||||
pendingGeneration?.modeType,
|
pendingGeneration?.modeType,
|
||||||
@ -5429,6 +5438,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration) {
|
||||||
return;
|
return;
|
||||||
@ -6385,8 +6402,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
);
|
);
|
||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
setSelectedResultUrl(null);
|
setSelectedResultUrl(null);
|
||||||
|
const requestMessageId = generateUUID();
|
||||||
setPendingGeneration({
|
setPendingGeneration({
|
||||||
chatId,
|
chatId,
|
||||||
|
requestMessageId,
|
||||||
modeType: currentResultModeType,
|
modeType: currentResultModeType,
|
||||||
expectedCount:
|
expectedCount:
|
||||||
currentResultModeType === "scene_generation"
|
currentResultModeType === "scene_generation"
|
||||||
@ -6407,7 +6426,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
|
void Promise.resolve(sendMessage({ id: requestMessageId, role: "user", parts })).catch((error) => {
|
||||||
generationRequestSnapshotRef.current = {
|
generationRequestSnapshotRef.current = {
|
||||||
hiddenPrompt: null,
|
hiddenPrompt: null,
|
||||||
replacementExecutionCardIds: null,
|
replacementExecutionCardIds: null,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||||
import { getGrsaiGptImageOutputSize } from "@/lib/ai/grsai-gpt-image-sizes";
|
import { getGrsaiGptImageOutputSize } from "@/lib/ai/grsai-gpt-image-sizes";
|
||||||
|
import { resolveVip98kImageTaskSize } from "@/lib/ai/vip98k-task-invocation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@ -51,6 +52,7 @@ interface ImageParamsBarProps {
|
|||||||
supportsQuality?: boolean;
|
supportsQuality?: boolean;
|
||||||
supportedAspectRatios?: string[];
|
supportedAspectRatios?: string[];
|
||||||
supportedImageSizes?: string[];
|
supportedImageSizes?: string[];
|
||||||
|
vip98k?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,6 +336,11 @@ export function ImageParamsBar({
|
|||||||
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
|
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
|
||||||
|
|
||||||
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
|
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") {
|
if (providerType === "grsai-gpt-image") {
|
||||||
return getGrsaiGptImageOutputSize(aspectRatio, imageSize);
|
return getGrsaiGptImageOutputSize(aspectRatio, imageSize);
|
||||||
}
|
}
|
||||||
@ -439,24 +446,17 @@ export function ImageParamsBar({
|
|||||||
const currentAspectRatio = isAspectRatioAllowed(params.aspectRatio)
|
const currentAspectRatio = isAspectRatioAllowed(params.aspectRatio)
|
||||||
? params.aspectRatio
|
? params.aspectRatio
|
||||||
: getFallbackAspectRatio();
|
: getFallbackAspectRatio();
|
||||||
const sizeOptionKeySet = new Set<string>();
|
const parameterGroups = [{ title: "比例", kind: "ratio", options:
|
||||||
const sizeOptions = [
|
availableAspectRatioOptions.map((option) => ({
|
||||||
...availableAspectRatioOptions.map((option) => ({
|
|
||||||
label: option.value === "auto" ? "auto" : option.label,
|
label: option.value === "auto" ? "auto" : option.label,
|
||||||
aspectRatio: option.value,
|
aspectRatio: option.value,
|
||||||
imageSize: currentImageSize,
|
imageSize: currentImageSize,
|
||||||
})),
|
})) }, { title: "分辨率", kind: "resolution", options:
|
||||||
...availableSizeOptions.map((option) => ({
|
availableSizeOptions.map((option) => ({
|
||||||
label: option.label,
|
label: option.label,
|
||||||
aspectRatio: currentAspectRatio,
|
aspectRatio: currentAspectRatio,
|
||||||
imageSize: option.value,
|
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 imageCountOptions = getImageCountOptions();
|
||||||
const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio;
|
const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio;
|
||||||
const selectedImageCount = params.imageCount || 1;
|
const selectedImageCount = params.imageCount || 1;
|
||||||
@ -495,8 +495,9 @@ export function ImageParamsBar({
|
|||||||
variant="ghost"
|
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]"
|
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="生成参数"
|
title="生成参数"
|
||||||
|
aria-label={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
|
||||||
>
|
>
|
||||||
{qualityLabel} · {selectedAspectLabel} · {selectedImageCount} img
|
{qualityLabel} · {selectedAspectLabel} · {currentImageSize} · {selectedImageCount} img
|
||||||
{minimalParamsOpen ? (
|
{minimalParamsOpen ? (
|
||||||
<ChevronUp className="ml-1 h-3 w-3" />
|
<ChevronUp className="ml-1 h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
@ -533,7 +534,7 @@ export function ImageParamsBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 text-[12px] font-medium leading-none">尺寸</div>
|
<div className="mb-1.5 text-[12px] font-medium leading-none">预计尺寸</div>
|
||||||
<div className="grid grid-cols-[1fr_20px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[1fr_20px_1fr] items-center gap-2">
|
||||||
<div className="flex h-8 items-center gap-2 rounded-[var(--ui-radius-control)] bg-[#f3f3f4] px-2.5 text-[13px] dark:bg-white/8">
|
<div className="flex h-8 items-center gap-2 rounded-[var(--ui-radius-control)] bg-[#f3f3f4] px-2.5 text-[13px] dark:bg-white/8">
|
||||||
<span className="text-[#68707c] dark:text-zinc-400">W</span>
|
<span className="text-[#68707c] dark:text-zinc-400">W</span>
|
||||||
@ -547,14 +548,15 @@ export function ImageParamsBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sizeOptions.length > 0 && (
|
{parameterGroups.filter((group) => group.options.length > 0).map((group) => (
|
||||||
<div>
|
<div key={group.kind}>
|
||||||
<div className="mb-1.5 text-[12px] font-medium leading-none">Size</div>
|
<div className="mb-1.5 text-[12px] font-medium leading-none">{group.title}</div>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{sizeOptions.map((option) => {
|
{group.options.map((option) => {
|
||||||
const selected =
|
const selected =
|
||||||
params.aspectRatio === option.aspectRatio &&
|
group.kind === "ratio"
|
||||||
(option.aspectRatio === "auto" || params.imageSize === option.imageSize);
|
? params.aspectRatio === option.aspectRatio
|
||||||
|
: params.imageSize === option.imageSize;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${option.aspectRatio}-${option.imageSize}-${option.label}`}
|
key={`${option.aspectRatio}-${option.imageSize}-${option.label}`}
|
||||||
@ -573,7 +575,7 @@ export function ImageParamsBar({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex h-5 items-center justify-center text-[#30353c] dark:text-zinc-200">
|
<span className="flex h-5 items-center justify-center text-[#30353c] dark:text-zinc-200">
|
||||||
{renderMinimalRatioGlyph(option.aspectRatio)}
|
{group.kind === "ratio" ? renderMinimalRatioGlyph(option.aspectRatio) : <Scan className="h-4 w-4" />}
|
||||||
</span>
|
</span>
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -581,7 +583,7 @@ export function ImageParamsBar({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 text-[12px] font-medium leading-none">Image</div>
|
<div className="mb-1.5 text-[12px] font-medium leading-none">Image</div>
|
||||||
|
|||||||
73
lib/agents/replacement-run-recovery.ts
Normal file
73
lib/agents/replacement-run-recovery.ts
Normal file
@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,6 +8,10 @@ import {
|
|||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { createOpenAI } from "@ai-sdk/openai";
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
isVip98kReferenceSizeAllowed,
|
||||||
|
normalizeVip98kReferenceImage,
|
||||||
|
} from "@/lib/ai/vip98k-reference-image";
|
||||||
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
|
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
|
||||||
import {
|
import {
|
||||||
bindVip98kImages,
|
bindVip98kImages,
|
||||||
@ -33,7 +37,6 @@ import {
|
|||||||
getVip98kStagedMediaRetryDelay,
|
getVip98kStagedMediaRetryDelay,
|
||||||
getVip98kSubmissionRetryDelay,
|
getVip98kSubmissionRetryDelay,
|
||||||
isVip98kStagedReferenceUrl,
|
isVip98kStagedReferenceUrl,
|
||||||
shouldStageVip98kReferenceUrl,
|
|
||||||
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
||||||
} from "@/lib/ai/vip98k-reference-staging";
|
} from "@/lib/ai/vip98k-reference-staging";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
@ -650,6 +653,7 @@ async function findStagedReference(
|
|||||||
}),
|
}),
|
||||||
{ abortSignal: AbortSignal.timeout(REFERENCE_CACHE_LOOKUP_TIMEOUT_MS) },
|
{ abortSignal: AbortSignal.timeout(REFERENCE_CACHE_LOOKUP_TIMEOUT_MS) },
|
||||||
);
|
);
|
||||||
|
if (!isVip98kReferenceSizeAllowed(Number(response.ContentLength))) return null;
|
||||||
if (
|
if (
|
||||||
response.LastModified &&
|
response.LastModified &&
|
||||||
Date.now() - response.LastModified.getTime() >
|
Date.now() - response.LastModified.getTime() >
|
||||||
@ -801,23 +805,14 @@ async function uploadReferenceImage(
|
|||||||
return buildS3PublicUrl(stagingConfig.storageConfig, key);
|
return buildS3PublicUrl(stagingConfig.storageConfig, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeReferenceImage(buffer: Buffer, mimeType: string) {
|
async function normalizeReferenceImage(buffer: Buffer, _mimeType: string) {
|
||||||
try {
|
try {
|
||||||
const normalized = await sharp(buffer, { failOn: "none" })
|
return await normalizeVip98kReferenceImage(buffer);
|
||||||
.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",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} 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),
|
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
|
const sourceObjectKey = stagingConfig
|
||||||
? getStorageObjectKeyFromPublicUrl(normalized, stagingConfig.sourcePublicUrl)
|
? getStorageObjectKeyFromPublicUrl(normalized, stagingConfig.sourcePublicUrl)
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (!stagingConfig) {
|
||||||
!stagingConfig ||
|
const downloaded = await downloadReferenceImage(normalized);
|
||||||
sourceObjectKey?.startsWith(VIP98K_REFERENCE_STAGING_KEY_PREFIX) ||
|
const prepared = await normalizeReferenceImage(
|
||||||
!shouldStageVip98kReferenceUrl({
|
downloaded.buffer,
|
||||||
mediaUrl: normalized,
|
downloaded.mimeType,
|
||||||
sourcePublicUrl: stagingConfig.sourcePublicUrl,
|
);
|
||||||
})
|
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;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -937,9 +938,12 @@ async function ensurePublicMediaUrl(
|
|||||||
);
|
);
|
||||||
return uploadReferenceImage(normalizedImage.buffer, stagingConfig);
|
return uploadReferenceImage(normalizedImage.buffer, stagingConfig);
|
||||||
}
|
}
|
||||||
|
const prepared = mediaKind === "image"
|
||||||
|
? await normalizeReferenceImage(parsed.buffer, parsed.mimeType)
|
||||||
|
: { buffer: parsed.buffer, extension };
|
||||||
const uploaded = await uploadFile(
|
const uploaded = await uploadFile(
|
||||||
`vip98k-ref-${suffix}.${extension}`,
|
`vip98k-ref-${suffix}.${prepared.extension}`,
|
||||||
toArrayBuffer(parsed.buffer),
|
toArrayBuffer(prepared.buffer),
|
||||||
);
|
);
|
||||||
return uploaded.url;
|
return uploaded.url;
|
||||||
}
|
}
|
||||||
@ -1118,6 +1122,7 @@ async function submitTask(params: {
|
|||||||
path,
|
path,
|
||||||
attempt,
|
attempt,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
|
providerRequestId: response.headers.get("x-request-id"),
|
||||||
attemptDurationMs: Date.now() - attemptStartedAt,
|
attemptDurationMs: Date.now() - attemptStartedAt,
|
||||||
totalDurationMs: Date.now() - submissionStartedAt,
|
totalDurationMs: Date.now() - submissionStartedAt,
|
||||||
});
|
});
|
||||||
@ -1271,6 +1276,9 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
),
|
),
|
||||||
maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts,
|
maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts,
|
||||||
bodyKeys: Object.keys(body).sort(),
|
bodyKeys: Object.keys(body).sort(),
|
||||||
|
requestedSize: body.size,
|
||||||
|
requestedAspectRatio: body.aspect_ratio || body.aspectRatio || body.ratio,
|
||||||
|
requestedQuality: body.quality,
|
||||||
});
|
});
|
||||||
const submitted = await submitTask({
|
const submitted = await submitTask({
|
||||||
apiKey: params.apiKey,
|
apiKey: params.apiKey,
|
||||||
|
|||||||
@ -670,6 +670,9 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
modelType: type,
|
modelType: type,
|
||||||
|
...(type === "image" && hasVerifiedVip98kOpenAiImageArrayContract(getModelId(model))
|
||||||
|
? { resolutionSizing: "gpt-area" }
|
||||||
|
: {}),
|
||||||
taskApi:
|
taskApi:
|
||||||
String(invocation?.kind || "").toLowerCase() === "tool"
|
String(invocation?.kind || "").toLowerCase() === "tool"
|
||||||
? "external-tool"
|
? "external-tool"
|
||||||
@ -728,8 +731,12 @@ function createImagePreset(
|
|||||||
const supportsNativeAspectRatio = [
|
const supportsNativeAspectRatio = [
|
||||||
"ratio",
|
"ratio",
|
||||||
"aspect-ratio",
|
"aspect-ratio",
|
||||||
].some((key) => invocationKeys.has(key)) || hasVerifiedResolutionTiers;
|
].some((key) => invocationKeys.has(key)) || hasVerifiedVip98kOpenAiImageArrayContract(modelId);
|
||||||
const supportsQuality = invocationKeys.has("quality");
|
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 = [
|
const supportedModes = [
|
||||||
...(capabilities.includes("text-to-image") || capabilities.length === 0
|
...(capabilities.includes("text-to-image") || capabilities.length === 0
|
||||||
? ["text-to-image"]
|
? ["text-to-image"]
|
||||||
@ -764,9 +771,7 @@ function createImagePreset(
|
|||||||
inputModes,
|
inputModes,
|
||||||
outputModes,
|
outputModes,
|
||||||
supportedImageSizes: supportsNativeSize ? supportedImageSizes : [],
|
supportedImageSizes: supportsNativeSize ? supportedImageSizes : [],
|
||||||
supportedAspectRatios: supportsNativeAspectRatio
|
supportedAspectRatios,
|
||||||
? [...DEFAULT_ASPECT_RATIOS]
|
|
||||||
: ["auto"],
|
|
||||||
supportsMultiImageReference,
|
supportsMultiImageReference,
|
||||||
maxReferenceImages,
|
maxReferenceImages,
|
||||||
vip98k: getVip98kMetadata(model, "image"),
|
vip98k: getVip98kMetadata(model, "image"),
|
||||||
@ -792,9 +797,7 @@ function createImagePreset(
|
|||||||
supports2K: supportsNativeSize && supportedImageSizes.includes("2K"),
|
supports2K: supportsNativeSize && supportedImageSizes.includes("2K"),
|
||||||
supports4K: supportsNativeSize && supportedImageSizes.includes("4K"),
|
supports4K: supportsNativeSize && supportedImageSizes.includes("4K"),
|
||||||
supportedImageSizes: supportsNativeSize ? supportedImageSizes : [],
|
supportedImageSizes: supportsNativeSize ? supportedImageSizes : [],
|
||||||
supportedAspectRatios: supportsNativeAspectRatio
|
supportedAspectRatios,
|
||||||
? [...DEFAULT_ASPECT_RATIOS]
|
|
||||||
: ["auto"],
|
|
||||||
supportsPromptExtend: false,
|
supportsPromptExtend: false,
|
||||||
supportsNegativePrompt: false,
|
supportsNegativePrompt: false,
|
||||||
supportsWatermark: false,
|
supportsWatermark: false,
|
||||||
|
|||||||
23
lib/ai/vip98k-reference-image.ts
Normal file
23
lib/ai/vip98k-reference-image.ts
Normal file
@ -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 限制,请缩小图片后重试");
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { isMediaInputFailure } from "@/lib/ai/runtime/request-failure-classification";
|
import { isMediaInputFailure } from "@/lib/ai/runtime/request-failure-classification";
|
||||||
|
|
||||||
export const VIP98K_REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-";
|
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) {
|
function parseUrl(value: string) {
|
||||||
try {
|
try {
|
||||||
@ -85,6 +85,7 @@ export function getVip98kStagedMediaRetryDelay(params: {
|
|||||||
params.status !== 400 ||
|
params.status !== 400 ||
|
||||||
params.stagedReferenceCount < 1 ||
|
params.stagedReferenceCount < 1 ||
|
||||||
params.attempt >= params.maxAttempts ||
|
params.attempt >= params.maxAttempts ||
|
||||||
|
/(?:larger than|too large|exceeds?|maximum).*(?:MiB|MB|bytes|size)/i.test(params.message) ||
|
||||||
!isMediaInputFailure(params.message)
|
!isMediaInputFailure(params.message)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -61,6 +61,10 @@ export function resolveVip98kImageTaskSize(params: {
|
|||||||
.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
|
.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
|
||||||
const sizeBucket = String(params.imageSize || "").trim().toUpperCase();
|
const sizeBucket = String(params.imageSize || "").trim().toUpperCase();
|
||||||
const longestEdge = VIP98K_IMAGE_SIZE_LONGEST_EDGE[sizeBucket];
|
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 (
|
if (
|
||||||
params.useGptImage25AspectSizing &&
|
params.useGptImage25AspectSizing &&
|
||||||
ratioMatch?.[1] &&
|
ratioMatch?.[1] &&
|
||||||
@ -85,9 +89,14 @@ export function resolveVip98kImageTaskSize(params: {
|
|||||||
clampDimension(Math.floor(value / 16) * 16);
|
clampDimension(Math.floor(value / 16) * 16);
|
||||||
const shouldFloorWidth = sizeBucket !== "4K" && width < height;
|
const shouldFloorWidth = sizeBucket !== "4K" && width < height;
|
||||||
const shouldFloorHeight = sizeBucket !== "4K" && height < width;
|
const shouldFloorHeight = sizeBucket !== "4K" && height < width;
|
||||||
return `${shouldFloorWidth ? floorToSixteen(width) : roundToSixteen(width)}x${
|
let outputWidth = shouldFloorWidth ? floorToSixteen(width) : roundToSixteen(width);
|
||||||
shouldFloorHeight ? floorToSixteen(height) : roundToSixteen(height)
|
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;
|
if (longestEdge && params.preserveResolutionBucket) return sizeBucket;
|
||||||
@ -136,11 +145,14 @@ const KNOWN_IMAGE_TOOL_INPUTS: Record<string, KnownImageInput> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VERIFIED_OPENAI_IMAGE_ARRAY_MODEL_IDS = new Set([
|
const VERIFIED_OPENAI_IMAGE_ARRAY_MODEL_IDS = new Set([
|
||||||
|
"gpt-image-2-vip",
|
||||||
"gpt-image-2.5",
|
"gpt-image-2.5",
|
||||||
"gpt-image-2.5-flare",
|
"gpt-image-2.5-flare",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const VERIFIED_GPT_IMAGE_25_RESOLUTION_MODEL_IDS = new Set([
|
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",
|
"gpt-image-2.5-flare",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
74
scripts/vip98k-matrix-probe.cjs
Normal file
74
scripts/vip98k-matrix-probe.cjs
Normal file
@ -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(`<svg width="512" height="512"><rect width="512" height="512" fill="${color}"/><text x="256" y="345" text-anchor="middle" fill="white" font-family="sans-serif" font-size="260">${label}</text></svg>`)).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;i<pix.length;i+=info.channels){const [r,g,b]=[pix[i],pix[i+1],pix[i+2]];if(r>100&&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<jobs.length){const job=jobs[cursor++];await run(job);}}));
|
||||||
|
fs.writeFileSync(path.join(out,'completed.json'),JSON.stringify({finishedAt:new Date().toISOString(),results},null,2));console.log(JSON.stringify({event:'complete',count:results.length}));
|
||||||
|
})().catch(e=>{console.error(sanitize(e.message));process.exit(1)});
|
||||||
@ -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 { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
bindVip98kImages,
|
bindVip98kImages,
|
||||||
@ -126,7 +134,7 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
const source = "https://storage.example/uploads/reference.png";
|
const source = "https://storage.example/uploads/reference.png";
|
||||||
const key = getVip98kReferenceStagingObjectKey(source);
|
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(
|
expect(
|
||||||
getVip98kReferenceStagingObjectKey(`${source}?sg_media_retry=retry-2`),
|
getVip98kReferenceStagingObjectKey(`${source}?sg_media_retry=retry-2`),
|
||||||
).toBe(key);
|
).toBe(key);
|
||||||
@ -557,10 +565,10 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
).toBe(false);
|
).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([
|
const presets = buildVip98kModelPresets([
|
||||||
{
|
{
|
||||||
id: "gpt-image-2-vip",
|
id: "unverified-image-model",
|
||||||
type: "image",
|
type: "image",
|
||||||
capabilities: ["openai.images.generations", "text_to_image"],
|
capabilities: ["openai.images.generations", "text_to_image"],
|
||||||
invocation: {
|
invocation: {
|
||||||
@ -568,7 +576,7 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/v1/images/generations",
|
path: "/v1/images/generations",
|
||||||
content_type: "application/json",
|
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: {
|
capabilities: {
|
||||||
supportsMultiImageReference: true,
|
supportsMultiImageReference: true,
|
||||||
maxReferenceImages: 8,
|
maxReferenceImages: 8,
|
||||||
supportedImageSizes: ["1K"],
|
supportedImageSizes: ["1K", "2K", "4K"],
|
||||||
},
|
},
|
||||||
imageGenConfig: {
|
imageGenConfig: {
|
||||||
supportsSize: true,
|
supportsSize: true,
|
||||||
supports1K: true,
|
supports1K: true,
|
||||||
supports2K: false,
|
supports2K: true,
|
||||||
supports4K: false,
|
supports4K: true,
|
||||||
supportedImageSizes: ["1K"],
|
supportedImageSizes: ["1K", "2K", "4K"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -674,7 +682,7 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
isHidden: false,
|
isHidden: false,
|
||||||
supportsQuality: true,
|
supportsQuality: true,
|
||||||
quality: "auto",
|
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",
|
modelId: "gpt-image-2.5-flare",
|
||||||
@ -690,8 +698,6 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
"9:16",
|
"9:16",
|
||||||
"3:2",
|
"3:2",
|
||||||
"2:3",
|
"2:3",
|
||||||
"4:5",
|
|
||||||
"5:4",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -744,8 +750,6 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
"9:16",
|
"9:16",
|
||||||
"3:2",
|
"3:2",
|
||||||
"2:3",
|
"2:3",
|
||||||
"4:5",
|
|
||||||
"5:4",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
defaultParams: { imageSize: "2K" },
|
defaultParams: { imageSize: "2K" },
|
||||||
@ -871,7 +875,7 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
preserveResolutionBucket: true,
|
preserveResolutionBucket: true,
|
||||||
useGptImage25AspectSizing: true,
|
useGptImage25AspectSizing: true,
|
||||||
}),
|
}),
|
||||||
).toBe("2K");
|
).toBe("2048x2048");
|
||||||
expect(
|
expect(
|
||||||
resolveVip98kImageTaskSize({
|
resolveVip98kImageTaskSize({
|
||||||
imageSize: "2K",
|
imageSize: "2K",
|
||||||
@ -892,6 +896,15 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
).toBe("2352x3520");
|
).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", () => {
|
test("advertises verified Flare resolutions and aspect-ratio controls", () => {
|
||||||
const [preset] = buildVip98kModelPresets([
|
const [preset] = buildVip98kModelPresets([
|
||||||
{
|
{
|
||||||
@ -934,9 +947,88 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
"9:16",
|
"9:16",
|
||||||
"3:2",
|
"3:2",
|
||||||
"2:3",
|
"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:
|
||||||
|
`<!-- AGENT_REPLACEMENT_RUN_ID:${run} -->\n<!-- AGENT_REPLACEMENT_CARD_ID:${card} -->\n${failed ? "生成失败,当前先沿用参考图" : "完成"}\n` }],
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user