From 1fe2c3ba8abf9eb21f09f230fc4f3f467ac3a726 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Feb 2026 01:54:13 +0800 Subject: [PATCH] feat: block image send when points are insufficient --- components/canvas/CanvasChatPanel.tsx | 137 +++++++++++++++++++++++++- components/chat.tsx | 58 +++++++++++ components/multimodal-input.tsx | 121 ++++++++++++++++++++++- lib/utils.ts | 31 +++++- 4 files changed, 342 insertions(+), 5 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index b0106fd8c..bc30926d5 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -8,6 +8,16 @@ import useSWR, { useSWRConfig } from "swr"; import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { DropdownMenu, DropdownMenuContent, @@ -944,6 +954,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const [lastImageModelId, setLastImageModelId] = useState(null); const [lastVideoModelId, setLastVideoModelId] = useState(null); const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false); + const [insufficientPointsState, setInsufficientPointsState] = useState<{ + balance: number; + expectedCost: number; + unitCost: number; + imageCount: number; + } | null>(null); const imageParamsRef = useRef(imageParams); const videoParamsRef = useRef(videoParams); const deepThinkingEnabledRef = useRef(deepThinkingEnabled); @@ -1517,6 +1533,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; + creditCostPerImage?: number; } | null; }>; agentModel?: { @@ -1531,6 +1548,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; + creditCostPerImage?: number; } | null; } | null; }>("/api/models", fetcher); @@ -1545,6 +1563,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF canvasDefaultModelId?: string | null; canvasAutoModeDefaultModelId?: string | null; }>("/api/site-settings", fetcher); + const { data: membershipSummary } = useSWR<{ + pointsSystemConfig?: { + enabled?: boolean; + blockOnInsufficient?: boolean; + }; + summary?: { + currentCredits?: number; + }; + } | null>("/api/user/membership-summary", async (url: string) => { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) return null; + return response.json(); + }); const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true; const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video"]); @@ -2178,7 +2209,30 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } }, onError: (error) => { - toast.error(error.message || "生成失败"); + 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, + }); + } + + toast.error(fallbackMessage || "生成失败"); }, }); @@ -3875,6 +3929,52 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF siteSettings?.canvasDefaultModelId, ]); + const ensureImagePointsBeforeSend = useCallback(() => { + if (isVideoModel) return true; + + const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true; + const blockOnInsufficient = + membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false; + const unitCost = Math.max( + 0, + Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0)) + ); + + if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) { + return true; + } + + const requestedImageCount = Math.max( + 1, + Math.round(Number(imageParams?.imageCount || 1)) + ); + const expectedCost = unitCost * requestedImageCount; + const currentBalance = Math.max( + 0, + Number(membershipSummary?.summary?.currentCredits ?? 0) + ); + + if (currentBalance >= expectedCost) { + return true; + } + + setInsufficientPointsState({ + balance: currentBalance, + expectedCost, + unitCost, + imageCount: requestedImageCount, + }); + toast.error("积分不足,请先充值后再生图"); + return false; + }, [ + currentModel?.imageGenConfig?.creditCostPerImage, + imageParams?.imageCount, + isVideoModel, + membershipSummary?.pointsSystemConfig?.blockOnInsufficient, + membershipSummary?.pointsSystemConfig?.enabled, + membershipSummary?.summary?.currentCredits, + ]); + // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { if (isDetailGenerationMode && aiDetailGenerationEnabled) { @@ -3887,6 +3987,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.error("请选择一个模型"); return; } + + if (!ensureImagePointsBeforeSend()) { + return; + } // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; @@ -4033,6 +4137,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF isDetailGenerationMode, aiDetailGenerationEnabled, handleDetailGeneration, + ensureImagePointsBeforeSend, ]); const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => { @@ -6020,6 +6125,36 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF document.body ); })()} + { + if (!open) setInsufficientPointsState(null); + }} + > + + + 积分不足 + + {insufficientPointsState + ? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。` + : "当前积分不足,请先充值后再发送生图需求。"} + + + + 稍后再说 + { + setInsufficientPointsState(null); + if (typeof window !== "undefined") { + window.location.href = "/recharge"; + } + }} + > + 去充值 + + + + ); } diff --git a/components/chat.tsx b/components/chat.tsx index 1ef4b5bcf..9196694bf 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -311,6 +311,12 @@ export function Chat({ const [input, setInput] = useState(""); const [usage, setUsage] = useState(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); @@ -737,6 +743,28 @@ export function Chat({ 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 ( @@ -746,6 +774,8 @@ export function Chat({ } else { toast.error(error.message); } + } else { + toast.error(fallbackMessage || "请求失败,请稍后重试"); } }, }); @@ -1975,6 +2005,34 @@ export function Chat({ + { + if (!open) setInsufficientPointsState(null); + }} + > + + + 积分不足 + + {insufficientPointsState + ? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。` + : "当前积分不足,请先充值后再发送生图需求。"} + + + + 稍后再说 + { + setInsufficientPointsState(null); + window.location.href = "/recharge"; + }} + > + 去充值 + + + + ); } diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index c8cc0021c..a37b2967b 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -2,6 +2,7 @@ import type { UseChatHelpers } from "@ai-sdk/react"; import { Bot, ImageIcon, Sparkles, Video } from "lucide-react"; +import { useRouter } from "next/navigation"; // 动态模型类型定义 type DynamicModel = { @@ -24,9 +25,20 @@ type DynamicModel = { supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; + creditCostPerImage?: number; } | null; }; +type PointsSummaryResponse = { + pointsSystemConfig?: { + enabled?: boolean; + blockOnInsufficient?: boolean; + }; + summary?: { + currentCredits?: number; + }; +} | null; + const fetcher = (url: string) => fetch(url).then((res) => res.json()); import { Trigger } from "@radix-ui/react-select"; import type { UIMessage } from "ai"; @@ -48,6 +60,16 @@ import useSWR from "swr"; import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { saveChatModelAsCookie } from "@/app/(chat)/actions"; import { SelectItem } from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import type { Attachment, ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; import { cn } from "@/lib/utils"; @@ -135,6 +157,7 @@ function PureMultimodalInput({ supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; + creditCostPerImage?: number; } | null; }) { // 🔍 调试日志:检查图片模型相关props @@ -142,11 +165,26 @@ function PureMultimodalInput({ console.log('[MultimodalInput] Render - imageParams:', imageParams); console.log('[MultimodalInput] Render - onImageParamsChange:', !!onImageParamsChange); + const router = useRouter(); const textareaRef = useRef(null); const { width } = useWindowSize(); + const [insufficientPointsState, setInsufficientPointsState] = useState<{ + balance: number; + expectedCost: number; + unitCost: number; + imageCount: number; + } | null>(null); // 获取网站设置(用于时间限制检查) const { data: siteSettings } = useSWR("/api/site-settings", fetcher); + const { data: membershipSummary } = useSWR( + "/api/user/membership-summary", + async (url: string) => { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) return null; + return response.json(); + } + ); // 检查当前时间是否在允许的使用时间段内 const isWithinUsageTime = useMemo(() => { @@ -224,7 +262,57 @@ function PureMultimodalInput({ const [draggedIndex, setDraggedIndex] = useState(null); const [videoSlotUploading, setVideoSlotUploading] = useState(null); - const submitForm = useCallback(() => { + const ensureImagePointsBeforeSend = useCallback(async () => { + if (!isImageModel || isVideoModel) return true; + + const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true; + const blockOnInsufficient = + membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false; + const unitCost = Math.max( + 0, + Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0)) + ); + + if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) { + return true; + } + + const requestedImageCount = Math.max( + 1, + Math.round(Number(imageParams?.imageCount || 1)) + ); + const expectedCost = unitCost * requestedImageCount; + const currentBalance = Math.max( + 0, + Number(membershipSummary?.summary?.currentCredits ?? 0) + ); + + if (currentBalance >= expectedCost) { + return true; + } + + setInsufficientPointsState({ + balance: currentBalance, + expectedCost, + unitCost, + imageCount: requestedImageCount, + }); + toast.error("积分不足,请先充值后再生图"); + return false; + }, [ + imageGenConfig?.creditCostPerImage, + imageParams?.imageCount, + isImageModel, + isVideoModel, + membershipSummary?.pointsSystemConfig?.blockOnInsufficient, + membershipSummary?.pointsSystemConfig?.enabled, + membershipSummary?.summary?.currentCredits, + ]); + + const submitForm = useCallback(async () => { + const canContinue = await ensureImagePointsBeforeSend(); + if (!canContinue) return; + window.history.pushState({}, "", `/chat/${chatId}`); sendMessage({ @@ -252,6 +340,7 @@ function PureMultimodalInput({ textareaRef.current?.focus(); } }, [ + ensureImagePointsBeforeSend, input, setInput, attachments, @@ -850,7 +939,7 @@ function PureMultimodalInput({ if (status === "submitted" || status === "streaming") { toast.error("请等待模型返回当前回复后再继续"); } else { - submitForm(); + void submitForm(); } }} > @@ -1089,6 +1178,34 @@ function PureMultimodalInput({ )} + { + if (!open) setInsufficientPointsState(null); + }} + > + + + 积分不足 + + {insufficientPointsState + ? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。` + : "当前积分不足,请先充值后再发送生图需求。"} + + + + 稍后再说 + { + setInsufficientPointsState(null); + router.push("/recharge"); + }} + > + 去充值 + + + + ); } diff --git a/lib/utils.ts b/lib/utils.ts index 2badd3455..685969e81 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -34,8 +34,35 @@ export async function fetchWithErrorHandlers( const response = await fetch(input, init); if (!response.ok) { - const { code, cause } = await response.json(); - throw new ChatSDKError(code as ErrorCode, cause); + let payload: any = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + const code = + typeof payload?.code === "string" && payload.code.includes(":") + ? (payload.code as ErrorCode) + : null; + const cause = typeof payload?.cause === "string" ? payload.cause : undefined; + + if (code) { + throw new ChatSDKError(code, cause); + } + + const fallbackMessage = + (typeof payload?.message === "string" && payload.message) || + (typeof payload?.error === "string" && payload.error) || + `Request failed with status ${response.status}`; + const requestError = new Error(fallbackMessage) as Error & { + status?: number; + data?: any; + }; + requestError.name = "RequestError"; + requestError.status = response.status; + requestError.data = payload; + throw requestError; } return response;