fix(video): sync placeholder updates and harden volcano input handling
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-18 22:36:15 +08:00
parent 46b3f4b081
commit b194f1a7e2
3 changed files with 121 additions and 37 deletions

View File

@ -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 = "🎬 视频任务已创建,正在处理中,请稍候...";

View File

@ -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];

View File

@ -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<string> {
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<string> {
}
// 已经是公网 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<string> {
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格式,前端可解析)