feat: block image send when points are insufficient
This commit is contained in:
parent
de955db82f
commit
1fe2c3ba8a
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
31
lib/utils.ts
31
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user