Your Name 12583cdd10
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: sync latest canvas and media updates
2026-03-06 17:31:32 +08:00

2151 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { ChatHeader } from "@/components/chat-header";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { useAutoResume } from "@/hooks/use-auto-resume";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import { usePendingGeneration, type PendingGeneration } from "@/hooks/use-pending-generation";
import type { Vote } from "@/lib/db/schema";
import type { UserType } from "@/app/(auth)/auth";
import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "@/components/artifact";
import { useDataStream } from "@/components/data-stream-provider";
import { Messages } from "@/components/messages";
import { MultimodalInput } from "@/components/multimodal-input";
import { getChatHistoryPaginationKey } from "@/components/sidebar-history";
import { toast } from "sonner";
import type { VisibilityType } from "@/components/visibility-selector";
import type { VideoGenerationParams } from "@/components/video-params-bar";
import type { ImageGenerationParams } from "@/components/image-params-bar";
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
type ImageGenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed";
function normalizeImageParamsForProvider(
params: ImageGenerationParams,
providerType?: string
): ImageGenerationParams {
const normalized = { ...params };
const currentCount = normalized.imageCount || 1;
if (providerType === "ruxa-image") {
normalized.imageCount = 1;
return normalized;
}
if (providerType === "alibaba-image") {
normalized.imageCount = Math.min(Math.max(currentCount, 1), 6);
return normalized;
}
if (providerType === "volcano-vision") {
normalized.imageCount = Math.min(Math.max(currentCount, 1), 3);
return normalized;
}
normalized.imageCount = Math.min(Math.max(currentCount, 1), 2);
return normalized;
}
function normalizeVideoParamsForProvider(
params: VideoGenerationParams | null | undefined,
providerType?: string,
capability?: VideoToolCapabilityContract | null
): VideoGenerationParams {
if (capability) {
const modeOptions = capability.modeOptions || [];
const ratioOptions = capability.ratioOptions || [];
const durationOptions = capability.durationOptions || [];
const resolutionOptions = capability.resolutionOptions || [];
const modeSet = new Set(modeOptions.map((item) => item.mode));
const ratioSet = new Set(ratioOptions);
const durationSet = new Set(durationOptions);
const resolutionSet = new Set(resolutionOptions);
const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video";
const fallbackRatio = capability.defaults?.ratio || ratioOptions[0] || "16:9";
const fallbackDuration = capability.defaults?.duration || durationOptions[0] || 5;
const fallbackResolution = capability.defaults?.resolution || resolutionOptions[0] || "720p";
const merged = {
mode: fallbackMode,
resolution: fallbackResolution as VideoGenerationParams["resolution"],
ratio: fallbackRatio as VideoGenerationParams["ratio"],
duration: Number(fallbackDuration) || 5,
generateAudio: capability.defaults?.generateAudio ?? false,
watermark: false,
cameraFixed: false,
cameraMove: "none" as VideoGenerationParams["cameraMove"],
fps: capability.defaults?.fps || 24,
...(params || {}),
} as VideoGenerationParams;
if (!modeSet.has(merged.mode)) {
merged.mode = fallbackMode as VideoGenerationParams["mode"];
}
if (!ratioSet.has(merged.ratio)) {
merged.ratio = fallbackRatio as VideoGenerationParams["ratio"];
}
if (!durationSet.has(Number(merged.duration))) {
merged.duration = Number(fallbackDuration) || 5;
} else {
merged.duration = Number(merged.duration) || Number(fallbackDuration) || 5;
}
if (!resolutionSet.has(merged.resolution)) {
merged.resolution = fallbackResolution as VideoGenerationParams["resolution"];
}
if (!capability.ui.showGenerateAudio) {
merged.generateAudio = false;
}
if (!capability.ui.showFps) {
merged.fps = capability.defaults?.fps || 24;
}
if (!capability.ui.showCameraMove) {
merged.cameraMove = "none";
merged.cameraFixed = false;
}
if (!capability.ui.showReturnLastFrame) {
merged.returnLastFrame = false;
}
if (!capability.ui.showSeed) {
merged.seed = undefined;
}
return merged;
}
const isFalVideo = providerType === "fal-video";
const isRuxaVideo = providerType === "ruxa-video";
const base: VideoGenerationParams = {
mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video",
resolution: "720p",
ratio: "16:9",
duration: 5,
generateAudio: true,
watermark: false,
cameraFixed: false,
cameraMove: "none",
fps: 24,
};
const merged = { ...base, ...(params || {}) };
if (isFalVideo) {
if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") {
merged.mode = "first-frame";
}
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5)));
merged.generateAudio = merged.generateAudio ?? true;
return merged;
}
if (isRuxaVideo) {
merged.mode = "first-frame";
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = merged.duration >= 8 ? 10 : 5;
merged.generateAudio = false;
merged.cameraMove = "none";
merged.cameraFixed = false;
merged.fps = 24;
return merged;
}
if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) {
merged.mode = "text-to-video";
}
if (!["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = merged.duration >= 8 ? 10 : 5;
return merged;
}
function estimateTypewriterDurationMs(text: string): number {
const length = Array.from(String(text || "")).length;
return Math.min(9000, Math.max(1200, length * 22));
}
function extractDisplayTextFromParts(parts?: Array<any>): string {
if (!Array.isArray(parts) || parts.length === 0) return "";
return parts
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) =>
String(part.text)
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.trim()
)
.join("\n")
.trim();
}
function sortMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] {
return [...messages].sort((a, b) => {
const timeA = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0;
const timeB = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0;
return timeA - timeB;
});
}
function hasImageMarkdown(parts?: Array<any>) {
if (!parts || parts.length === 0) return false;
return parts.some((part) => {
if (!part) return false;
if (
part?.type === "text" &&
typeof part.text === "string" &&
(part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image"))
) {
return true;
}
if (part?.type === "image" && typeof part.url === "string" && part.url.length > 0) {
return true;
}
if (
part?.type === "file" &&
typeof part.url === "string" &&
((typeof part.mediaType === "string" && part.mediaType.startsWith("image/")) ||
/\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(part.url))
) {
return true;
}
return false;
});
}
function getMessageTextLength(message?: ChatMessage | null) {
if (!message?.parts || message.parts.length === 0) return 0;
return message.parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text.trim())
.join("\n")
.length;
}
function hasLikelyGenerationErrorMessage(message?: ChatMessage | null) {
if (!message?.parts || message.parts.length === 0) return false;
const text = message.parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("\n")
.trim();
if (!text) return false;
return /失败|超时|重试|error|failed/i.test(text);
}
function getAssistantMessagesAfterTargetUser(
dbMessages: ChatMessage[],
targetUserMessageId?: string | null,
) {
if (!Array.isArray(dbMessages) || dbMessages.length === 0) return [] as ChatMessage[];
let targetUserIndex = -1;
if (targetUserMessageId) {
targetUserIndex = dbMessages.findIndex(
(message) => message.role === "user" && message.id === targetUserMessageId,
);
}
if (targetUserIndex < 0) {
targetUserIndex = dbMessages.findLastIndex((message) => message.role === "user");
}
if (targetUserIndex < 0) {
return dbMessages.filter((message) => message.role === "assistant");
}
return dbMessages
.slice(targetUserIndex + 1)
.filter((message) => message.role === "assistant");
}
export function Chat({
id,
initialMessages,
initialChatModel,
initialVisibilityType,
isReadonly,
autoResume,
initialLastContext,
userType,
}: {
id: string;
initialMessages: ChatMessage[];
initialChatModel: string;
initialVisibilityType: VisibilityType;
isReadonly: boolean;
autoResume: boolean;
initialLastContext?: AppUsage;
userType?: UserType;
}) {
const router = useRouter();
const { visibilityType } = useChatVisibility({
chatId: id,
initialVisibilityType,
});
// 🆕 持久化生成任务状态(解决用户离开再返回时加载状态丢失)
const { pendingGeneration, startGeneration, completeGeneration } = usePendingGeneration(id);
const pendingGenerationRef = useRef<PendingGeneration | null>(pendingGeneration);
// 🆕 标记是否已经尝试刷新过(避免无限刷新)
const hasTriedRefreshRef = useRef(false);
// 🆕 跟踪是否正在生成图片(用于占位图显示)
const [isGeneratingImageFlag, setIsGeneratingImageFlag] = useState(false);
const [imageGenerationPhase, setImageGenerationPhase] = useState<ImageGenerationPhase>("idle");
const [imageGenerationStartedAt, setImageGenerationStartedAt] = useState<number | null>(null);
const imageGenerationPhaseRef = useRef<ImageGenerationPhase>("idle");
const imagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestAssistantDisplayTextRef = useRef<string>("");
const startedGenerationMessageIdRef = useRef<string | null>(null);
// 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突
const [appendedMessages, setAppendedMessages] = useState<ChatMessage[]>([]);
// 🆕 当检测到恢复的 pendingGeneration 时,刷新页面获取最新消息
useEffect(() => {
if (pendingGeneration && !hasTriedRefreshRef.current) {
// 检查最后一条消息是否已经是 AI 回复
const lastMessage = initialMessages.length > 0 ? initialMessages[initialMessages.length - 1] : null;
if (lastMessage?.role === "assistant") {
// 最后一条已经是 AI 回复,说明生成已完成,清除 pending 状态
console.log("[Chat] 🎉 检测到生成已完成(最后消息是 AI 回复),清除 pending 状态");
completeGeneration();
} else if (lastMessage?.role === "user") {
// 最后一条是用户消息,可能还在生成中,或者已经完成但页面数据过期
// 标记已尝试刷新,然后刷新页面获取最新数据
hasTriedRefreshRef.current = true;
console.log("[Chat] 🔄 检测到恢复的 pending 状态,刷新页面获取最新消息...");
// 延迟一小段时间再刷新,避免立即刷新
setTimeout(() => {
router.refresh();
}, 500);
}
}
}, [pendingGeneration, initialMessages, completeGeneration, router]);
useEffect(() => {
pendingGenerationRef.current = pendingGeneration;
}, [pendingGeneration]);
const updateImageGenerationPhase = useCallback((nextPhase: ImageGenerationPhase) => {
imageGenerationPhaseRef.current = nextPhase;
setImageGenerationPhase(nextPhase);
if (nextPhase === "idle" || nextPhase === "failed") {
setIsGeneratingImageFlag(false);
setImageGenerationStartedAt(null);
return;
}
setIsGeneratingImageFlag(true);
setImageGenerationStartedAt((previous) => previous ?? Date.now());
}, []);
// 清理占位图定时器
useEffect(() => {
return () => {
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
};
}, []);
const { mutate } = useSWRConfig();
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
// When user navigates back/forward, refresh to sync with URL
router.refresh();
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [router]);
const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>("");
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [insufficientPointsState, setInsufficientPointsState] = useState<{
balance: number;
expectedCost: number;
unitCost: number;
imageCount: number;
} | null>(null);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
// 图片处理状态,防止重复点击
const [isProcessingEnhance, setIsProcessingEnhance] = useState(false);
const [isProcessingRmbg, setIsProcessingRmbg] = useState(false);
const [isProcessingExtend, setIsProcessingExtend] = useState(false);
const [isProcessingAngleEdit, setIsProcessingAngleEdit] = useState(false);
const [isProcessingWatermarkRemove, setIsProcessingWatermarkRemove] = useState(false);
const currentModelIdRef = useRef(currentModelId);
// 🆕 视频参数状态
const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null);
// 🆕 图片参数状态(默认值:auto比例,2K分辨率)
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K"
});
// 🆕 从 localStorage 读取保存的图片参数
useEffect(() => {
try {
const saved = localStorage.getItem("gemini-image-params");
if (saved) {
const parsed = JSON.parse(saved);
// 确保所有字段都有值,避免传递 undefined
setImageParams({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
...parsed,
});
}
} catch (e) {
console.error("Failed to load image params:", e);
}
}, []);
// 🆕 保存图片参数到 localStorage
const handleImageParamsChange = (params: ImageGenerationParams) => {
const normalized = normalizeImageParamsForProvider(params, currentModel?.providerType);
setImageParams(normalized);
try {
localStorage.setItem("gemini-image-params", JSON.stringify(normalized));
} catch (e) {
console.error("Failed to save image params:", e);
}
console.log("[Chat] Image params updated:", normalized);
};
// 🆕 获取模型列表以检查是否是视频模型/图片模型
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
modelId: string;
displayName: string;
providerType: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
videoToolCapability?: VideoToolCapabilityContract | null;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
} | null;
}>;
}>("/api/models", fetcher);
const currentModel = modelsData?.models?.find(m => m.id === currentModelId);
const isVideoModel =
currentModel?.providerType === "volcano-video" ||
currentModel?.providerType === "doubao-video" ||
currentModel?.providerType === "qianwen-video" ||
currentModel?.providerType === "fal-video" ||
currentModel?.providerType === "ruxa-video";
const isImageModel = currentModel?.supportsImageGen === true;
// 🔍 调试日志
console.log('[Chat] Render - currentModelId:', currentModelId);
console.log('[Chat] Render - modelsData loaded:', !!modelsData);
console.log('[Chat] Render - currentModel:', currentModel?.displayName, {
id: currentModel?.id,
supportsImageGen: currentModel?.supportsImageGen,
providerType: currentModel?.providerType,
isImageModel,
imageParams
});
// 使用 ref 保存视频/图片相关状态,以便在 prepareSendMessagesRequest 中访问最新值
const isVideoModelRef = useRef(isVideoModel);
const videoParamsRef = useRef(videoParams);
const isImageModelRef = useRef(isImageModel);
const imageParamsRef = useRef(imageParams);
useEffect(() => {
isVideoModelRef.current = isVideoModel;
videoParamsRef.current = videoParams;
isImageModelRef.current = isImageModel;
imageParamsRef.current = imageParams;
console.log("[Chat] Refs updated - imageParams:", imageParams);
}, [isVideoModel, videoParams, isImageModel, imageParams]);
// 调试日志
console.log("[Chat Debug] currentModelId:", currentModelId);
console.log("[Chat Debug] currentModel:", currentModel);
console.log("[Chat Debug] isVideoModel:", isVideoModel);
// 🆕 当切换到视频模型时,自动初始化 videoParams
useEffect(() => {
if (!isVideoModel) {
if (!videoParams) return;
setVideoParams(null);
console.log("[Chat] 已切换到非视频模型,清空视频参数");
return;
}
const normalized = normalizeVideoParamsForProvider(
videoParams,
currentModel?.providerType,
currentModel?.videoToolCapability
);
if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) {
setVideoParams(normalized);
console.log("[Chat] 视频参数已按当前模型规范化:", normalized);
}
}, [isVideoModel, videoParams, currentModel?.providerType, currentModel?.videoToolCapability]);
// 🆕 当切换到图片模型时,强制更新 imageParams(确保组件重新渲染)
useEffect(() => {
if (isImageModel) {
setImageParams((prev) => {
const next = normalizeImageParamsForProvider(prev, currentModel?.providerType);
if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
try {
localStorage.setItem("gemini-image-params", JSON.stringify(next));
} catch {}
return next;
});
console.log("[Chat] 图片模型已激活,参数已按当前模型规范化");
}
}, [isImageModel, currentModel?.providerType]);
useEffect(() => {
currentModelIdRef.current = currentModelId;
// 调试:在控制台显示当前模型切换
console.log(`[Chat] Model changed to: ${currentModelId}`);
}, [currentModelId]);
const {
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
resumeStream,
} = useChat<ChatMessage>({
id,
messages: initialMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
latestAssistantDisplayTextRef.current = "";
console.log("[Chat] Preparing request, videoParams:", videoParamsRef.current);
console.log("[Chat] Preparing request, imageParams:", imageParamsRef.current);
console.log("[Chat] Is video model:", isVideoModelRef.current);
console.log("[Chat] Is image model:", isImageModelRef.current);
return {
body: {
id: request.id,
message: request.messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: visibilityType,
videoParams: videoParamsRef.current || undefined, // 🆕 传递视频参数(使用ref获取最新值)
imageParams: imageParamsRef.current || undefined, // 🆕 传递图片参数(使用ref获取最新值)
...request.body,
},
};
},
}),
onData: (dataPart: any) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
if (dataPart.type === "data-usage") {
setUsage(dataPart.data);
}
// 🆕 处理后端返回的自定义消息(用于图片/视频生成的非流式响应)
if (dataPart.type === "data-appendMessage") {
try {
const messageData = typeof dataPart.data === 'string'
? JSON.parse(dataPart.data)
: dataPart.data;
console.log("[Chat] Received appendMessage:", messageData);
const hasImageGenStartMarker = messageData.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_START -->")
);
const hasImageGenEndMarker = messageData.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
);
const hasLegacyGeneratingMarker = messageData.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- GENERATING_IMAGE -->")
);
const isMarkerOnlyMessage = messageData.parts?.every((part: any) => {
if (part.type !== "text" || !part.text) return false;
const cleanedText = part.text
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.trim();
return cleanedText.length === 0;
});
const displayTextFromMessage = extractDisplayTextFromParts(messageData.parts);
if (displayTextFromMessage) {
latestAssistantDisplayTextRef.current = displayTextFromMessage;
}
if (hasImageGenEndMarker && isMarkerOnlyMessage) {
console.log("[Chat] 🖼️ Image generation entered finalizing stage");
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("finalizing");
return; // 不渲染标记消息
}
if (hasImageGenStartMarker && isMarkerOnlyMessage) {
const delayText = latestAssistantDisplayTextRef.current;
const delayMs = delayText
? estimateTypewriterDurationMs(delayText) + 240
: 1200;
console.log("[Chat] 🖼️ Image generation start marker received, delay processing placeholder:", delayMs);
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("queued");
imagePlaceholderTimerRef.current = setTimeout(() => {
updateImageGenerationPhase("processing");
imagePlaceholderTimerRef.current = null;
}, delayMs);
return; // 不渲染标记消息
}
if (hasLegacyGeneratingMarker) {
console.log("[Chat] 🖼️ Detected legacy marker, will show placeholder after delay");
const displayText = displayTextFromMessage;
const hasDisplayText = displayText.length > 0;
const placeholderDelayMs = hasDisplayText
? estimateTypewriterDurationMs(displayText) + 240
: 3000;
console.log("[Chat] Placeholder delay:", placeholderDelayMs);
if (!hasDisplayText) {
console.log("[Chat] ⚠️ Marker message has no display text, skip image placeholder timer");
return;
}
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
imagePlaceholderTimerRef.current = setTimeout(() => {
console.log("[Chat] 🖼️ Now showing placeholder after text reveal");
updateImageGenerationPhase("processing");
imagePlaceholderTimerRef.current = null;
}, placeholderDelayMs);
}
// 🆕 检测是否包含图片(生成完成)
const messageText = messageData.parts?.[0]?.text || "";
console.log("[Chat] 📝 Message text preview:", messageText.substring(0, 100));
const hasImage = hasImageMarkdown(messageData.parts);
console.log("[Chat] 🔍 hasImage check:", hasImage);
if (hasImage) {
console.log("[Chat] 🎉 Image generated, hiding placeholder");
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("idle");
completeGeneration();
}
// 🆕 将消息添加到 appendedMessages 状态,而不是直接修改 useChat 的 messages
// 这样可以避免与 useChat 内部状态冲突导致消息丢失
const newMessage: ChatMessage = {
id: messageData.id,
role: messageData.role,
content: messageData.parts?.[0]?.text || "",
createdAt: new Date(),
parts: messageData.parts || [],
attachments: Array.isArray(messageData.attachments) ? messageData.attachments : [],
} as ChatMessage;
setAppendedMessages((prev) => {
// 检查消息是否已存在
if (prev.some(m => m.id === messageData.id)) {
console.log("[Chat] Appended message already exists, skipping:", messageData.id);
return prev;
}
console.log("[Chat] Adding to appendedMessages:", messageData.id);
return [...prev, newMessage];
});
const messageTextForErrorCheck = messageData.parts
?.filter((part: any) => part.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("\n")
.trim();
const isLikelyGenerationError =
!hasImage &&
!!messageTextForErrorCheck &&
/失败|超时|重试|error|failed/i.test(messageTextForErrorCheck) &&
imageGenerationPhaseRef.current !== "idle";
if (isLikelyGenerationError) {
console.log("[Chat] ❌ Generation error detected, reset loading state");
updateImageGenerationPhase("failed");
completeGeneration();
}
} catch (e) {
console.error("[Chat] Failed to parse data-appendMessage:", e);
}
}
},
onFinish: async () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
console.log("[Chat] onFinish called");
const pendingSnapshot = pendingGenerationRef.current;
const keepPendingUntilDbSync =
pendingSnapshot?.modelType === "gemini" ||
pendingSnapshot?.modelType === "volcano-vision" ||
pendingSnapshot?.modelType === "alibaba-image";
if (!keepPendingUntilDbSync) {
completeGeneration();
}
// 🆕 从数据库重新加载消息,确保 data-appendMessage 的消息正确显示
// 这是根治方案 B:后端已保存消息到数据库,前端从数据库重新加载
try {
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
dbMessages.forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
const persistedIds = new Set<string>(dbMessages.map((message) => message.id));
setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id)));
};
const reloadMessagesOnce = async () => {
const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return { hasImage: false, hasError: false };
const data = await response.json();
if (!data.messages || !Array.isArray(data.messages)) {
return { hasImage: false, hasError: false };
}
const dbMessages = data.messages as ChatMessage[];
console.log("[Chat] Reloading messages from database:", dbMessages.length);
mergeMessagesFromDatabase(dbMessages);
const targetUserMessageId =
pendingSnapshot?.userMessageId || startedGenerationMessageIdRef.current;
const assistantAfterTargetUser = getAssistantMessagesAfterTargetUser(
dbMessages,
targetUserMessageId,
);
const hasPersistedImage = assistantAfterTargetUser.some((message) =>
hasImageMarkdown((message as any).parts),
);
const hasPersistedError = assistantAfterTargetUser.some((message) =>
hasLikelyGenerationErrorMessage(message),
);
return { hasImage: hasPersistedImage, hasError: hasPersistedError };
};
// 延迟一小段时间确保数据库写入完成
await new Promise((resolve) => setTimeout(resolve, 500));
let { hasImage, hasError } = await reloadMessagesOnce();
if (
!hasImage &&
!hasError &&
(imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing" ||
keepPendingUntilDbSync)
) {
for (let attempt = 1; attempt <= 6; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 600));
const result = await reloadMessagesOnce();
hasImage = result.hasImage;
hasError = result.hasError;
if (hasImage || hasError) break;
}
}
if (hasImage) {
updateImageGenerationPhase("idle");
completeGeneration();
} else if (hasError) {
updateImageGenerationPhase("failed");
completeGeneration();
} else if (
imageGenerationPhaseRef.current !== "idle" &&
imageGenerationPhaseRef.current !== "failed"
) {
// 保持 pending,让轮询继续从数据库拉取结果,避免前端“空白直到刷新”
console.log("[Chat] Waiting for persisted result, keep pending state");
}
} catch (error) {
console.error("[Chat] Failed to reload messages:", error);
} finally {
// 兜底:即使消息重载失败,也不能让占位图卡死在处理中状态
if (
imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing"
) {
if (!keepPendingUntilDbSync) {
updateImageGenerationPhase("idle");
}
}
}
},
onError: (error) => {
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("failed");
completeGeneration();
const fallbackMessage = error instanceof Error ? error.message : "请求失败";
const responseData = (error as any)?.data;
const isInsufficientPoints =
responseData?.error === "insufficient_points" ||
/积分不足/.test(fallbackMessage);
if (isInsufficientPoints) {
const balance = Math.max(0, Number(responseData?.balance ?? 0));
const expectedCost = Math.max(0, Number(responseData?.expectedCost ?? 0));
const unitCost = Math.max(0, Number(responseData?.unitCost ?? 0));
const imageCount =
unitCost > 0 && expectedCost > 0
? Math.max(1, Math.round(expectedCost / unitCost))
: Math.max(1, Math.round(Number(imageParamsRef.current?.imageCount || 1)));
setInsufficientPointsState({
balance,
expectedCost,
unitCost,
imageCount,
});
}
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if (
error.message?.includes("AI Gateway requires a valid credit card")
) {
setShowCreditCardAlert(true);
} else {
toast.error(error.message);
}
} else {
toast.error(fallbackMessage || "请求失败,请稍后重试");
}
},
});
useEffect(() => {
const isImagePending =
pendingGeneration?.modelType === "gemini" ||
pendingGeneration?.modelType === "volcano-vision" ||
pendingGeneration?.modelType === "alibaba-image";
if (!pendingGeneration || !isImagePending) return;
let cancelled = false;
const maxDurationMs = 10 * 60 * 1000;
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
dbMessages.forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
const persistedIds = new Set<string>(dbMessages.map((message) => message.id));
setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id)));
};
const pollMessages = async () => {
if (cancelled) return;
if (Date.now() - pendingGeneration.timestamp > maxDurationMs) {
console.warn("[Chat] Polling timeout for pending generation");
completeGeneration();
if (
imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing"
) {
updateImageGenerationPhase("failed");
}
return;
}
try {
const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return;
const data = await response.json();
if (!data.messages || !Array.isArray(data.messages)) return;
const dbMessages = data.messages as ChatMessage[];
mergeMessagesFromDatabase(dbMessages);
const assistantAfterTargetUser = getAssistantMessagesAfterTargetUser(
dbMessages,
pendingGeneration.userMessageId,
);
if (assistantAfterTargetUser.length === 0) return;
const hasImage = assistantAfterTargetUser.some((message) =>
hasImageMarkdown((message as any).parts),
);
const hasError = assistantAfterTargetUser.some((message) =>
hasLikelyGenerationErrorMessage(message)
);
if (!hasImage && !hasError) return;
console.log("[Chat] Polling detected finished assistant response");
completeGeneration();
if (hasImage) {
updateImageGenerationPhase("idle");
} else if (
imageGenerationPhaseRef.current === "queued" ||
imageGenerationPhaseRef.current === "processing" ||
imageGenerationPhaseRef.current === "finalizing"
) {
updateImageGenerationPhase("failed");
}
} catch (error) {
console.error("[Chat] Polling messages failed:", error);
}
};
pollMessages();
const timer = setInterval(pollMessages, 800);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [pendingGeneration, id, setMessages, completeGeneration, updateImageGenerationPhase]);
// 🆕 保存 initialMessages 的引用,防止历史消息丢失
// 当 useChat 内部状态重置时,我们仍然有初始消息
const initialMessagesRef = useRef<ChatMessage[]>(initialMessages);
useEffect(() => {
// 只在初始化时设置,不在后续更新
if (initialMessagesRef.current.length === 0 && initialMessages.length > 0) {
initialMessagesRef.current = initialMessages;
}
}, [initialMessages]);
// 调试:监控聊天状态变化 & 持久化生成状态
// 使用 ref 来访问最新的 modelsData,避免依赖数组问题
const modelsDataRef = useRef(modelsData);
useEffect(() => {
modelsDataRef.current = modelsData;
}, [modelsData]);
useEffect(() => {
console.log(`[Chat] Status changed: ${status}`);
if (status !== "submitted") {
startedGenerationMessageIdRef.current = null;
}
// 🆕 当状态变为 submitted 时,记录生成任务
if (status === "submitted") {
const latestMessage = messages.length > 0 ? messages[messages.length - 1] : null;
const latestUserMessageId = latestMessage?.role === "user" ? latestMessage.id : null;
if (latestUserMessageId && startedGenerationMessageIdRef.current === latestUserMessageId) {
return;
}
const latestUserHasImageInput =
latestMessage?.role === "user" &&
Array.isArray(latestMessage.parts) &&
latestMessage.parts.some((part: any) => {
if (!part) return false;
if (part.type === "file" || part.type === "image" || part.type === "image_url") return true;
if (part.type === "text" && typeof part.text === "string") {
return /\!\[thumb\||\!\[Generated Image/i.test(part.text);
}
return false;
});
// 判断模型类型 - 使用 ref 获取最新的 modelsData
const models = modelsDataRef.current?.models;
const model = models?.find((m: any) => m.id === currentModelId);
let modelType: PendingGeneration["modelType"] = "other";
// 🆕 gemini-image 和 huanke-gemini 都视为 gemini 类型
if (model?.providerType === "gemini" || model?.providerType === "gemini-image" || model?.providerType === "huanke-gemini") modelType = "gemini";
else if (model?.providerType === "volcano-vision") modelType = "volcano-vision";
else if (model?.providerType === "volcano-video" || model?.providerType === "doubao-video") modelType = "volcano-video";
else if (model?.providerType === "qianwen-video") modelType = "qianwen-video";
else if (model?.providerType === "fal-video") modelType = "fal-video";
else if (model?.providerType === "ruxa-video") modelType = "ruxa-video";
else if (
model?.providerType === "alibaba-image" ||
model?.providerType === "ruxa-image" ||
model?.providerType === "fal-image"
) modelType = "alibaba-image";
// 智能自动模式下,如果当前轮输入包含图片,默认按图片生成链路跟踪
if (currentModelId === "auto" && latestUserHasImageInput) {
modelType = "gemini";
}
console.log(`[Chat] 🚀 记录生成任务: modelType=${modelType}, modelId=${currentModelId}`);
startGeneration(currentModelId, modelType, latestUserMessageId || undefined);
if (latestUserMessageId) {
startedGenerationMessageIdRef.current = latestUserMessageId;
}
if (modelType === "gemini" || modelType === "volcano-vision" || modelType === "alibaba-image") {
updateImageGenerationPhase("queued");
}
}
}, [status, currentModelId, messages, startGeneration, updateImageGenerationPhase]);
const searchParams = useSearchParams();
const query = searchParams.get("query");
const editImageUrl = searchParams.get("editImage");
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
const [hasLoadedEditImage, setHasLoadedEditImage] = useState(false);
useEffect(() => {
if (query && !hasAppendedQuery) {
sendMessage({
role: "user" as const,
parts: [{ type: "text", text: query }],
});
setHasAppendedQuery(true);
window.history.replaceState({}, "", `/chat/${id}`);
}
}, [query, sendMessage, hasAppendedQuery, id]);
// 🆕 处理从Gallery跳转过来的图片编辑请求
useEffect(() => {
if (editImageUrl && !hasLoadedEditImage) {
const fileName = editImageUrl.split('/').pop()?.split('?')[0] || `image-${Date.now()}.jpg`;
// 推断图片类型
let contentType = "image/jpeg";
if (fileName.toLowerCase().endsWith(".png")) contentType = "image/png";
if (fileName.toLowerCase().endsWith(".webp")) contentType = "image/webp";
if (fileName.toLowerCase().endsWith(".gif")) contentType = "image/gif";
setAttachments([{
url: decodeURIComponent(editImageUrl),
name: fileName,
contentType: contentType,
}]);
// 自动填充提示词
setInput("请帮我编辑这张图片:");
setHasLoadedEditImage(true);
window.history.replaceState({}, "", `/chat/${id}`);
}
}, [editImageUrl, hasLoadedEditImage, id, setInput]);
// 🆕 处理从导航栏图片编辑器跳转过来的图片编辑请求
const [hasLoadedPendingImage, setHasLoadedPendingImage] = useState(false);
useEffect(() => {
if (!hasLoadedPendingImage) {
try {
// 安全地访问sessionStorage(某些浏览器设置可能禁止访问)
let pendingImage: string | null = null;
try {
pendingImage = sessionStorage.getItem("pending-edited-image");
} catch (storageError) {
// sessionStorage不可用(如隐私模式),忽略错误继续执行
console.warn("sessionStorage不可用,跳过待处理图片加载:", storageError);
}
if (pendingImage) {
// 清除 sessionStorage 中的数据,避免重复添加
try {
sessionStorage.removeItem("pending-edited-image");
} catch (removeError) {
// 忽略清除错误
}
// 判断是 base64 data URL 还是普通 URL
const fileName = pendingImage.startsWith("data:image/")
? `edited-image-${Date.now()}.png`
: pendingImage.split('/').pop()?.split('?')[0] || `image-${Date.now()}.jpg`;
// 推断图片类型
let contentType = "image/png";
if (pendingImage.startsWith("data:image/jpeg") || pendingImage.startsWith("data:image/jpg")) {
contentType = "image/jpeg";
} else if (pendingImage.startsWith("data:image/webp")) {
contentType = "image/webp";
} else if (pendingImage.startsWith("data:image/gif")) {
contentType = "image/gif";
} else if (!pendingImage.startsWith("data:")) {
// 普通 URL,根据文件名推断
if (fileName.toLowerCase().endsWith(".jpg") || fileName.toLowerCase().endsWith(".jpeg")) {
contentType = "image/jpeg";
} else if (fileName.toLowerCase().endsWith(".webp")) {
contentType = "image/webp";
} else if (fileName.toLowerCase().endsWith(".gif")) {
contentType = "image/gif";
}
}
setAttachments((prev) => [
...prev,
{
url: pendingImage,
name: fileName,
contentType: contentType,
},
]);
// 自动填充提示词(如果输入框为空)
setInput((prev) => prev || "请帮我处理这张图片:");
setHasLoadedPendingImage(true);
}
} catch (error) {
console.error("读取待处理图片失败:", error);
setHasLoadedPendingImage(true);
}
}
}, [hasLoadedPendingImage, setInput]);
const { data: votes } = useSWR<Vote[]>(
messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
fetcher
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
// 处理图片编辑请求
const handleImageEdit = (imageUrl: string) => {
// 优化:直接使用 URL 构造附件对象,无需重新下载上传
// 后端 API 会在发送时处理 URL(如果需要 Base64 转换)
const fileName = imageUrl.split('/').pop()?.split('?')[0] || `image-${Date.now()}.jpg`;
// 简单的类型推断
let contentType = "image/jpeg";
if (fileName.toLowerCase().endsWith(".png")) contentType = "image/png";
if (fileName.toLowerCase().endsWith(".webp")) contentType = "image/webp";
if (fileName.toLowerCase().endsWith(".gif")) contentType = "image/gif";
setAttachments((prev) => [
...prev,
{
url: imageUrl,
name: fileName,
contentType: contentType,
},
]);
// 如果输入框为空,自动填入提示词
setInput((prev) => prev ? prev : "请帮我编辑这张图片:");
};
// 处理画图标注完成
const handleDrawingComplete = async (annotatedImageUrl: string) => {
// annotatedImageUrl 是 base64 data URL 格式,需要先上传到服务器获取 URL
const fileName = `annotated-${Date.now()}.png`;
try {
// 如果是 base64 格式,先上传到服务器
if (annotatedImageUrl.startsWith("data:")) {
toast.info("正在上传标注图片...");
// 将 base64 转换为 Blob
const response = await fetch(annotatedImageUrl);
const blob = await response.blob();
// 创建 FormData 上传
const formData = new FormData();
formData.append("file", blob, fileName);
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const uploadResult = await uploadResponse.json();
setAttachments((prev) => [
...prev,
{
url: uploadResult.url,
name: fileName,
contentType: "image/png",
assetId:
typeof uploadResult.assetId === "string" ? uploadResult.assetId : undefined,
},
]);
toast.success("标注图片已添加");
} else {
// 如果已经是 URL,直接使用
setAttachments((prev) => [
...prev,
{
url: annotatedImageUrl,
name: fileName,
contentType: "image/png",
},
]);
}
// 如果输入框为空,自动填入提示词
setInput((prev) => prev ? prev : "请根据我的标注修改这张图片:");
} catch (error) {
console.error("上传标注图片失败:", error);
toast.error("上传标注图片失败,请重试");
}
};
// 处理图片变清晰
const handleImageEnhance = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingEnhance(true);
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在变清晰..." }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
const blob = await response.blob();
const reader = new FileReader();
reader.onloadend = async () => {
try {
const base64Data = (reader.result as string).split(",")[1];
const result = await requestLongTaskResult({
endpoint: "/api/enhance",
payload: {
image_base64: base64Data,
prompt: "图片变清晰处理",
chatId: id,
messageId,
},
pollingParams: {
prompt: "图片变清晰处理",
chatId: id,
messageId,
},
});
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息(前端显示用)
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
metadata: {
createdAt: new Date().toISOString(),
},
} as ChatMessage;
// 更新消息
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 滚动到底部,显示处理结果
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
attachments: [],
createdAt: new Date().toISOString(),
},
}),
});
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
toast.success(`变清晰完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "变清晰处理失败");
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
console.log("[变清晰] finally 块执行,重置 isProcessingEnhance");
setIsProcessingEnhance(false);
}
};
reader.onerror = () => {
console.log("[变清晰] reader.onerror 执行,重置 isProcessingEnhance");
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingEnhance(false);
};
reader.readAsDataURL(blob);
} catch (error) {
console.error("[变清晰] 错误:", error);
console.log("[变清晰] 外层 catch 执行,重置 isProcessingEnhance");
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingEnhance(false);
}
};
// 处理图片抠图
const handleImageRmbg = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingRmbg(true);
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在抠图..." }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
const blob = await response.blob();
const reader = new FileReader();
reader.onloadend = async () => {
try {
const base64Data = (reader.result as string).split(",")[1];
// 调用抠图API
const rmbgResponse = await fetch("/api/rmbg", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "抠图处理",
}),
});
if (!rmbgResponse.ok) {
const error = await rmbgResponse.json();
throw new Error(error.error || "抠图处理失败");
}
const result = await rmbgResponse.json();
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息(前端显示用)
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
metadata: {
createdAt: new Date().toISOString(),
},
} as ChatMessage;
// 更新消息
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 滚动到底部,显示处理结果
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
attachments: [],
createdAt: new Date().toISOString(),
},
}),
});
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
toast.success(`抠图完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
console.log("[抠图] finally 块执行,重置 isProcessingRmbg");
setIsProcessingRmbg(false);
}
};
reader.onerror = () => {
console.log("[抠图] reader.onerror 执行,重置 isProcessingRmbg");
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingRmbg(false);
};
reader.readAsDataURL(blob);
} catch (error) {
console.error("[抠图] 错误:", error);
console.log("[抠图] 外层 catch 执行,重置 isProcessingRmbg");
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingRmbg(false);
}
};
// 处理图片去水印
const handleImageWatermarkRemove = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingWatermarkRemove) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingWatermarkRemove(true);
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在去水印..." }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
const result = await requestLongTaskResult({
endpoint: "/api/watermark-remove",
payload: {
image_url: imageUrl,
},
});
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息(前端显示用)
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => {
const filtered = prev.filter((msg) => msg.id !== messageId);
return [...filtered, finalMessage];
});
// 保存消息到数据库
try {
const saveResponse = await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
},
}),
});
if (!saveResponse.ok) {
console.error("[Chat] Failed to save message:", await saveResponse.text());
}
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
toast.success(`去水印完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "去水印处理失败");
}
} catch (error) {
console.error("[去水印] 错误:", error);
toast.error(error instanceof Error ? error.message : "去水印失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
setIsProcessingWatermarkRemove(false);
}
};
// 处理图像拓展
const handleImageExtend = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingExtend(true);
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在图像拓展..." }],
metadata: {
createdAt: new Date().toISOString(),
},
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
const blob = await response.blob();
const reader = new FileReader();
reader.onloadend = async () => {
try {
const base64Data = (reader.result as string).split(",")[1];
// 调用图像拓展API
const extendResponse = await fetch("/api/extend", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "图片图像拓展处理",
}),
});
if (!extendResponse.ok) {
const error = await extendResponse.json();
throw new Error(error.error || "图像拓展处理失败");
}
const result = await extendResponse.json();
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息(前端显示用)
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
metadata: {
createdAt: new Date().toISOString(),
},
} as ChatMessage;
// 更新消息
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 滚动到底部,显示处理结果
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
attachments: [],
createdAt: new Date().toISOString(),
},
}),
});
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
toast.success(`图像拓展完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "图像拓展处理失败");
}
} catch (error) {
console.error("[图像拓展] 错误:", error);
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
console.log("[图像拓展] finally 块执行,重置 isProcessingExtend");
setIsProcessingExtend(false);
}
};
reader.onerror = () => {
console.log("[图像拓展] reader.onerror 执行,重置 isProcessingExtend");
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingExtend(false);
};
reader.readAsDataURL(blob);
} catch (error) {
console.error("[图像拓展] 错误:", error);
console.log("[图像拓展] 外层 catch 执行,重置 isProcessingExtend");
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingExtend(false);
}
};
// 处理角度编辑完成
const handleImageAngleEdit = async (resultUrl: string) => {
const messageId = generateUUID();
// 构造图片Markdown(使用与其他处理功能相同的格式)
const imageMarkdown = `![thumb|||${resultUrl}|||2K](${resultUrl})`;
// 创建最终消息
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
metadata: {
createdAt: new Date().toISOString(),
},
} as ChatMessage;
// 添加消息
setMessages((prev) => [...prev, finalMessage]);
// 滚动到底部
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
attachments: [],
createdAt: new Date().toISOString(),
},
}),
});
} catch (saveError) {
console.error("[Chat] Failed to save angle edit message:", saveError);
}
};
// 轮询视频任务状态:解决刷新后“处理中”无法自动恢复结果的问题
useEffect(() => {
if (isReadonly) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let previousPendingCount = -1;
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
dbMessages.forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
const persistedIds = new Set<string>(dbMessages.map((message) => message.id));
setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id)));
};
const resolveModelTypeByProvider = (
provider?: string
): PendingGeneration["modelType"] => {
if (provider === "qianwen-video") return "qianwen-video";
if (provider === "fal-video") return "fal-video";
if (provider === "ruxa-video") return "ruxa-video";
if (provider === "volcano-video" || provider === "doubao-video") return "volcano-video";
return "other";
};
const pollPendingVideoTasks = async () => {
try {
const response = await fetch(`/api/video-tasks?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return;
const data = await response.json();
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
const pendingCount = tasks.length;
if (pendingCount > 0 && !pendingGenerationRef.current) {
const firstTask = tasks[0] as { provider?: string } | undefined;
startGeneration(
currentModelIdRef.current,
resolveModelTypeByProvider(firstTask?.provider),
);
}
if (previousPendingCount > 0 && pendingCount === 0) {
const msgResponse = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (msgResponse.ok) {
const msgData = await msgResponse.json();
if (Array.isArray(msgData?.messages)) {
mergeMessagesFromDatabase(msgData.messages as ChatMessage[]);
}
}
completeGeneration();
}
previousPendingCount = pendingCount;
} catch (error) {
console.warn("[Chat] video task polling failed", error);
} finally {
if (!cancelled) {
timer = setTimeout(pollPendingVideoTasks, 5000);
}
}
};
pollPendingVideoTasks();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [id, isReadonly, setMessages, setAppendedMessages, startGeneration, completeGeneration]);
useAutoResume({
autoResume,
initialMessages,
resumeStream,
setMessages,
});
// 🆕 合并消息:初始消息 + useChat 消息 + appendedMessages
// 确保历史消息不会因为 useChat 内部状态重置而丢失
const mergedMessages = useMemo(() => {
// 使用 Map 进行去重,以 ID 为键
const messageMap = new Map<string, ChatMessage>();
// 1. 首先添加初始消息(历史记录)
initialMessagesRef.current.forEach(m => {
messageMap.set(m.id, m);
});
// 2. 然后添加 useChat 的当前消息(会覆盖相同 ID 的旧消息)
messages.forEach(m => {
messageMap.set(m.id, m);
});
// 3. 最后添加 appendedMessages(data-appendMessage 的消息)
appendedMessages.forEach(m => {
const existing = messageMap.get(m.id);
if (!existing) {
messageMap.set(m.id, m);
return;
}
const existingHasImage = hasImageMarkdown((existing as any).parts);
const nextHasImage = hasImageMarkdown((m as any).parts);
const shouldReplace =
(nextHasImage && !existingHasImage) ||
getMessageTextLength(m) > getMessageTextLength(existing);
if (shouldReplace) {
messageMap.set(m.id, {
...existing,
...m,
createdAt: (existing as any).createdAt || (m as any).createdAt,
} as ChatMessage);
}
});
// 按创建时间排序
const result = Array.from(messageMap.values()).sort((a, b) => {
const timeA = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0;
const timeB = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0;
return timeA - timeB;
});
console.log("[Chat] Merged messages:", result.length, "(initial:", initialMessagesRef.current.length, ", current:", messages.length, ", appended:", appendedMessages.length, ")");
return result;
}, [messages, appendedMessages]);
return (
<>
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
isReadonly={isReadonly}
selectedVisibilityType={initialVisibilityType}
/>
<Messages
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={mergedMessages}
regenerate={regenerate}
sendMessage={sendMessage}
selectedModelId={currentModelId}
setMessages={setMessages}
status={status}
votes={votes}
onImageEdit={handleImageEdit}
onDrawingComplete={handleDrawingComplete}
onImageEnhance={handleImageEnhance}
onImageRmbg={handleImageRmbg}
onImageExtend={handleImageExtend}
onImageAngleEdit={handleImageAngleEdit}
onImageWatermarkRemove={handleImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
isGeneratingImageFlag={isGeneratingImageFlag}
imageGenerationPhase={imageGenerationPhase}
imageGenerationStartedAt={imageGenerationStartedAt}
/>
<div className="sticky bottom-0 z-1 mx-auto w-full max-w-4xl bg-background px-2 pb-3 md:px-4 md:pb-4">
{!isReadonly && (
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={mergedMessages}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
usage={usage}
isVideoModel={isVideoModel}
videoModelName={currentModel?.displayName}
videoParams={videoParams}
videoProviderType={currentModel?.providerType}
videoToolCapability={currentModel?.videoToolCapability}
onVideoParamsChange={(params) => {
setVideoParams(params);
console.log("[Chat] Video params updated:", params);
}}
isImageModel={isImageModel}
imageModelName={currentModel?.displayName}
imageParams={imageParams}
onImageParamsChange={handleImageParamsChange}
imageProviderType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
/>
)}
</div>
</div>
<Artifact
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={mergedMessages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
votes={votes}
/>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>启用 AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
此应用需要
{process.env.NODE_ENV === "production" ? "项目拥有者" : "你"}
激活 Vercel AI Gateway。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
window.open(
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
);
window.location.href = "/";
}}
>
立即启用
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(insufficientPointsState)}
onOpenChange={(open) => {
if (!open) setInsufficientPointsState(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>积分不足</AlertDialogTitle>
<AlertDialogDescription>
{insufficientPointsState
? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。`
: "当前积分不足,请先充值后再发送生图需求。"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>稍后再说</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setInsufficientPointsState(null);
window.location.href = "/recharge";
}}
>
去充值
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}