2226 lines
80 KiB
TypeScript
2226 lines
80 KiB
TypeScript
"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 {
|
||
isImageGenerationModelDescriptor,
|
||
isVideoModelDescriptor,
|
||
} from "@/lib/ai/model-classification";
|
||
import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability";
|
||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||
import { Artifact } from "@/components/artifact";
|
||
import { ProtocolDebugPanel } from "@/components/protocol-debug-panel";
|
||
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" || providerType === "openai-image" || providerType === "midjourney") {
|
||
normalized.imageCount = 1;
|
||
return normalized;
|
||
}
|
||
|
||
if (providerType === "runway") {
|
||
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 modeSet = new Set(modeOptions.map((item) => item.mode));
|
||
const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video";
|
||
|
||
const merged = {
|
||
mode: fallbackMode,
|
||
resolution: (capability.defaults?.resolution || "720p") as VideoGenerationParams["resolution"],
|
||
ratio: (capability.defaults?.ratio || "16:9") as VideoGenerationParams["ratio"],
|
||
duration: Number(capability.defaults?.duration) || 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"];
|
||
}
|
||
|
||
const scopedOptions = getVideoToolOptionsForMode(capability, merged.mode);
|
||
const ratioOptions = scopedOptions.ratioOptions || [];
|
||
const durationOptions = scopedOptions.durationOptions || [];
|
||
const resolutionOptions = scopedOptions.resolutionOptions || [];
|
||
const ratioSet = new Set(ratioOptions);
|
||
const durationSet = new Set(durationOptions);
|
||
const resolutionSet = new Set(resolutionOptions);
|
||
const fallbackRatio =
|
||
scopedOptions.defaults?.ratio || ratioOptions[0] || capability.defaults?.ratio || "16:9";
|
||
const fallbackDuration =
|
||
Number(scopedOptions.defaults?.duration) ||
|
||
durationOptions[0] ||
|
||
Number(capability.defaults?.duration) ||
|
||
5;
|
||
const fallbackResolution =
|
||
scopedOptions.defaults?.resolution ||
|
||
resolutionOptions[0] ||
|
||
capability.defaults?.resolution ||
|
||
"720p";
|
||
|
||
if (!ratioSet.has(merged.ratio)) {
|
||
merged.ratio = fallbackRatio as VideoGenerationParams["ratio"];
|
||
}
|
||
if (!durationSet.has(Number(merged.duration))) {
|
||
merged.duration = fallbackDuration;
|
||
} else {
|
||
merged.duration = Number(merged.duration) || fallbackDuration;
|
||
}
|
||
if (resolutionOptions.length > 0 && !resolutionSet.has(merged.resolution)) {
|
||
merged.resolution = fallbackResolution as VideoGenerationParams["resolution"];
|
||
} else if (resolutionOptions.length === 0) {
|
||
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 searchParams = useSearchParams();
|
||
const protocolDebugEnabled = searchParams.get("debugProtocol") === "1";
|
||
const [protocolDebugEvents, setProtocolDebugEvents] = useState<
|
||
Array<{ id: string; at: number; payload: Record<string, unknown> }>
|
||
>([]);
|
||
|
||
useEffect(() => {
|
||
setProtocolDebugEvents([]);
|
||
}, [id]);
|
||
|
||
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;
|
||
capabilities?: any;
|
||
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 = isVideoModelDescriptor({
|
||
providerType: currentModel?.providerType,
|
||
modelId: currentModel?.modelId,
|
||
capabilities: currentModel?.capabilities,
|
||
supportsImageGen: currentModel?.supportsImageGen,
|
||
});
|
||
const isImageModel = isImageGenerationModelDescriptor({
|
||
providerType: currentModel?.providerType,
|
||
modelId: currentModel?.modelId,
|
||
capabilities: currentModel?.capabilities,
|
||
supportsImageGen: currentModel?.supportsImageGen,
|
||
});
|
||
|
||
// 🔍 调试日志
|
||
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,
|
||
debugProtocol: protocolDebugEnabled,
|
||
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);
|
||
}
|
||
}
|
||
|
||
if (dataPart.type === "data-protocolDebug") {
|
||
try {
|
||
const payload =
|
||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||
if (!payload || typeof payload !== "object") return;
|
||
setProtocolDebugEvents((prev) => [
|
||
...prev.slice(-59),
|
||
{
|
||
id: generateUUID(),
|
||
at: Date.now(),
|
||
payload,
|
||
},
|
||
]);
|
||
} catch (e) {
|
||
console.error("[Chat] Failed to parse data-protocolDebug:", 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 (
|
||
isVideoModelDescriptor({
|
||
providerType: model?.providerType,
|
||
modelId: model?.modelId,
|
||
capabilities: model?.capabilities,
|
||
supportsImageGen: model?.supportsImageGen,
|
||
})
|
||
) modelType = model?.providerType === "qianwen-video"
|
||
? "qianwen-video"
|
||
: model?.providerType === "fal-video"
|
||
? "fal-video"
|
||
: model?.providerType === "ruxa-video"
|
||
? "ruxa-video"
|
||
: "volcano-video";
|
||
else if (
|
||
isImageGenerationModelDescriptor({
|
||
providerType: model?.providerType,
|
||
modelId: model?.modelId,
|
||
capabilities: model?.capabilities,
|
||
supportsImageGen: model?.supportsImageGen,
|
||
})
|
||
) 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 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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息
|
||
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>
|
||
<ProtocolDebugPanel
|
||
enabled={protocolDebugEnabled}
|
||
events={protocolDebugEvents}
|
||
onClear={() => setProtocolDebugEvents([])}
|
||
/>
|
||
</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>
|
||
</>
|
||
);
|
||
}
|