From aaa650265c21a92cc23a54789aad6ed2561ed623 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 11 Sep 2026 14:52:38 +0800 Subject: [PATCH 01/28] fix: verify VIP98K sizes and recover replacement results --- app/(chat)/api/chat/route.ts | 49 +++---- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 89 ++++++++----- components/image-params-bar.tsx | 46 +++---- lib/agents/replacement-run-recovery.ts | 73 +++++++++++ lib/ai/providers/vip98k.ts | 56 ++++---- lib/ai/vip98k-models.ts | 17 ++- lib/ai/vip98k-reference-image.ts | 23 ++++ lib/ai/vip98k-reference-staging.ts | 3 +- lib/ai/vip98k-task-invocation.ts | 18 ++- scripts/vip98k-matrix-probe.cjs | 74 +++++++++++ tests/routes/vip98k-reference-routing.test.ts | 124 +++++++++++++++--- 11 files changed, 442 insertions(+), 130 deletions(-) create mode 100644 lib/agents/replacement-run-recovery.ts create mode 100644 lib/ai/vip98k-reference-image.ts create mode 100644 scripts/vip98k-matrix-probe.cjs 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); + }); +}); From 0aff83f61afd1d2c0acde2168dd3801f400ccf5a Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 11 Sep 2026 15:10:20 +0800 Subject: [PATCH 02/28] docs: record VIP98K live matrix and deployment evidence --- docs/VIP98K排查-2026-09-11.md | 84 +++++++++++++++++++++++++++++++++ scripts/vip98k-matrix-probe.cjs | 3 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 docs/VIP98K排查-2026-09-11.md diff --git a/docs/VIP98K排查-2026-09-11.md b/docs/VIP98K排查-2026-09-11.md new file mode 100644 index 000000000..a5032cd60 --- /dev/null +++ b/docs/VIP98K排查-2026-09-11.md @@ -0,0 +1,84 @@ +# VIP98K 图片参数、参考图限制与详情替换状态修复 + +2026-09-11,北京时间。运行修复已上线至 `ai.yydd.ai`。服务器代码版本 `b26e2650`,构建 ID `bEps6MRbyVkKLfBQTUChg`;本地对应提交 `aaa65026`。 + +## 实测结论 + +读取[服务商公开目录](https://api.vip98k.com/api/v2/catalog/models)及当前账户授权列表,测试其中 9 个生图模型。不含 OCR、抠图、压缩等处理工具,也不包含其他服务商。共 77 次尺寸、比例及质量调用:64 次取得结果图,13 次接口失败;另完成 1 次超过 10MiB 原图继续编辑的完整参考图处理调用。出图成功不等于尺寸或比例符合要求。 + +[实测图片与全部记录](./qa-vip98k-0911/index.html)提供原图、实际像素、耗时、请求编号和 JSON;这些大体积验收附件保存在本地,未纳入 Git。测试使用合成蓝 A / 红 B 卡片,验证接口与返回参数,不代表复杂商品细节一致性的验收。 + +| 模型 | 真实结果 | 本次处理 | +| --- | --- | --- | +| gpt-image-2-vip | 方图 1K=1024²、2K=2048²、4K=2880²;横图亦成功。2K 覆盖 9 个固定比例 | 恢复 1K/2K/4K 与比例;同步目录缺字段不再覆盖已验证能力 | +| gpt-image-2.5 | 请求 1024²、2048²、2880² 都返回约 1254²;2K、4K 档位字符串也只返回约 1672×940 横图。9:16、4:5、5:4 成功 | 恢复比例,保留真实可用的 1K 档;不虚报 2K/4K | +| gpt-image-2.5-flare | 三档有实图;2K 的 2736×1536 会变成 3840×2160。4:5、5:4 即使显式传 aspect_ratio,仍变成 3:4、4:3 | 修正跨档边界为 2720×1536,实收 2048×1152;保留 7 个已验证固定比例 | +| nano-banana-2 | 方图三档 1024²/2048²/4096²;4K 横图 5504×3072 | 保留三档和比例 | +| nano-banana-pro | 方图三档 1024²/2048²/4096²;4K 横图 5504×3072 | 保留三档和比例 | +| gpt-image-2 | multipart edits 方图 2K 成功;初测超大尺寸遇到 502/503,调整后 2K、4K 横图成功 | 记录原生 edits 证据;网站尚未启用该 multipart 线路,未偷偷改为其他接口或模型 | +| gpt-image-2.5-sunburst | 三档成功;2K 横图同样存在升到 4K 的边界现象 | 保持网站原有隐藏状态 | +| nano-banana-2-lite | 双参考与无参考各一次均 HTTP 400 | 保持不可选 | +| nano-banana-fast | 双参考与无参考各一次均 HTTP 400 | 保持不可选 | + +覆盖方式:各模型基础调用;可成功的主要线路补测三档方图及高分辨率横图;VIP/Flare 补测 2K 的全部原有固定比例,并对异常做对照。不是每个模型、每个比例、每个档位的完整笛卡尔积测试。 + +普通 GPT Image 2.5 的 2K/4K 不是“没公开所以推断不支持”,而是实际请求已接受、实际像素未提高。VIP 与 Flare 的 4K 方图是 2880²;不能把所有模型的 4K 都解释为 4096²。 + +## 为什么界面连 1K 都消失 + +目录缺少尺寸和比例数据时,原同步规则回退到“1K、auto”;此外,简洁参数面板把比例和分辨率放进同一数组,并按 `aspectRatio:imageSize` 去重,导致“auto”覆盖了“1K”按钮。 + +已拆成“比例”和“分辨率”两组,修正各自选中状态,并在收起后的按钮上显示当前分辨率。按验证结果更新了现有 5 个 VIP98K 模型的尺寸、比例能力,保留模型价格、启停状态和既有业务配置。Flare 的 4:5、5:4 因反复偏离请求未继续展示。 + +## 10MiB 只约束送入服务商的参考副本 + +网站上传上限是 20MiB;用户报错描述的是服务商单张参考图 10MiB。这不限制生成结果原图,也不会为了 10MiB 改写结果原件。 + +实际闭环: + +- 将本次成功生成的 10,622,997 字节结果原图保存在对象存储。 +- 使用修复后的真实服务商适配器,把它作为下一次生成的参考图。 +- 生成 JPEG 参考副本 451,639 字节;准备阶段约 17.5 秒;完整测试约 83.2 秒并成功返回新图。 +- 下载原件核对 SHA-256,内容完全未变。 + +统一覆盖外部图片地址、Data URL 和无默认 S3 配置分支;压缩失败停止提交,不再透传原始字节。压缩后检查字节上限,新缓存命名空间隔离历史未校验对象,缓存 HEAD 校验大小。仅参考副本沿用原有最长边 1920 策略,完整保留画面;结果原图归档流程未增加 10MiB 限制。 + +## 张华的三次详情替换与页面一直处理中 + +会话 `0f1c5be0-4522-4300-abcb-a93e17a60001`,三次都重跑“拼合切片 2”,实际使用 `gpt-image-2.5-flare`,每次提交 2 张参考图。 + +| 北京时间 | 总耗时 | 前置识别 | 生图调用阶段 | 结果 | +| --- | ---: | ---: | ---: | --- | +| 13:24:30–13:25:07 | 36.483s | 27.070s | 8.773s | HTTP 400 | +| 13:32:06–13:33:14 | 67.919s | 14.824s | 52.337s | 两次 502 后 HTTP 400 | +| 13:34:33–13:34:56 | 23.114s | 13.750s | 8.305s | HTTP 400 | + +第二次有 15 秒和 30 秒重试等待,共 45 秒。参考图准备分别 394ms、2ms、0ms,不是上传大图造成这三次等待。 + +这些记录中的图片是失败后展示的参考图。原代码把回退参考图保存进 GeneratedImage 并递增成功数,造成“日志里有图片”的误导。现在失败保留清楚提示,但不再记作成功生成;没有删除历史三条记录。 + +页面另有独立缺陷: + +1. 工作区没有消费后台 `data-appendMessage` 结果事件。 +2. 历史同步只比较消息 ID,可能忽略同一消息的内容更新。 +3. 进行中状态依赖流状态或结果数量,重跑旧卡片、并行乱序以及失败附带参考图时会判断失误。 + +已补齐结果事件处理、按请求 ID/卡片 ID 判定结束、内容同步、失败结束状态。切回页面、网络恢复和 pageshow 触发历史同步;所有目标卡片结束后主动关闭遗留流连接并清除本地“处理中”。旧的本地待处理记录也能从历史恢复。 + +没有证据证明张华当时一定切换过浏览器。后台标签页可能延迟前端刷新,但不能据此代替上述已确认的代码原因。 + +## 服务商 400 仍有未确认因素 + +原三次日志没有实际 size、quality 与服务商请求编号,无法追溯并确定是哪一个字段导致拒绝;也没有证据把用户贴出的 10MiB 错误与这三次绑定。 + +修复已记录尺寸、比例、质量和 `x-request-id`。上线后另一次 VIP 调用出现 400,但同质量合成测试成功。Flare 后续的高、中、自动质量都出现过 400;同一路线此前大量自动质量调用成功,中质量亦有成功。因此没有把“高质量不支持”当成定论,也未据此隐藏质量选项。对照请求号及结果保存在实测记录中,可交给服务商定位。不能承诺已经消除服务商自身的拒绝或波动。 + +## 上线与验证 + +- 42 项相关单元/回归测试通过;包含真实大于 10MiB 图片压缩、损坏数据拒绝、模型路由、像素边界及历史恢复。 +- 浏览器使用真实 AI SDK 验证:结果乱序、失败附图、流保持打开、恢复历史后的状态结束;真实参数组件验证 4K/16:9 切换和仅 1K 时按钮不丢失。 +- TypeScript 与生产构建通过。候选版本首页/登录页 HTTP 200,受保护模型/聊天 API 返回预期 401,风格化页返回预期认证跳转;生产域名检查通过。 +- 先将新请求切到候选实例,等待原主进程连接归零后更新主实例,再将新请求切回主实例。保留旧静态资源,避免已打开页面加载旧分块失败。 +- 回滚构建:服务器 `backups/next-before-vip98k-0911`;模型配置备份:`backups/vip98k-sizing-0911-1789109819739.json`;Nginx 原配置:`backups/vip98k-nginx-0911.conf`。 + +已经打开的页面需要刷新一次载入新前端。之后切回浏览器会主动补同步,无须靠反复刷新判断是否完成。 diff --git a/scripts/vip98k-matrix-probe.cjs b/scripts/vip98k-matrix-probe.cjs index 5086c189b..d31ef9ad8 100644 --- a/scripts/vip98k-matrix-probe.cjs +++ b/scripts/vip98k-matrix-probe.cjs @@ -44,7 +44,8 @@ function b64(x){if(!x||typeof x!=='object')return null;if(x.b64_json)return x.b6 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(process.env.PROBE_QUALITY)body.quality=process.env.PROBE_QUALITY; + record.quality=body.quality;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; From b45d9ee314a26cf41f080c1829b87bd335ada5d4 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 14 Sep 2026 09:09:31 +0800 Subject: [PATCH 03/28] fix: stop Claw artifact request loops and restore CAD actions --- components/canvas/CanvasChatPanel.tsx | 2 - .../OpenClawInteractiveModelViewer.tsx | 5 ++- .../chat-panel/OpenClawModel3DPreview.tsx | 43 ++++++++++++++----- docs/incidents/claw-3d-actions-2026-09-14.md | 24 +++++++++++ lib/openclaw-chat/model-interaction.ts | 15 +++++++ lib/security/rate-limiter.ts | 4 ++ proxy.ts | 28 ++++++++++++ tests/routes/openclaw-3d-artifacts.test.ts | 34 +++++++++++++++ 8 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 docs/incidents/claw-3d-actions-2026-09-14.md diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index e53bb72b7..d1da8a602 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -16870,8 +16870,6 @@ export function CanvasChatPanel({ [part.nodeName, part]) ); cloned.traverse((node) => { - const part = partsByNodeName.get(node.name); + const part = getOpenClawPartForSceneNode(node, partsByNodeName); if (!part) return; const offset = getOpenClawSourceNodeOffset( part, @@ -218,7 +219,7 @@ function ModelInteractionScene({ const entries = new Map(); const objectEntries = new Map(); scene.traverse((node) => { - const part = partsByNodeName.get(node.name); + const part = getOpenClawPartForSceneNode(node, partsByNodeName); if (!part || entries.has(part.nodeName)) return; const entry = { node, diff --git a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx index 9c2bf0a59..d1b37b35f 100644 --- a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx +++ b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx @@ -140,6 +140,9 @@ function OpenClawProductionDrawingPanel({ const [manifest, setManifest] = useState(); const [manifestError, setManifestError] = useState(false); + // The parent normalizes artifacts on each render. Object identity must not + // restart an in-flight request (or refetch an already loaded manifest). + const manifestIndex = manifestArtifact?.index ?? -1; const proxyUrl = useCallback( (index: number, download = false) => @@ -153,14 +156,13 @@ function OpenClawProductionDrawingPanel({ ); useEffect(() => { - if (!manifestArtifact) { + if (manifestIndex < 0) { setManifest(null); return; } const controller = new AbortController(); setManifestError(false); - fetch(proxyUrl(manifestArtifact.index), { - cache: "no-store", + fetch(proxyUrl(manifestIndex), { signal: controller.signal, }) .then(async (response) => { @@ -177,7 +179,7 @@ function OpenClawProductionDrawingPanel({ setManifestError(true); }); return () => controller.abort(); - }, [manifestArtifact, proxyUrl]); + }, [manifestIndex, proxyUrl]); if (!manifestArtifact && pdfs.length === 0 && dxfBundles.length === 0) { return null; @@ -479,6 +481,9 @@ export function OpenClawModel3DPreview({ const [manifest, setManifest] = useState(); const [manifestError, setManifestError] = useState(null); + const interactionManifestIndex = bundle?.manifestIndex ?? -1; + const [failedPreviewUrl, setFailedPreviewUrl] = useState(null); + const [previewRetry, setPreviewRetry] = useState(0); const [selectedPart, setSelectedPart] = useState(null); const [selectedPackagingPart, setSelectedPackagingPart] = @@ -642,8 +647,8 @@ export function OpenClawModel3DPreview({ }, [closeViewer, isActive]); useEffect(() => { - if (!isActive || !bundle || manifest !== undefined) return; - if (bundle.manifestIndex < 0) { + if (!isActive || manifest !== undefined) return; + if (interactionManifestIndex < 0) { setManifest(null); return; } @@ -651,10 +656,10 @@ export function OpenClawModel3DPreview({ const manifestUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, - artifactIndex: bundle.manifestIndex, + artifactIndex: interactionManifestIndex, }); setManifestError(null); - fetch(manifestUrl, { cache: "no-store", signal: controller.signal }) + fetch(manifestUrl, { signal: controller.signal }) .then(async (response) => { if (!response.ok) { throw new Error(`交互清单读取失败(HTTP ${response.status})`); @@ -673,7 +678,7 @@ export function OpenClawModel3DPreview({ ); }); return () => controller.abort(); - }, [bundle, chatId, isActive, manifest, messageId]); + }, [interactionManifestIndex, chatId, isActive, manifest, messageId]); useEffect(() => { if (viewMode === "product" && !productModel && packagingModel) { @@ -1093,12 +1098,13 @@ export function OpenClawModel3DPreview({ ) ) : (
- {previewUrl ? ( + {previewUrl && failedPreviewUrl !== previewUrl ? ( {`${model.displayName setFailedPreviewUrl(previewUrl)} /> ) : ( @@ -1111,6 +1117,21 @@ export function OpenClawModel3DPreview({ 查看 3D + {previewUrl && failedPreviewUrl === previewUrl ? ( +
+

静态预览加载失败,可以直接查看 3D 或重试预览。

+ +
+ ) : null}
)}
diff --git a/docs/incidents/claw-3d-actions-2026-09-14.md b/docs/incidents/claw-3d-actions-2026-09-14.md new file mode 100644 index 000000000..ca11a749b --- /dev/null +++ b/docs/incidents/claw-3d-actions-2026-09-14.md @@ -0,0 +1,24 @@ +# Claw 3D 预览与快捷回复故障(2026-09-14) + +## 现场证据 + +花城“产品成本评估”对话,08:00:11(北京时间)发起的报价任务已完成,回复含 31 个附件;BOM 成本 182.25 元、出厂报价 210.61 元,与反馈截图一致。整装 GLB、静态预览和交互清单均可读取,未重新生成或修改用户对话。 + +同一回复的 production-drawings.json 被重复请求。09:00 前的 Nginx 日志样本中,该文件有 368 次 499、1,365 次 429;08:15:40 assembled-preview.png 返回 429,08:16:20 对话 POST 返回 429。附件读取和对话提交原先共享通用 API 每分钟 100 次/IP 的预算。日志证实快捷按钮有提交请求到达服务端,不能把这次失败归因为用户没点中或切换浏览器。 + +## 修复 + +- 生产图纸和交互清单 effect 依赖稳定的附件索引、对话 ID 和消息 ID,避免父组件重新规范化附件对象时取消、重发请求;恢复使用私有 HTTP 缓存。 +- Claw 附件 GET 使用独立的每分钟 600 次/用户预算,匿名访问按 IP,仍保留鉴权、所有权校验、SSRF 防护及 429/Retry-After。 +- 静态预览失败时显示明确提示和手动重试,保留“查看 3D”入口。 +- 已完成回复的快捷按钮不再被展示用的打字动画禁用;实际任务运行期间仍禁用,并保留发送去重保护。 +- GLTFLoader 会规范化 `SOURCE-001:1` 等节点名,交互清单保留原名。按加载器保存的 `userData.name` 匹配,恢复配件选中、拆解、组合及包装操作。 + +## 验证 + +- Chrome 中渲染实际预览组件,重建附件对象并每 20ms 更新父组件:修复前清单请求 121 次,修复后 1 次。 +- 人为让首个静态预览请求返回 429,手动重试后图片正常显示。 +- 从实际组件取快捷按钮及其禁用表达式:打字动画仍为 true 时点击可提交正确的确认文本;进入任务运行状态后禁止重复点击。发送回调在测试环境内记录,未向真实对话发送消息。 +- 使用现场实际 GLB 和交互清单:整装模型 23/23 配件匹配,拆解前后 Canvas 截图不同;包装模型可加载,包装层全部匹配。真实文件仅保存在本地 QA 目录,未提交仓库。 +- GLTFLoader 原始节点名回归测试覆盖两个实例、普通名字和不存在的节点;限流测试覆盖 600 次上限、对话预算隔离和另一用户隔离。 +- 27 项相关测试通过;TypeScript 检查、Next.js 生产构建及 git diff --check 通过。构建有既存 HEIC 动态 require 警告。 diff --git a/lib/openclaw-chat/model-interaction.ts b/lib/openclaw-chat/model-interaction.ts index 1b7c6d8dd..3f4a59179 100644 --- a/lib/openclaw-chat/model-interaction.ts +++ b/lib/openclaw-chat/model-interaction.ts @@ -89,6 +89,21 @@ const MAX_ACTIONS = 32; const MAX_TEXT_LENGTH = 500; const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000; +export function getOpenClawPartForSceneNode( + node: { name: string; userData?: Record }, + partsByNodeName: ReadonlyMap +): T | undefined { + // GLTFLoader sanitizes names such as SOURCE-001:1 to SOURCE-0011, but + // preserves the original GLB node name in userData.name. The manifest uses + // that original name, including the instance suffix. + const originalName = node.userData?.name; + return ( + (typeof originalName === "string" + ? partsByNodeName.get(originalName) + : undefined) ?? partsByNodeName.get(node.name) + ); +} + function readRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/lib/security/rate-limiter.ts b/lib/security/rate-limiter.ts index dd08c8066..2eee24dad 100644 --- a/lib/security/rate-limiter.ts +++ b/lib/security/rate-limiter.ts @@ -89,6 +89,10 @@ export const rateLimiters = { // 图片代理接口:每分钟3000次请求(图片列表/画布高频加载) imageProxy: new RateLimiter(60000, 3000), + // CAD replies include many previews, drawings and range requests. Keep their + // read budget separate from conversation submissions, scoped per user. + clawdArtifact: new RateLimiter(60000, 600), + // 高频只读接口(首页/画布轮询):每分钟1200次请求 uiPolling: new RateLimiter(60000, 1200), diff --git a/proxy.ts b/proxy.ts index 777dcba96..2c23ea941 100644 --- a/proxy.ts +++ b/proxy.ts @@ -461,6 +461,34 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) { } ); } + } else if ( + pathname === "/api/canvas/clawd-chat/artifact" && + request.method === "GET" + ) { + const token = await resolveToken(); + const userId = typeof token?.sub === "string" && token.sub ? token.sub : null; + const artifactRateLimit = checkRateLimitByIdentifier( + userId ? `user:${userId}:clawd-artifact` : `ip:${clientIP}:clawd-artifact`, + rateLimiters.clawdArtifact + ); + if (!artifactRateLimit.allowed) { + const retryAfter = Math.max( + 1, + Math.ceil((artifactRateLimit.resetTime - Date.now()) / 1000) + ); + return withAccessLog( + NextResponse.json( + { error: `附件读取过于频繁,请 ${retryAfter} 秒后重试`, retryAfter }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ), + { + outcome: "rate_limited", + severity: "watch", + reasons: ["Claw附件读取限流"], + userId, + } + ); + } } else if (pathname === "/api/image-proxy" && request.method === "GET") { const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy); if (!imageProxyRateLimit.allowed) { diff --git a/tests/routes/openclaw-3d-artifacts.test.ts b/tests/routes/openclaw-3d-artifacts.test.ts index 08e1de730..f1b04035b 100644 --- a/tests/routes/openclaw-3d-artifacts.test.ts +++ b/tests/routes/openclaw-3d-artifacts.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "@playwright/test"; +import { GLTFLoader } from "three-stdlib"; +import { checkRateLimitByIdentifier, rateLimiters } from "@/lib/security/rate-limiter"; import { buildOpenClawArtifactProxyUrl, extractOpenClawArtifacts, @@ -9,6 +11,7 @@ import { } from "@/lib/openclaw-chat/artifacts"; import { getOpenClawExplodedOffsetMm, + getOpenClawPartForSceneNode, getOpenClawPackagingInteractionParts, getOpenClawSourceNodeOffset, mapOpenClawCadOffsetToGlb, @@ -34,6 +37,37 @@ function createMemoryStorage() { }; } +test("matches original CAD instance names after GLTFLoader sanitizes them", async () => { + const gltf = await new GLTFLoader().parseAsync( + JSON.stringify({ + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1, 2] }], + nodes: [{ name: "SOURCE-001:1" }, { name: "SOURCE-001:2" }, { name: "plain" }], + }), + "" + ); + const parts = new Map([ + ["SOURCE-001:1", "first instance"], + ["SOURCE-001:2", "second instance"], + ["plain", "plain node"], + ]); + expect(gltf.scene.children[0].name).not.toBe("SOURCE-001:1"); + expect(gltf.scene.children.map((node) => getOpenClawPartForSceneNode(node, parts))) + .toEqual(["first instance", "second instance", "plain node"]); + expect(getOpenClawPartForSceneNode({ name: "missing" }, parts)).toBeUndefined(); +}); + +test("CAD artifact bursts cannot exhaust the conversation API budget", () => { + const identifier = `claw-artifact-test-${Date.now()}`; + for (let request = 0; request < 600; request += 1) { + expect(checkRateLimitByIdentifier(identifier, rateLimiters.clawdArtifact).allowed).toBe(true); + } + expect(checkRateLimitByIdentifier(identifier, rateLimiters.clawdArtifact).allowed).toBe(false); + expect(checkRateLimitByIdentifier(identifier, rateLimiters.api).allowed).toBe(true); + expect(checkRateLimitByIdentifier(`${identifier}-other-user`, rateLimiters.clawdArtifact).allowed).toBe(true); +}); + const modelFile = { type: "file", filename: "wood-demo-interactive.glb", From 8f0dff35edb7a73096e1b3d56c18bafea27af9ac Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 14 Sep 2026 09:16:01 +0800 Subject: [PATCH 04/28] docs: record Claw production rollout verification --- docs/incidents/claw-3d-actions-2026-09-14.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/incidents/claw-3d-actions-2026-09-14.md b/docs/incidents/claw-3d-actions-2026-09-14.md index ca11a749b..65548f838 100644 --- a/docs/incidents/claw-3d-actions-2026-09-14.md +++ b/docs/incidents/claw-3d-actions-2026-09-14.md @@ -22,3 +22,9 @@ - 使用现场实际 GLB 和交互清单:整装模型 23/23 配件匹配,拆解前后 Canvas 截图不同;包装模型可加载,包装层全部匹配。真实文件仅保存在本地 QA 目录,未提交仓库。 - GLTFLoader 原始节点名回归测试覆盖两个实例、普通名字和不存在的节点;限流测试覆盖 600 次上限、对话预算隔离和另一用户隔离。 - 27 项相关测试通过;TypeScript 检查、Next.js 生产构建及 git diff --check 通过。构建有既存 HEIC 动态 require 警告。 + +## 发布 + +2026-09-14 已发布至 45.192.99.48 / ai.yydd.ai。生产代码提交 `f3cded51`,构建 ID `kGGW4LcGZLR8YnJF06qkH`。先在 3126 验证,再将正式 3125 服务切换至新构建;保留旧构建 `.next-pre-claw-0914`。线上登录页和新 JS 返回 200,未登录访问会话附件仍返回 401。 + +发布时已有的 Claw 上游任务继续通过持久化状态恢复,正式服务切换后记录仍在更新。修复分支及生产集成分支均已推送 Git。 From c024ae31b9eba9a2baf2e199563efa7c840b09f1 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 15 Sep 2026 13:34:41 +0800 Subject: [PATCH 05/28] fix: separate Claw markdown table columns --- components/elements/response.tsx | 4 ++-- docs/incidents/claw-table-spacing-2026-09-15.md | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 docs/incidents/claw-table-spacing-2026-09-15.md diff --git a/components/elements/response.tsx b/components/elements/response.tsx index 820dc2976..c0fe127c5 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -723,7 +723,7 @@ function MarkdownTableCell({ return ( Date: Tue, 15 Sep 2026 14:34:58 +0800 Subject: [PATCH 06/28] fix: restore Claw history before waiting for active tasks --- components/canvas/CanvasChatPanel.tsx | 117 ++++++++++++------ .../claw-history-recovery-2026-09-15.md | 18 +++ scripts/verify-claw-history-ui.mjs | 89 +++++++++++++ 3 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 docs/incidents/claw-history-recovery-2026-09-15.md create mode 100644 scripts/verify-claw-history-ui.mjs diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index d1da8a602..beededcc7 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -4485,13 +4485,18 @@ export function CanvasChatPanel({ }, []); // 从 API 加载历史消息 - const { data: fetchedHistoryData, mutate: mutateHistoryData } = + const { + data: fetchedHistoryData, + error: historyLoadError, + isLoading: isHistoryLoading, + mutate: mutateHistoryData, + } = useSWR( historyMessagesSWRKey, fetchCanvasHistoryMessages, { - revalidateOnFocus: false, - revalidateOnReconnect: false, + revalidateOnFocus: true, + revalidateOnReconnect: true, dedupingInterval: 10000, } ); @@ -8958,6 +8963,36 @@ export function CanvasChatPanel({ // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) const loadedHistorySignatureRef = useRef(""); + // Clear the previous session before hydrating a cached destination. + const processedMessageIds = useRef>(new Set()); + const hasHydratedHistoryRef = useRef(false); + + useEffect(() => { + if (!hasInitializedChatStateRef.current) { + hasInitializedChatStateRef.current = true; + return; + } + + loadedHistorySignatureRef.current = ""; + processedMessageIds.current.clear(); + hasHydratedHistoryRef.current = false; + hiddenPromptRef.current = null; + autoContextRef.current = null; + pendingLaunchRef.current = null; + setMessageViewportKey((prev) => prev + 1); + setMessages([]); + setInput(""); + setAttachments([]); + setClawdTableFiles([]); + setIsSessionMenuOpen(false); + setDetailSlotPickerIndex(null); + + const editableDiv = textareaRef.current as HTMLDivElement | null; + if (editableDiv) { + editableDiv.innerHTML = ""; + } + }, [activeChatId, setMessages]); + useEffect(() => { if (historyScrollTimerRef.current) { clearTimeout(historyScrollTimerRef.current); @@ -8965,9 +9000,11 @@ export function CanvasChatPanel({ } if (!historyData || !rawChatId) return; + const historyMessages = historyData.messages || []; + const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage); const shouldForceHydrate = forceHistoryHydrationRef.current === rawChatId; if ( - (status === "streaming" || status === "submitted" || isClawdSubmitting) && + (status === "streaming" || status === "submitted") && !shouldForceHydrate ) { return; @@ -8979,7 +9016,6 @@ export function CanvasChatPanel({ return; } - const historyMessages = historyData.messages || []; const signature = buildCanvasHistoryMessagesSignature(rawChatId, historyMessages); if (loadedHistorySignatureRef.current === signature) return; @@ -8988,7 +9024,21 @@ export function CanvasChatPanel({ processedMessageIds.current = new Set(); hasHydratedHistoryRef.current = true; forceHistoryHydrationRef.current = null; - setMessages(sortCanvasMessagesByCreatedAt(historyMessages)); + setMessages((currentMessages) => { + // A slow history read must not erase the Claw message just submitted. + // Preserve only this session's pending user message; persisted IDs win. + const pendingUserMessageId = isClawdHistory + ? activeClawdRequestUi?.userMessageId + : undefined; + const pendingMessage = pendingUserMessageId + ? currentMessages.find((message) => message.id === pendingUserMessageId) + : undefined; + const missingPendingMessage = pendingMessage && + !historyMessages.some((message) => message.id === pendingMessage.id); + return sortCanvasMessagesByCreatedAt( + missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages + ); + }); setMessageViewportKey((prev) => prev + 1); console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); @@ -9007,7 +9057,7 @@ export function CanvasChatPanel({ } }; } - }, [activeChatId, historyData, isClawdSubmitting, rawChatId, setMessages, status]); + }, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]); useEffect(() => { const streamState = streamImageGenerationStateByChatId[activeChatId]; @@ -9075,37 +9125,6 @@ export function CanvasChatPanel({ clearStreamImageGenerationStateForChat, ]); - // 监听消息变化,提取图片并添加到画布 - // 仅处理历史同步后的新消息,避免刷新后把历史图片重复回灌到画布 - const processedMessageIds = useRef>(new Set()); - const hasHydratedHistoryRef = useRef(false); - - useEffect(() => { - if (!hasInitializedChatStateRef.current) { - hasInitializedChatStateRef.current = true; - return; - } - - loadedHistorySignatureRef.current = ""; - processedMessageIds.current.clear(); - hasHydratedHistoryRef.current = false; - hiddenPromptRef.current = null; - autoContextRef.current = null; - pendingLaunchRef.current = null; - setMessageViewportKey((prev) => prev + 1); - setMessages([]); - setInput(""); - setAttachments([]); - setClawdTableFiles([]); - setIsSessionMenuOpen(false); - setDetailSlotPickerIndex(null); - - const editableDiv = textareaRef.current as HTMLDivElement | null; - if (editableDiv) { - editableDiv.innerHTML = ""; - } - }, [activeChatId, setMessages]); - useEffect(() => { if (!activeChatId) return; @@ -9150,6 +9169,8 @@ export function CanvasChatPanel({ }); }, [activeChatId, messages, updateChatSessions]); + // Only add newly generated media after history hydration; Claw artifacts + // remain in the conversation instead of being imported into the canvas. useEffect(() => { if (activeChatSessionMode === "clawd") return; if (!hasHydratedHistoryRef.current) return; @@ -15827,7 +15848,25 @@ export function CanvasChatPanel({ "space-y-5 overflow-x-hidden pr-1", isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" )}> - {!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? ( + {isClawdMode && renderableVisibleMessages.length === 0 && + (isHistoryLoading || historyLoadError) ? ( +
+ + {historyLoadError ? "聊天记录加载失败,请重试" : "正在加载聊天记录…"} + + {historyLoadError ? ( + + ) : null} +
+ ) : null} + {!isDetailGenerationMode && renderableVisibleMessages.length === 0 && + !(isClawdMode && (isHistoryLoading || historyLoadError)) ? ( isWorkspacePresentation ? (
diff --git a/docs/incidents/claw-history-recovery-2026-09-15.md b/docs/incidents/claw-history-recovery-2026-09-15.md new file mode 100644 index 000000000..14541d95e --- /dev/null +++ b/docs/incidents/claw-history-recovery-2026-09-15.md @@ -0,0 +1,18 @@ +# Claw 新建对话后返回历史记录空白(2026-09-15) + +## 原因与修复 + +使用真实 `useChat`、SWR 和 CanvasChatPanel 中的 effect 顺序复现:进入历史 A → 新建空白会话 → 返回缓存中的 A。原代码先把历史写入 SDK 消息列表,后执行会话切换清理,将列表清空;SWR 缓存命中且数据没有变化时,不会再次触发恢复。原版本回归结果为 `[]`,而缓存中仍有两条消息。 + +此外,历史同步被 `isClawdSubmitting` 阻塞,导致继续发送或恢复正在处理的 Claw 任务后,历史要等任务结束才显示。 + +- 将会话清理放到历史同步之前,保留既有目标 chatId 校验,避免缓存记录刚显示就被清除。 +- Claw 任务进行期间也允许显示历史;保留当前请求尚未出现在服务端响应中的用户消息,按 ID 去重,已有持久化消息优先。 +- 保留普通 SDK 流式输出和发送中的保护,避免用历史覆盖正在输出的内容。 +- 浏览器返回或网络恢复时重新校验历史;空白加载状态显示“正在加载聊天记录”,失败时提供重新加载。 + +## 验证 + +`node scripts/verify-claw-history-ui.mjs` 使用 Chrome、项目实际 React/AI SDK/SWR、从源文件按原始顺序提取的两个历史 effect,覆盖:缓存往返、Claw 进行中加载历史、用户消息去重、跨会话迟到响应隔离、普通流式输出保护、窗口返回刷新、断网重连刷新及失败后手动重试。 + +`node scripts/verify-claw-history-ui.mjs --source-ref=c024ae31` 可复现旧版缓存往返后消息列表为空的断言失败。测试使用合成消息和本地 HTTP 服务,不发送用户对话、不调用生图服务、不修改数据库。 diff --git a/scripts/verify-claw-history-ui.mjs b/scripts/verify-claw-history-ui.mjs new file mode 100644 index 000000000..4c9c5e33e --- /dev/null +++ b/scripts/verify-claw-history-ui.mjs @@ -0,0 +1,89 @@ +// Browser regression for the actual CanvasChatPanel history effects and SDK. +// Run: node scripts/verify-claw-history-ui.mjs [--source-ref=] +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {chromium} from '@playwright/test'; +import ts from 'typescript'; +const require=createRequire(import.meta.url); +const esbuild=require(require.resolve('esbuild',{paths:[require.resolve('tsx')]})); +const root=process.cwd(), sourcePath='components/canvas/CanvasChatPanel.tsx'; +const ref=process.argv.find(x=>x.startsWith('--source-ref='))?.split('=')[1]; +const source=ref?execFileSync('git',['show',`${ref}:${sourcePath}`],{encoding:'utf8'}):await fs.readFile(sourcePath,'utf8'); +const ast=ts.createSourceFile(sourcePath,source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX); +const effects=[];let swrDeclaration='',fetchDeclaration='',isClawdDeclaration=''; +function visit(node){ + if(ts.isExpressionStatement(node)&&ts.isCallExpression(node.expression)&&node.expression.expression.getText(ast)==='useEffect'){ + const text=node.getText(ast); + if(text.includes('hasInitializedChatStateRef.current')||text.includes('const shouldForceHydrate = forceHistoryHydrationRef.current'))effects.push({pos:node.pos,text}); + } + if(ts.isVariableStatement(node)&&node.getText(ast).includes('useSWR'))swrDeclaration=node.getText(ast); + if(ts.isFunctionDeclaration(node)&&node.name?.text==='fetchCanvasHistoryMessages')fetchDeclaration=node.getText(ast); + if(ts.isFunctionDeclaration(node)&&node.name?.text==='isClawdChatMessage')isClawdDeclaration=node.getText(ast); + ts.forEachChild(node,visit); +}visit(ast); +assert.equal(effects.length,2,'Expected the session reset and history hydration effects'); +assert(swrDeclaration&&fetchDeclaration&&isClawdDeclaration); +const noticeStart=source.indexOf('{isClawdMode && renderableVisibleMessages.length === 0 &&'); +const noticeEnd=source.indexOf('{!isDetailGenerationMode && renderableVisibleMessages.length === 0 &&',noticeStart); +const notice=noticeStart>=0&¬iceEnd>noticeStart?source.slice(noticeStart,noticeEnd):''; +const temp=await fs.mkdtemp(path.join(os.tmpdir(),'claw-history-ui-')); +const message=(id,text,role='assistant')=>({id,role,parts:[{type:'text',text}],createdAt:'2026-09-15T00:00:00Z',videoMetadata:{composerMode:'clawd'}}); +const histories={a:[message('a-user','旧问题','user'),message('a-answer','历史回复')],new:[],pending:[message('p-old','之前的消息')],regular:[message('r-old','持久化旧消息')]}; +const pending=message('pending-user','刚提交的新问题','user');pending.createdAt='2026-09-15T01:00:00Z'; +let pendingDelay=350,requests=0,failHistory=true; +const server=http.createServer(async(req,res)=>{ + const url=new URL(req.url,'http://localhost'); + if(url.pathname==='/history'){ + requests++;const id=url.searchParams.get('chatId');const payload=JSON.stringify({messages:histories[id]||[]}); + if(id==='error'){await new Promise(r=>setTimeout(r,500));if(failHistory){res.writeHead(500);res.end('{}');return;}} + if(id==='pending')await new Promise(r=>setTimeout(r,pendingDelay)); + res.setHeader('Content-Type','application/json');res.end(payload);return; + } + if(url.pathname==='/app.js'){res.setHeader('Content-Type','text/javascript');res.end(await fs.readFile(path.join(temp,'app.js')));return;} + res.setHeader('Content-Type','text/html');res.end('
'); +}); +const entry=`import React,{useState,useRef,useEffect}from'react';import{createRoot}from'react-dom/client';import{useChat}from'@ai-sdk/react';import useSWR from'swr';import{sortCanvasMessagesByCreatedAt,buildCanvasHistoryMessagesSignature}from'./components/canvas/chat-panel/canvas-chat-media'; +const fetcher=async url=>{const response=await fetch(url);if(!response.ok)throw new Error('history unavailable');return response.json()};${fetchDeclaration}\n${isClawdDeclaration} +function Button({children,onClick}){return } +function App(){ + const scenario=new URLSearchParams(location.search).get('case')||'cache'; + const [activeChatId,setActiveChatId]=useState(scenario==='pending'?'pending':scenario==='regular'?'regular':scenario==='error'?'error':'a'); + const rawChatId=activeChatId;const historyMessagesSWRKey='/history?chatId='+activeChatId; + ${swrDeclaration} + const historyData=fetchedHistoryData?.chatId===rawChatId?fetchedHistoryData:undefined; + const [isClawdSubmitting,setSubmitting]=useState(scenario==='pending'); + const [status,setStatus]=useState(scenario==='regular'?'streaming':'ready'); + const isClawdMode=scenario!=='regular';const activeClawdRequestUi=isClawdSubmitting?{userMessageId:'pending-user'}:null; + const {messages,setMessages}=useChat({id:activeChatId,messages:scenario==='pending'?[${JSON.stringify(pending)}]:scenario==='regular'?[{id:'stream',role:'assistant',parts:[{type:'text',text:'仍在输出'}]}]:historyData?.messages||[]}); + const hasInitializedChatStateRef=useRef(false),loadedHistorySignatureRef=useRef(''),processedMessageIds=useRef(new Set()),hasHydratedHistoryRef=useRef(false),hiddenPromptRef=useRef(null),autoContextRef=useRef(null),pendingLaunchRef=useRef(null),textareaRef=useRef(null),historyScrollTimerRef=useRef(null),messagesEndRef=useRef(null),forceHistoryHydrationRef=useRef(null),inFlightChatIdRef=useRef(null),inFlightRawChatIdRef=useRef(null); + const [,setMessageViewportKey]=useState(0),[,setInput]=useState(''),[,setAttachments]=useState([]),[,setClawdTableFiles]=useState([]),[,setIsSessionMenuOpen]=useState(false),[,setDetailSlotPickerIndex]=useState(null); + ${effects.sort((a,b)=>a.pos-b.pos).map(x=>x.text).join('\n')} + window.historyTest={messages,loaded:!!historyData,pending:isClawdSubmitting,switchTo(id){forceHistoryHydrationRef.current=id;setActiveChatId(id)},refresh(){return mutateHistoryData()},finish(){setSubmitting(false);setStatus('ready')}}; + const renderableVisibleMessages=messages;return
${notice}
{messages.map(m=>

{m.parts[0]?.text}

)}
{String(isClawdSubmitting)}
+}createRoot(document.getElementById('root')).render();`; +let browser; +try{ + await esbuild.build({stdin:{contents:entry,loader:'tsx',resolveDir:root},outfile:path.join(temp,'app.js'),bundle:true,jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'}}); + await new Promise(r=>server.listen(0,'127.0.0.1',r));const base='http://127.0.0.1:'+server.address().port; + browser=await chromium.launch({channel:'chrome',headless:true}); + const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + const ids=()=>page.locator('[data-message-id]').evaluateAll(nodes=>nodes.map(n=>n.getAttribute('data-message-id'))); + await page.goto(base);await page.waitForFunction(()=>window.historyTest?.loaded);assert.deepEqual(await ids(),['a-user','a-answer']); + await page.evaluate(()=>window.historyTest.switchTo('new'));await page.waitForFunction(()=>window.historyTest.loaded);assert.deepEqual(await ids(),[]); + const priorRequests=requests;await page.evaluate(()=>window.historyTest.switchTo('a'));await page.waitForTimeout(150); + assert.deepEqual(await ids(),['a-user','a-answer'],'Opening cached history after a new chat must not clear its messages');assert.equal(requests,priorRequests,'This case must exercise the cached response, not a fresh network response');console.log('PASS cached history survives new-chat round trip'); + await page.goto(base+'/?case=pending');await page.waitForFunction(()=>window.historyTest?.loaded);await page.waitForTimeout(100); + assert.equal(await page.locator('#pending').innerText(),'true');assert.deepEqual(await ids(),['p-old','pending-user'],'History must appear before the task finishes and preserve the optimistic user message');console.log('PASS history loads during an active Claw task'); + histories.pending.push({...pending,parts:[{type:'text',text:'服务器已保存的新问题'}]});await page.evaluate(()=>window.historyTest.refresh());await page.waitForTimeout(100);assert.deepEqual(await ids(),['p-old','pending-user']);assert.equal(await page.locator('[data-message-id="pending-user"]').innerText(),'服务器已保存的新问题');console.log('PASS persisted message replaces its optimistic copy without duplicates'); + pendingDelay=700;const read=page.evaluate(()=>window.historyTest.refresh());await page.waitForTimeout(100);await page.evaluate(()=>window.historyTest.switchTo('new'));await read;await page.waitForTimeout(100);assert.deepEqual(await ids(),[],'A late response from another chat must not be displayed');console.log('PASS late history cannot leak into another chat'); + await page.goto(base+'/?case=regular');await page.waitForFunction(()=>window.historyTest?.loaded);assert.deepEqual(await ids(),['stream'],'Regular SDK output must retain its streaming protection');console.log('PASS ordinary streaming messages remain protected'); + await page.goto(base);await page.waitForFunction(()=>window.historyTest?.loaded);histories.a.push(message('a-new','其他窗口保存的新回复'));await page.waitForTimeout(11000);await page.evaluate(()=>window.dispatchEvent(new Event('focus')));await page.waitForFunction(()=>window.historyTest.messages.some(x=>x.id==='a-new'));console.log('PASS returning to the browser refreshes history'); + histories.a.push(message('a-reconnect','重新连接后的消息'));await page.waitForTimeout(11000);await page.evaluate(()=>window.dispatchEvent(new Event('online')));await page.waitForFunction(()=>window.historyTest.messages.some(x=>x.id==='a-reconnect'));console.log('PASS reconnect refreshes history'); + if(notice){await page.goto(base+'/?case=error');await page.getByText('正在加载聊天记录…',{exact:true}).waitFor();await page.getByText('聊天记录加载失败,请重试',{exact:true}).waitFor();failHistory=false;histories.error=[message('retry-restored','重试恢复的记录')];await page.getByRole('button',{name:'重新加载',exact:true}).click();await page.locator('[data-message-id="retry-restored"]').waitFor();console.log('PASS loading, failure and retry states recover history');} + assert.deepEqual(errors,[]); +}finally{await browser?.close();server.close();await fs.rm(temp,{recursive:true,force:true});} From 54c857d714951ae035d9ce23ef4e00ccd76cd9a4 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 15 Sep 2026 14:39:08 +0800 Subject: [PATCH 07/28] docs: record Claw history recovery rollout checks --- docs/incidents/claw-history-recovery-2026-09-15.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/incidents/claw-history-recovery-2026-09-15.md b/docs/incidents/claw-history-recovery-2026-09-15.md index 14541d95e..6da544bf4 100644 --- a/docs/incidents/claw-history-recovery-2026-09-15.md +++ b/docs/incidents/claw-history-recovery-2026-09-15.md @@ -16,3 +16,7 @@ `node scripts/verify-claw-history-ui.mjs` 使用 Chrome、项目实际 React/AI SDK/SWR、从源文件按原始顺序提取的两个历史 effect,覆盖:缓存往返、Claw 进行中加载历史、用户消息去重、跨会话迟到响应隔离、普通流式输出保护、窗口返回刷新、断网重连刷新及失败后手动重试。 `node scripts/verify-claw-history-ui.mjs --source-ref=c024ae31` 可复现旧版缓存往返后消息列表为空的断言失败。测试使用合成消息和本地 HTTP 服务,不发送用户对话、不调用生图服务、不修改数据库。 + +## 发布验证 + +8 个浏览器场景、141 项相关测试及 TypeScript/生产构建通过。修复发布到 48 服务器,代码提交 `3bf0fec0`,构建 `Hqpoo4Axj8R_mMeZiR838`;保留上一版表格修复构建用于回滚。线上登录页和新前端资源返回 200,未登录访问历史接口仍返回 401。通过临时实例承接流量,待原实例请求完成后切换,避免中断既有生成任务。 From 3df44f1f8cd7061d42fbe5c7a828a7ea7fb7ccfd Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 15 Sep 2026 16:20:23 +0800 Subject: [PATCH 08/28] fix: render Claw models without waiting for external HDR lighting --- .../OpenClawInteractiveModelViewer.tsx | 10 ++- .../chat-panel/OpenClawModel3DPreview.tsx | 15 +++- .../canvas/model3d/LocalStudioEnvironment.tsx | 31 ++++++++ components/canvas/model3d/Model3DViewer.tsx | 14 +++- .../canvas/model3d/ModelLoadingFallback.tsx | 21 ++++++ .../claw-3d-black-screen-2026-09-15.md | 24 ++++++ scripts/verify-claw-3d-loading.mjs | 73 +++++++++++++++++++ 7 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 components/canvas/model3d/LocalStudioEnvironment.tsx create mode 100644 components/canvas/model3d/ModelLoadingFallback.tsx create mode 100644 docs/incidents/claw-3d-black-screen-2026-09-15.md create mode 100644 scripts/verify-claw-3d-loading.mjs diff --git a/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx b/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx index 4d7b93ff3..ab172b030 100644 --- a/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx +++ b/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx @@ -1,13 +1,15 @@ "use client"; /* eslint-disable @next/next/no-img-element */ -import { Environment, OrbitControls, useGLTF } from "@react-three/drei"; +import { OrbitControls, useGLTF } from "@react-three/drei"; import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { AlertCircle } from "lucide-react"; import React, { Suspense, useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import type { Mesh, Object3D } from "three"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; +import { LocalStudioEnvironment } from "@/components/canvas/model3d/LocalStudioEnvironment"; +import { ModelLoadingFallback } from "@/components/canvas/model3d/ModelLoadingFallback"; import type { OpenClawModelInteractionManifest, OpenClawModelInteractionPart, @@ -483,7 +485,6 @@ function ModelInteractionScene({ position={fitTransform.position} scale={fitTransform.scale} /> - -

3D 交互预览加载失败,已显示静态预览

+

{previewImageUrl ? "3D 交互预览加载失败,可查看静态预览或下载模型" : "3D 交互预览加载失败,请退出预览后重试或下载模型"}

} @@ -572,7 +573,8 @@ export function OpenClawInteractiveModelViewer({ shadows style={{ background }} > - + + }> + 正在准备 3D 预览…
); } @@ -658,6 +659,11 @@ export function OpenClawModel3DPreview({ messageId, artifactIndex: interactionManifestIndex, }); + const timeout = setTimeout(() => { + controller.abort(); + setManifest(null); + setManifestError("交互清单加载超时"); + }, 15000); setManifestError(null); fetch(manifestUrl, { signal: controller.signal }) .then(async (response) => { @@ -676,8 +682,12 @@ export function OpenClawModel3DPreview({ setManifestError( error instanceof Error ? error.message : "交互清单读取失败" ); - }); - return () => controller.abort(); + }) + .finally(() => clearTimeout(timeout)); + return () => { + clearTimeout(timeout); + controller.abort(); + }; }, [interactionManifestIndex, chatId, isActive, manifest, messageId]); useEffect(() => { @@ -1082,6 +1092,7 @@ export function OpenClawModel3DPreview({ ) : ( { + const room = new RoomEnvironment(); + const generator = new PMREMGenerator(gl); + const previous = scene.environment; + let target: ReturnType | undefined; + try { + target = generator.fromScene(room, 0.04); + scene.environment = target.texture; + } catch { + // Direct lights still render the model on devices unable to build a PMREM. + } finally { + room.dispose(); + generator.dispose(); + } + return () => { + if (scene.environment === target?.texture) scene.environment = previous; + target?.dispose(); + }; + }, [gl, scene]); + return null; +} diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index ff459f2c2..c52e29455 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -16,6 +16,8 @@ import type { Mesh, Object3D } from "three"; import { KTX2Loader } from "three-stdlib"; import { Button } from "@/components/ui/button"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; +import { LocalStudioEnvironment } from "./LocalStudioEnvironment"; +import { ModelLoadingFallback } from "./ModelLoadingFallback"; export type Model3DLayer = { id: string; @@ -55,6 +57,7 @@ type Model3DViewerProps = { modelUrl?: string | null; modelFormat?: "glb" | "gltf" | null; autoFitModel?: boolean; + localStudioEnvironment?: boolean; previewImageUrl?: string | null; emptyStateTitle?: string; settings: Model3DViewerSettings; @@ -721,6 +724,7 @@ export function Model3DViewer({ modelUrl, modelFormat, autoFitModel = false, + localStudioEnvironment = false, previewImageUrl, emptyStateTitle = "准备生成 3D 资产", settings, @@ -789,7 +793,7 @@ export function Model3DViewer({ ) : null}
-

3D 预览加载失败,已切换为静态预览

+

{previewImageUrl ? "3D 预览加载失败,可查看静态预览或下载模型" : "3D 预览加载失败,请重试或下载模型"}

- ); +
+ {STARTER_CARDS.map((card, index) => { + const Icon = [ScanText, UserRound, ImageIcon, AudioLines][index]; + const gradients = ["linear-gradient(#173C27,#B4C490)", "linear-gradient(#B62927,#FF9292)", "linear-gradient(#274156,#B4D3E2)", "linear-gradient(#332248,#B7A1CF)"]; + return ; })}
@@ -11175,13 +10871,13 @@ export function LiblibVideoCanvasReplica({ /> {activeVideoToolbarMode === "crop" ? ( <> - + setNodes(current => current.map(node => node.id === selectedVideoMediaNode.id ? {...node,draftValues:{...node.draftValues,cropRect:rect}} : node))} /> setPanMode(current => !current)} activePanel={activePanel} onOpen={(panel) => { setCanvasCommand(null); @@ -11519,6 +11218,7 @@ export function LiblibVideoCanvasReplica({ {inspectorNode ? ( edge.target === inspectorNode.id).length} onAddReference={(node) => addReferenceModulesForTool(node.id)} @@ -11915,14 +11615,18 @@ function getToolbarButtonLabel(item: (typeof TOOLBAR_ITEMS)[number]) { function LeftToolbar({ activePanel, + panMode, + onTogglePan, onOpen, }: { activePanel: ActivePanel; + panMode: boolean; + onTogglePan: () => void; onOpen: (panel: Exclude) => void; }) { return (
); })} @@ -13164,51 +12869,23 @@ function VideoInlineActionBar({ ); } -function VideoCropSelectionOverlay({ - anchor, - node, -}: { - anchor: PresetControlAnchor; - node: CloneNode; -}) { - const nodeWidth = Math.max(1, node.width * anchor.zoom); - const nodeHeight = Math.max(1, node.height * anchor.zoom); - const cardTop = anchor.topY + 32 * anchor.zoom; - const cardHeight = Math.max(1, nodeHeight - 32 * anchor.zoom); - const cardLeft = anchor.centerX - nodeWidth / 2; - const cropLeft = cardLeft + nodeWidth * VIDEO_CROP_RECT.x; - const cropTop = cardTop + cardHeight * VIDEO_CROP_RECT.y; - const cropWidth = nodeWidth * VIDEO_CROP_RECT.width; - const cropHeight = cardHeight * VIDEO_CROP_RECT.height; - const handlePoints = [ - ["left-0 top-0 -translate-x-1/2 -translate-y-1/2"], - ["left-1/2 top-0 -translate-x-1/2 -translate-y-1/2"], - ["right-0 top-0 translate-x-1/2 -translate-y-1/2"], - ["right-0 top-1/2 -translate-y-1/2 translate-x-1/2"], - ["right-0 bottom-0 translate-x-1/2 translate-y-1/2"], - ["left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2"], - ["left-0 bottom-0 -translate-x-1/2 translate-y-1/2"], - ["left-0 top-1/2 -translate-x-1/2 -translate-y-1/2"], - ]; - - return ( -
-
- - {VIDEO_CROP_INLINE_LABEL} - - {handlePoints.map(([className]) => ( - - ))} -
+function VideoCropSelectionOverlay({anchor,node,onChange}:{anchor:PresetControlAnchor;node:CloneNode;onChange:(rect:GridCropRect)=>void}) { + const box=getVideoCardOverlayRect(anchor,node); + const saved=(node.draftValues?.cropRect || VIDEO_CROP_RECT) as GridCropRect; + const [drawing,setDrawing]=useState<{x:number;y:number;endX:number;endY:number}|null>(null); + const rect=drawing ? {x:Math.min(drawing.x,drawing.endX),y:Math.min(drawing.y,drawing.endY),width:Math.abs(drawing.endX-drawing.x),height:Math.abs(drawing.endY-drawing.y)} : saved; + const point=(event:ReactPointerEvent)=>({x:clampUnit((event.clientX-box.left)/box.width),y:clampUnit((event.clientY-box.top)/box.height)}); + return
{event.stopPropagation();event.currentTarget.setPointerCapture(event.pointerId);const p=point(event);setDrawing({...p,endX:p.x,endY:p.y});}} + onPointerMove={event=>{if(!drawing)return;event.stopPropagation();const p=point(event);setDrawing({...drawing,endX:p.x,endY:p.y});}} + onPointerUp={event=>{event.stopPropagation();if(drawing&&rect.width>.02&&rect.height>.02)onChange(rect);setDrawing(null);event.currentTarget.releasePointerCapture(event.pointerId);}} + onPointerCancel={()=>setDrawing(null)}> +
+ 拖动框选裁剪区域 + +
- ); +
; } function clampUnit(value: number) { @@ -14060,7 +13737,7 @@ function PresetWorkflowControls({ defaultModel={getFunctionModelValue(functionConfig, definition)} functionDefinition={definition} membershipSummary={membershipSummary} - modelKind="image" + modelKind="image-edit" modelOptions={modelOptions} nodes={nodes} onSubmit={onComposerSubmit} @@ -14266,9 +13943,7 @@ function AudioTargetModuleComposer({ anchor }: { anchor: PresetControlAnchor }) const compactComposer = viewportWidth < 980 || viewportHeight < 620; const composerWidth = compactComposer ? Math.max(320, viewportWidth - 24) : 660; const composerHeight = settingsOpen ? 565 : 189; - const composerTop = compactComposer - ? Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 12)) - : anchor.bottomY + 12; + const composerTop = Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 76)); const insertToken = useCallback((token: string) => { setText((current) => (current ? `${current} ${token}` : token)); @@ -14848,6 +14523,7 @@ function VideoTargetModuleComposer({ onRemoveReference, onRemoveRole, onPromptChange, + onSettingsChange, onStartMarkSelection, onStartReferenceSelection, onSubmit, @@ -14869,6 +14545,7 @@ function VideoTargetModuleComposer({ onRemoveReference: (sourceId: string) => void; onRemoveRole: (sourceId: string) => void; onPromptChange: (prompt: string) => void; + onSettingsChange: (nodeId: string, settings: ComposerSettings, mode: VideoGenerationMode) => void; onStartMarkSelection: () => void; onStartReferenceSelection: () => void; onSubmit: (payload: VideoTargetComposerSubmitPayload) => void; @@ -14909,7 +14586,7 @@ function VideoTargetModuleComposer({ functionConfig, getConfiguredFunction(functionConfig, getVideoFunctionKeyForMode(resolveVideoComposerMode(initialTab))), ), - modelLabel: String(paramsSettings.modelLabel || "").trim() || "Seedance 2.0 VIP", + modelLabel: "", panorama: String(paramsSettings.panorama || "全能参考"), ratio: formatVideoRatioLabel(params.ratio || paramsSettings.ratio), resolution: formatVideoResolutionLabel(params.resolution || paramsSettings.resolution), @@ -14931,6 +14608,7 @@ function VideoTargetModuleComposer({ } | null>(null); const [settings, setSettings] = useState(initialSettings); const activeMode = resolveVideoComposerMode(activeTab); + useEffect(() => { onSettingsChange(node.id, settings, activeMode); }, [node.id, settings, activeMode, onSettingsChange]); const activeDefinition = getConfiguredFunction(functionConfig, getVideoFunctionKeyForMode(activeMode)); const activeModelFallback = normalizeModelRef(getFunctionModelValue(functionConfig, activeDefinition)) || "auto"; const showModelMenu = menu === "model"; @@ -14946,7 +14624,7 @@ function VideoTargetModuleComposer({ { key: "text-to-video", label: "文生视频", enabled: referenceCount === 0 }, { key: "reference-images", label: "全能参考", enabled: referenceCount >= 1 }, { key: "first-frame", label: "图生视频", enabled: referenceCount >= 1 }, - { key: "first-last-frame", label: "首尾帧", enabled: referenceCount >= 1 }, + { key: "first-last-frame", label: "首尾帧", enabled: referenceCount >= 2 }, { key: "image-reference", label: "图片参考", enabled: referenceCount >= 1 }, ]; const referenceThumbnails = imageSources @@ -14960,6 +14638,22 @@ function VideoTargetModuleComposer({ setSettings(initialSettings); }, [initialPrompt, initialTab, node.id]); + useEffect(() => { + const candidates = filterCanvasModels(modelOptions, "video", activeMode); + const normalized = normalizeModelRef(settings.model); + const selected = candidates.find(item => item.id === normalized || item.modelId === normalized); + const target = selected || ((!normalized || normalized === "auto") ? candidates[0] : undefined); + if (!target) return; + const supported = getCanvasVideoParameters(target, activeMode); + setSettings(current => { + const ratio = supported.ratios.length && !supported.ratios.includes(normalizeVideoRatioValue(current.ratio)) ? supported.ratios[0] : current.ratio; + const resolution = supported.resolutions.length && !supported.resolutions.includes(normalizeVideoResolutionValue(current.resolution)) ? supported.resolutions[0].toUpperCase() : current.resolution; + const duration = supported.durations.length && !supported.durations.includes(normalizeVideoDurationValue(current.duration)) ? `${supported.durations[0]}s` : current.duration; + return ratio === current.ratio && resolution === current.resolution && duration === current.duration ? current : {...current, ratio, resolution, duration}; + }); + }, [activeMode, modelOptions, settings.model, settings.ratio, settings.resolution, settings.duration]); + + useEffect(() => { if (!tabItems.find((item) => item.key === activeTab)?.enabled) { setActiveTab(inferVideoComposerDefaultTab(referenceCount)); @@ -14995,30 +14689,7 @@ function VideoTargetModuleComposer({ settings.resolution, ], ); - const estimatedBillingDisplay = useMemo( - () => - resolveVideoCanvasBillingDisplay({ - duration: normalizeVideoDurationValue(settings.duration), - functionDefinition: activeDefinition, - modelKind: "video", - modelOptions, - modelRef: settings.model, - resolution: normalizeVideoResolutionValue(settings.resolution), - }), - [ - activeDefinition, - modelOptions, - settings.duration, - settings.model, - settings.resolution, - ], - ); - const visibleBillingDisplay = /^\d+$/.test(estimatedBillingDisplay.label) - ? estimatedBillingDisplay - : { - label: "135", - message: billingDisplay.message || "当前视频生成预计消耗 135 积分。", - }; + const visibleBillingDisplay = billingDisplay; const appendMotionPrompt = useCallback( (snippet: string) => { const trimmedSnippet = snippet.trim(); @@ -15044,9 +14715,7 @@ function VideoTargetModuleComposer({ const baseComposerHeight = (compactComposer ? 210 : 191) + attachmentComposerHeight; const advancedComposerHeight = advancedSettingsOpen ? 116 : 0; const composerHeight = baseComposerHeight + advancedComposerHeight; - const composerTop = compactComposer - ? Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 12)) - : anchor.bottomY + 12; + const composerTop = Math.max(12, Math.min(anchor.bottomY + 12, viewportHeight - composerHeight - 76)); const allQuickActionItems: Array<{ icon: LucideIcon; key: "reference" | "mark" | "effects" | "role" | "motion"; label: string }> = [ { icon: Layers, key: "reference", label: "参考" }, { icon: BoxSelect, key: "mark", label: "标记" }, @@ -15086,7 +14755,7 @@ function VideoTargetModuleComposer({ data-canvas-overlay="true" style={{ height: composerHeight, - left: compactComposer ? 12 : anchor.centerX, + left: compactComposer ? 12 : Math.max(composerWidth / 2 + 12, Math.min(anchor.centerX, viewportWidth - composerWidth / 2 - 12)), top: composerTop, transform: compactComposer ? "none" : "translateX(-50%)", transformOrigin: "top left", @@ -15100,6 +14769,7 @@ function VideoTargetModuleComposer({
{showModelMenu ? ( { @@ -15127,6 +14797,8 @@ function VideoTargetModuleComposer({ setSettings((current) => ({ ...current, ...updates }))} /> ) : null} @@ -15666,7 +15338,7 @@ function AudioToVideoComposerPanel({ count: "1个", duration: "5s", model: normalizeModelRef(defaultModel) || "auto", - modelLabel: "Seedance 2.0 VIP", + modelLabel: "", panorama: "全景", ratio: "16:9", resolution: "720P", @@ -15736,6 +15408,7 @@ function AudioToVideoComposerPanel({
{showModelMenu ? ( { @@ -15750,7 +15423,9 @@ function AudioToVideoComposerPanel({ ) : null} {showSettingsMenu ? ( setSettings((current) => ({ ...current, ...updates }))} /> ) : null} @@ -16648,6 +16323,10 @@ function SceneToolbarPopover({ const nineGridOptions = [ "多机位九宫格", "剧情推演四宫格", + "角色脸部三视图", + "角色设定图", + "场景设定图", + "产品设定图", "25宫格连贯分镜", "电影级光影校正", "角色三视图生成", @@ -16699,21 +16378,21 @@ function SceneToolbarPopover({ if (menu === "grid") { return (
{nineGridOptions.map((option) => ( @@ -17329,11 +17008,10 @@ function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void } function getVideoComposerModelLabel(settings: Pick, modelOptions: CanvasModelOption[]) { - const configuredLabel = settings.modelLabel?.trim(); - if (configuredLabel) return configuredLabel; const normalized = normalizeModelRef(settings.model); - if (!normalized) return "Seedance 2.0 VIP"; - return getCanvasModelDisplayName(settings.model, modelOptions); + if (!normalized || normalized === "auto") return "自动模型"; + const model = modelOptions.find(item => item.id === normalized || item.modelId === normalized); + return model ? model.displayName || model.modelId || model.id : "模型不可用"; } function getVideoComposerModelShortLabel(label: string) { @@ -17344,7 +17022,7 @@ function getVideoComposerModelShortLabel(label: string) { if (normalized === "Seedance1.5 Pro") return "1.5 Pro"; if (normalized === "Seedance 1.0 Pro") return "1.0 Pro"; if (normalized === "Seedance 1.0 Lite") return "1.0 Lite"; - return normalized || "2.0"; + return normalized || "自动模型"; } function normalizeComposerBoolean(value: unknown, fallback = true) { @@ -17371,20 +17049,36 @@ function formatVideoParameterSummary(settings: Pick void; }) { + const candidates = filterCanvasModels(modelOptions, audioRequired ? "audio-video" : "video", mode); + const options = [ + { label: "自动模型", modelRef: "auto", description: "按当前模式和生成参数选择可用模型", duration: "", disabled: candidates.length === 0 }, + ...candidates.map(model => ({ + label: model.displayName || model.modelId || model.id, + modelRef: model.id, + description: model.providerName || "视频生成", + duration: getCanvasVideoModes(model).map(mode => ({"text-to-video":"文生", "first-frame":"首帧", "first-last-frame":"首尾帧", "reference-images":"多参考"})[mode]).join(" / "), + disabled: false, + })), + ]; return (
+ {candidates.length === 0 ?

暂无可用的视频模型,请在模型设置中启用视频模型。

: null}
- {LIBLIB_VIDEO_MODEL_OPTIONS.map((option, index) => { + {options.map((option, index) => { const selected = currentLabel === option.label || (!currentLabel && index === 0); const disabled = "disabled" in option && option.disabled === true; return ( @@ -17396,8 +17090,8 @@ function VideoModelPopover({ )} data-selected={selected ? "true" : "false"} disabled={disabled} - key={option.label} - onClick={() => onSelect({ label: option.label, modelRef: index === 0 ? "auto" : undefined })} + key={option.modelRef} + onClick={() => onSelect({ label: option.label, modelRef: option.modelRef })} type="button" > @@ -17473,117 +17167,37 @@ function VideoModePopover({ } function VideoParameterEditor({ - leftOffset = 217, - onChange, - settings, + leftOffset = 217, onChange, settings, modelOptions, mode, audioRequired = false, }: { + audioRequired?: boolean; leftOffset?: number; onChange: (updates: Partial) => void; settings: ComposerSettings; + modelOptions: CanvasModelOption[]; + mode?: VideoGenerationMode; }) { - const durationSeconds = normalizeVideoDurationValue(settings.duration); - const clampedDurationSeconds = Math.min(15, Math.max(4, durationSeconds)); - const [durationDraft, setDurationDraft] = useState(String(clampedDurationSeconds)); - const durationProgress = ((clampedDurationSeconds - 4) / 11) * 100; - - useEffect(() => { - setDurationDraft(String(clampedDurationSeconds)); - }, [clampedDurationSeconds]); - - const commitDuration = useCallback(() => { - const parsed = Number.parseInt(durationDraft, 10); - const nextDuration = Number.isFinite(parsed) - ? Math.min(15, Math.max(4, parsed)) - : clampedDurationSeconds; - setDurationDraft(String(nextDuration)); - onChange({ duration: `${nextDuration}s` }); - }, [clampedDurationSeconds, durationDraft, onChange]); - - const updateDurationFromSlider = useCallback( - (value: string) => { - const nextDuration = Math.min(15, Math.max(4, Number.parseInt(value, 10) || 5)); - setDurationDraft(String(nextDuration)); - onChange({ duration: `${nextDuration}s` }); - }, - [onChange] - ); - + const candidates = filterCanvasModels(modelOptions, audioRequired ? "audio-video" : "video", mode); + const model = candidates.find(item => item.id === settings.model || item.modelId === settings.model) || + (!normalizeModelRef(settings.model) || settings.model === "auto" ? candidates[0] : undefined); + const options = getCanvasVideoParameters(model, mode); return (
- - onChange({ ratio: label })} - /> - - - onChange({ resolution })} - /> - - -
-
- - updateDurationFromSlider(event.target.value)} - step={1} - type="range" - value={clampedDurationSeconds} - /> -
- setDurationDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - commitDuration(); - } - }} - step={1} - type="number" - value={durationDraft} - /> - s -
-
- - onChange({ audio })} - /> - - - onChange({ count })} - /> - + {!model ?

当前模式暂无可用模型,请先选择支持此模式的视频模型。

: <> + + {options.ratios.length ? onChange({ratio})} /> :

跟随输入素材

} +
+ + {options.resolutions.length ? v.toUpperCase())} selected={settings.resolution || "720P"} onSelect={resolution => onChange({resolution})} /> :

由模型决定

} +
+ + {options.durations.length ? `${v}s`)} selected={settings.duration || "5s"} onSelect={duration => onChange({duration})} /> :

由模型决定

} +
+ {options.supportsAudio ? onChange({audio})} /> : null} + onChange({count})} /> + }
); } @@ -17644,7 +17258,9 @@ function VideoParameterRow({ function VideoRatioOptions({ onSelect, selected, + options, }: { + options: string[]; onSelect: (option: string) => void; selected: string; }) { @@ -17660,9 +17276,9 @@ function VideoRatioOptions({ return (
- {VIDEO_RATIO_OPTIONS.map((option) => { + {options.map(label => ({label})).map((option) => { const isSelected = selected === option.label; - const iconSize = iconSizes[option.label]; + const iconSize = iconSizes[option.label] || {width: 12, height: 12}; return (