feat: block image send when points are insufficient
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-21 01:54:13 +08:00
parent de955db82f
commit 1fe2c3ba8a
4 changed files with 342 additions and 5 deletions

View File

@ -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<string | null>(null);
const [lastVideoModelId, setLastVideoModelId] = useState<string | null>(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
);
})()}
<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);
if (typeof window !== "undefined") {
window.location.href = "/recharge";
}
}}
>
去充值
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -311,6 +311,12 @@ export function Chat({
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);
@ -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({
</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>
</>
);
}

View File

@ -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<HTMLTextAreaElement>(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<PointsSummaryResponse>(
"/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<number | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(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({
)}
</PromptInputToolbar>
</PromptInput>
<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);
router.push("/recharge");
}}
>
去充值
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

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