diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index c630b2b56..18eba745c 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -3728,26 +3728,32 @@ export async function POST(request: Request) { const lastUserMessage = messagesForVideo.filter(m => m.role === "user").pop(); let prompt = ""; const imageUrls: string[] = []; - - // 🐛 调试:打印原始消息结构 - console.log("[Chat API] 🔍 原始 lastUserMessage:", JSON.stringify(lastUserMessage, null, 2)); + const summarizeImageRef = (rawUrl: string) => { + const trimmed = String(rawUrl || "").trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:image/")) { + return `data:image(base64,${trimmed.length} chars)`; + } + try { + const parsed = new URL(trimmed); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return trimmed.slice(0, 120); + } + }; if (lastUserMessage) { // 🆕 处理 parts 数组(AI SDK v4 格式) if ((lastUserMessage as any).parts && Array.isArray((lastUserMessage as any).parts)) { - console.log("[Chat API] 🔍 Found parts array, length:", (lastUserMessage as any).parts.length); + console.log("[Chat API] Video input parts:", (lastUserMessage as any).parts.length); for (const part of (lastUserMessage as any).parts) { - console.log("[Chat API] 🔍 Processing part:", JSON.stringify(part, null, 2)); - if (part.type === "text" && part.text) { // 提取文本 prompt += part.text; - console.log("[Chat API] 🔍 ✅ Extracted text:", part.text); } else if (part.type === "file" && part.url && part.mediaType?.startsWith("image/")) { // 提取图片 imageUrls.push(part.url); - console.log("[Chat API] 🔍 ✅ Extracted image URL:", part.url); } } } @@ -3775,8 +3781,11 @@ export async function POST(request: Request) { } } - console.log("[Chat API] 🔍 最终提取结果 - prompt:", prompt, "imageCount:", imageUrls.length); - console.log("[Chat API] 🔍 imageUrls:", imageUrls); + console.log("[Chat API] Video input extracted:", { + promptLength: prompt.trim().length, + imageCount: imageUrls.length, + imageRefs: imageUrls.slice(0, 4).map(summarizeImageRef), + }); const videoMessageId = generateUUID(); const videoStartAt = Date.now(); @@ -3793,7 +3802,7 @@ export async function POST(request: Request) { try { console.log("[Chat API] Video generation params:", { - prompt, + promptLength: prompt.trim().length, imageCount: imageUrls.length, videoParams: effectiveVideoParams, }); @@ -3920,7 +3929,17 @@ export async function POST(request: Request) { videoOptions.referenceImages = imageUrls; } - console.log("[Chat API] Video options constructed:", videoOptions); + console.log("[Chat API] Video options constructed:", { + model: videoOptions.model, + mode: videoOptions.mode, + resolution: videoOptions.resolution, + ratio: videoOptions.ratio, + duration: videoOptions.duration, + imageCount: + videoOptions.referenceImages?.length || + Number(Boolean(videoOptions.firstFrameImage)) + + Number(Boolean(videoOptions.lastFrameImage)), + }); // 先写入占位消息,避免刷新后消息丢失 const pendingText = "🎬 视频任务已创建,正在处理中,请稍候..."; diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1233df306..1a17dabf7 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1529,19 +1529,41 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const messageData = typeof dataPart.data === 'string' ? JSON.parse(dataPart.data) : dataPart.data; + const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; + const normalizedAttachments = Array.isArray(messageData.attachments) + ? messageData.attachments + : []; setMessages((prevMessages) => { - if (prevMessages.some(m => m.id === messageData.id)) { - return prevMessages; + const existingIndex = prevMessages.findIndex( + (message) => message.id === messageData.id + ); + + if (existingIndex >= 0) { + const existingMessage = prevMessages[existingIndex]; + const updatedMessage: any = { + ...existingMessage, + role: messageData.role || existingMessage.role, + content: + normalizedParts.find((part: any) => part?.type === "text")?.text || + (existingMessage as any).content || + "", + parts: normalizedParts, + attachments: normalizedAttachments, + }; + + const nextMessages = [...prevMessages]; + nextMessages[existingIndex] = updatedMessage; + return nextMessages; } const newMessage: any = { id: messageData.id, role: messageData.role, - content: messageData.parts?.[0]?.text || "", + content: normalizedParts.find((part: any) => part?.type === "text")?.text || "", createdAt: new Date(), - parts: messageData.parts || [], - attachments: [], + parts: normalizedParts, + attachments: normalizedAttachments, }; return [...prevMessages, newMessage]; diff --git a/lib/ai/providers/volcano-video.ts b/lib/ai/providers/volcano-video.ts index 91b9c01b6..889c2705d 100644 --- a/lib/ai/providers/volcano-video.ts +++ b/lib/ai/providers/volcano-video.ts @@ -28,6 +28,40 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function sanitizeUrlForLog(url: string): string { + const trimmed = String(url || "").trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("data:image/")) { + return `data:image(base64,${trimmed.length} chars)`; + } + try { + const parsed = new URL(trimmed); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return trimmed.slice(0, 120); + } +} + +function extractPublicHttpUrl(rawValue: string): string | null { + const trimmed = rawValue.trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + + const markdownMatch = trimmed.match(/\((https?:\/\/[^)\s]+)\)/i); + if (markdownMatch?.[1]) { + return markdownMatch[1]; + } + + const inlineMatch = trimmed.match(/https?:\/\/[^\s"'<>]+/i); + if (inlineMatch?.[0]) { + return inlineMatch[0]; + } + + return null; +} + function isRetryableFetchError(error: any): boolean { const code = error?.code || error?.cause?.code; if (typeof code === "string" && RETRYABLE_FETCH_CODES.has(code)) { @@ -110,9 +144,14 @@ async function fetchWithRetry( */ async function normalizeImageUrlForVolcano(imageUrl: string): Promise { try { + const rawInput = String(imageUrl || "").trim(); + if (!rawInput) { + throw new Error("Empty image url"); + } + // Base64 输入:上传到 S3,返回永久 URL - if (imageUrl.startsWith("data:image/")) { - const matches = imageUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); + if (rawInput.startsWith("data:image/")) { + const matches = rawInput.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); if (!matches) { throw new Error("Invalid base64 image format"); } @@ -127,14 +166,19 @@ async function normalizeImageUrlForVolcano(imageUrl: string): Promise { } // 已经是公网 URL,直接使用 - if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { - return imageUrl; + const publicHttpUrl = extractPublicHttpUrl(rawInput); + if (publicHttpUrl) { + return publicHttpUrl; } - // 其他格式:下载后上传到 S3,返回永久 URL - console.log(`[Volcano Video] Downloading image: ${imageUrl}`); + if (rawInput.startsWith("blob:") || rawInput.startsWith("file:")) { + throw new Error("Unsupported local image URL. Please upload image first."); + } + + // 其他格式:尝试下载后上传到 S3,返回永久 URL + console.log(`[Volcano Video] Downloading image: ${sanitizeUrlForLog(rawInput)}`); const response = await fetchWithRetry( - imageUrl, + rawInput, { method: "GET" }, { label: "download-image", @@ -154,7 +198,7 @@ async function normalizeImageUrlForVolcano(imageUrl: string): Promise { const filename = `volcano-video-input-${Date.now()}.jpg`; const { url: permanentUrl } = await uploadFile(filename, bufferNode as any); - console.log(`[Volcano Video] Image uploaded to S3: ${permanentUrl}`); + console.log(`[Volcano Video] Image uploaded to S3: ${sanitizeUrlForLog(permanentUrl)}`); return permanentUrl; } catch (error) { @@ -356,15 +400,18 @@ async function createVideoTask( console.log("[Volcano Video] Creating task:", { model: options.model, mode: options.mode, - prompt: options.prompt, - fullPrompt, + promptLength: options.prompt?.length || 0, + fullPromptLength: fullPrompt.length, cameraMove: options.cameraMove, + contentItems: content.length, + imageRefs: Object.values(inputImageUrls) + .flat() + .map((value: any) => sanitizeUrlForLog(String(value))) + .slice(0, 6), }); // 4. 发送请求 - console.log("[Volcano Video] 🔍 Request URL:", url); - console.log("[Volcano Video] 🔍 Request method: POST"); - console.log("[Volcano Video] 🔍 Request body:", JSON.stringify(requestBody, null, 2)); + console.log("[Volcano Video] Request URL:", url); const response = await fetchWithRetry( url, @@ -383,8 +430,7 @@ async function createVideoTask( } ); - console.log("[Volcano Video] 🔍 Response status:", response.status); - console.log("[Volcano Video] 🔍 Response headers:", JSON.stringify(Object.fromEntries(response.headers.entries()))); + console.log("[Volcano Video] Response status:", response.status); if (!response.ok) { const errorText = await response.text(); @@ -613,9 +659,8 @@ export async function* doGenerateStream( const lastFrameUrl = status.content?.last_frame_url || status.output?.last_frame_url || status.last_frame_url; console.log("[Volcano Video] ✅ Task succeeded!"); - console.log("[Volcano Video] Video URL:", videoUrl); - console.log("[Volcano Video] Last frame URL:", lastFrameUrl); - console.log("[Volcano Video] Full status response:", JSON.stringify(status, null, 2)); + console.log("[Volcano Video] Video URL:", sanitizeUrlForLog(videoUrl || "")); + console.log("[Volcano Video] Last frame URL:", sanitizeUrlForLog(lastFrameUrl || "")); const videoResponse = formatVideoResponse({ taskId: taskId, @@ -627,8 +672,6 @@ export async function* doGenerateStream( updatedAt: Date.now(), }); - console.log("[Volcano Video] Formatted response:", videoResponse); - yield videoResponse; // 输出视频元数据(JSON格式,前端可解析)