diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 6e5b3f790..44ed7ede0 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -139,6 +139,11 @@ import type { ResolvedTargetAssets, } from "@/lib/ai/context-memory/types"; import { ChatSDKError } from "@/lib/errors"; +import { + getFriendlyRateLimitMessage, + getRequestErrorMessage, + isRateLimitError, +} from "@/lib/request-error"; import { extractImageUrlsFromText } from "@/lib/image-url"; import { resolveClarificationBypass, @@ -12637,6 +12642,28 @@ export async function POST(request: Request) { return error.toResponse(); } + if (isRateLimitError(error)) { + const detailMessage = getRequestErrorMessage(error); + console.warn("[Chat API] Upstream rate limited request:", { + vercelId, + error: detailMessage, + }); + return NextResponse.json( + { + code: "rate_limit:chat", + message: getFriendlyRateLimitMessage(), + cause: detailMessage || "upstream_rate_limited", + retryAfter: 10, + }, + { + status: 429, + headers: { + "Retry-After": "10", + }, + } + ); + } + // Check for Vercel AI Gateway credit card error if ( error instanceof Error && diff --git a/app/api/extend/route.ts b/app/api/extend/route.ts index 61676c735..137fab98f 100644 --- a/app/api/extend/route.ts +++ b/app/api/extend/route.ts @@ -1,9 +1,11 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; +import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service"; import { - executeExtendJob, - ExtendServiceError, -} from "@/lib/ai/extend/service"; + getFriendlyRateLimitMessage, + getUserFacingRequestErrorMessage, + isRateLimitError, +} from "@/lib/request-error"; export const maxDuration = 180; @@ -89,9 +91,25 @@ export async function POST(request: NextRequest) { ); } catch (error) { if (error instanceof ExtendServiceError) { + if (error.status === 429 || isRateLimitError(error)) { + return NextResponse.json( + { + error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"), + detail: error.detail || error.message, + retryAfter: 10, + }, + { + status: 429, + headers: { + "Retry-After": "10", + }, + }, + ); + } + return NextResponse.json( { - error: error.message, + error: getUserFacingRequestErrorMessage(error, "图像拓展失败"), detail: error.detail || error.message, }, { status: error.status }, @@ -99,9 +117,24 @@ export async function POST(request: NextRequest) { } console.error("[Extend API] 错误:", error); + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"), + detail: error instanceof Error ? error.message : String(error), + retryAfter: 10, + }, + { + status: 429, + headers: { + "Retry-After": "10", + }, + }, + ); + } return NextResponse.json( { - error: "图像拓展失败", + error: getUserFacingRequestErrorMessage(error, "图像拓展失败"), detail: error instanceof Error ? error.message : String(error), }, { status: 500 }, diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index 0e5087e68..234ebaa16 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -1,22 +1,27 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; -import { - getEditableAgentPsdTemplateLayers, - getAgentPsdTemplateRuntimeIssue, - isAgentPsdImageLayerSlot, - isAgentPsdTemplateRuntimeReady, - isAgentPsdTemplateReady, - isAgentPsdTextLayerSlot, - type AgentPsdTemplateRenderResponse, -} from "@/lib/agents/psd-template-config"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement"; import { describeRemoteAssetLocation } from "@/lib/agents/psd-remote-asset"; -import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair"; +import { + type AgentPsdTemplateRenderResponse, + getAgentPsdTemplateRuntimeIssue, + getEditableAgentPsdTemplateLayers, + isAgentPsdImageLayerSlot, + isAgentPsdTemplateReady, + isAgentPsdTemplateRuntimeReady, + isAgentPsdTextLayerSlot, +} from "@/lib/agents/psd-template-config"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; -import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; +import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; +import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; +import { + getFriendlyRateLimitMessage, + getUserFacingRequestErrorMessage, + isRateLimitError, +} from "@/lib/request-error"; export const runtime = "nodejs"; @@ -163,10 +168,26 @@ export async function POST(request: NextRequest) { assignedImageCount: Object.keys(body.imageAssignments || {}).length, error, }); + + if (isRateLimitError(error)) { + return NextResponse.json( + { + success: false, + error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"), + } satisfies AgentPsdTemplateRenderResponse, + { + status: 429, + headers: { + "Retry-After": "10", + }, + } + ); + } + const message = - error instanceof Error && String(error.message || "").trim() - ? error.message - : "PSD 模板渲染失败"; + getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", { + rateLimitMessage: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"), + }); return jsonError(502, message); } } diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 545d027a4..2574bc7f8 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -81,6 +81,7 @@ import { resolveTextLayout, } from "@/lib/agents/psd-text-layout"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error"; function buildTemplateListRequestUrl(params?: { brandName?: string | null; @@ -182,6 +183,55 @@ const templateDetailFetcher = async ( } }; +async function parseActionJsonResponse( + response: Response, + fallbackMessage: string, + rateLimitMessage?: string +): Promise { + const payload = await response + .clone() + .json() + .catch(() => null); + + if (!response.ok) { + let responseText = ""; + if (payload == null) { + responseText = await response + .text() + .then((value) => value.trim()) + .catch(() => ""); + } + + const requestError = new Error( + getUserFacingRequestErrorMessage( + { + status: response.status, + data: payload, + message: responseText, + }, + fallbackMessage, + { + rateLimitMessage, + } + ) + ) as Error & { + status?: number; + data?: unknown; + }; + requestError.status = response.status; + requestError.data = + payload ?? + (responseText + ? { + message: responseText, + } + : null); + throw requestError; + } + + return payload as T; +} + const PREVIEW_VIEWPORT_PADDING = 56; const PREVIEW_STAGE_MAX_SCALE = 0.82; const PREVIEW_STAGE_TARGET_WIDTH = 760; @@ -1946,15 +1996,20 @@ export function AgentPsdTemplateEditorDialog({ const formData = new FormData(); formData.set("file", file); const response = await fetchUploadWithRetry(formData); - const payload = await response.json(); + const payload = await parseActionJsonResponse<{ url?: string }>( + response, + "素材上传失败", + getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试") + ); - if (!response.ok || !payload.url) { - throw new Error(payload.error || "图片上传失败"); + if (!payload?.url) { + throw new Error("图片上传失败"); } + const uploadedImageUrl = payload.url; setImageAssignments((current) => ({ ...current, - [slotId]: payload.url, + [slotId]: uploadedImageUrl, })); setImagePlacements((current) => ({ ...current, @@ -1967,7 +2022,11 @@ export function AgentPsdTemplateEditorDialog({ }); toast.success("替换图片已上传"); } catch (error) { - toast.error(error instanceof Error ? error.message : "素材上传失败"); + toast.error( + getUserFacingRequestErrorMessage(error, "素材上传失败", { + rateLimitMessage: getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试"), + }) + ); } finally { setUploadingSlotId(null); event.target.value = ""; @@ -1993,13 +2052,14 @@ export function AgentPsdTemplateEditorDialog({ prompt: "PSD 套版图片拓展处理", }), }); - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw new Error( - String(payload?.error || payload?.detail || "").trim() || - "图片拓展失败" - ); - } + const payload = await parseActionJsonResponse<{ + original_url?: string; + thumbnail_url?: string; + }>( + response, + "图片拓展失败", + getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试") + ); const nextImageUrl = String( payload?.original_url || payload?.thumbnail_url || "" @@ -2028,7 +2088,11 @@ export function AgentPsdTemplateEditorDialog({ : "图片拓展完成,已替换当前槽位" ); } catch (error) { - toast.error(error instanceof Error ? error.message : "图片拓展失败"); + toast.error( + getUserFacingRequestErrorMessage(error, "图片拓展失败", { + rateLimitMessage: getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试"), + }) + ); } finally { setExtendingSlotId(null); } @@ -2154,9 +2218,13 @@ export function AgentPsdTemplateEditorDialog({ }), }); - const payload = (await response.json()) as AgentPsdTemplateRenderResponse; - if (!response.ok || !payload.success || !payload.result?.url) { - throw new Error(payload.error || "PSD 模板渲染失败"); + const payload = await parseActionJsonResponse( + response, + "PSD 模板渲染失败", + getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试") + ); + if (!payload?.success || !payload.result?.url) { + throw new Error(payload?.error || "PSD 模板渲染失败"); } setRenderedResultUrl(payload.result.url); @@ -2168,7 +2236,11 @@ export function AgentPsdTemplateEditorDialog({ : "模板替换结果已生成" ); } catch (error) { - toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败"); + toast.error( + getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", { + rateLimitMessage: getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试"), + }) + ); } finally { setIsRendering(false); } diff --git a/components/chat.tsx b/components/chat.tsx index 83c5895e6..1a5afbff9 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -33,6 +33,7 @@ import { isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability"; +import { getFriendlyRateLimitMessage, isRateLimitError, getUserFacingRequestErrorMessage } from "@/lib/request-error"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { Artifact } from "@/components/artifact"; import { ProtocolDebugPanel } from "@/components/protocol-debug-panel"; @@ -373,6 +374,7 @@ export function Chat({ const imagePlaceholderTimerRef = useRef | null>(null); const latestAssistantDisplayTextRef = useRef(""); const startedGenerationMessageIdRef = useRef(null); + const lastRateLimitToastAtRef = useRef(0); // 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突 const [appendedMessages, setAppendedMessages] = useState([]); @@ -948,6 +950,18 @@ export function Chat({ }); } + const normalizedErrorMessage = getUserFacingRequestErrorMessage( + { + ...(error instanceof Error ? error : {}), + data: responseData, + message: fallbackMessage, + }, + "请求失败,请稍后重试", + { + rateLimitMessage: getFriendlyRateLimitMessage(), + } + ); + if (error instanceof ChatSDKError) { // Check if it's a credit card error if ( @@ -955,10 +969,17 @@ export function Chat({ ) { setShowCreditCardAlert(true); } else { - toast.error(error.message); + toast.error(normalizedErrorMessage); } } else { - toast.error(fallbackMessage || "请求失败,请稍后重试"); + if (isRateLimitError({ ...(error as any), data: responseData, message: fallbackMessage })) { + const now = Date.now(); + if (now - lastRateLimitToastAtRef.current < 4000) { + return; + } + lastRateLimitToastAtRef.current = now; + } + toast.error(normalizedErrorMessage); } }, }); @@ -1459,7 +1480,11 @@ export function Chat({ } } catch (error) { console.error("[变清晰] 错误:", error); - toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "变清晰失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前变清晰请求过于频繁,请稍后再试"), + }) + ); // 移除加载消息 setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); } finally { @@ -1479,7 +1504,11 @@ export function Chat({ } catch (error) { console.error("[变清晰] 错误:", error); console.log("[变清晰] 外层 catch 执行,重置 isProcessingEnhance"); - toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "变清晰失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前变清晰请求过于频繁,请稍后再试"), + }) + ); setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); setIsProcessingEnhance(false); } @@ -1604,7 +1633,11 @@ export function Chat({ } } catch (error) { console.error("[抠图] 错误:", error); - toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "抠图失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前抠图请求过于频繁,请稍后再试"), + }) + ); // 移除加载消息 setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); } finally { @@ -1624,7 +1657,11 @@ export function Chat({ } catch (error) { console.error("[抠图] 错误:", error); console.log("[抠图] 外层 catch 执行,重置 isProcessingRmbg"); - toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "抠图失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前抠图请求过于频繁,请稍后再试"), + }) + ); setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); setIsProcessingRmbg(false); } @@ -1715,7 +1752,11 @@ export function Chat({ } } catch (error) { console.error("[去水印] 错误:", error); - toast.error(error instanceof Error ? error.message : "去水印失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "去水印失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前去水印请求过于频繁,请稍后再试"), + }) + ); // 移除加载消息 setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); } finally { @@ -1844,7 +1885,11 @@ export function Chat({ } } catch (error) { console.error("[图像拓展] 错误:", error); - toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "图像拓展失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前图像拓展请求过于频繁,请稍后再试"), + }) + ); // 移除加载消息 setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); } finally { @@ -1864,7 +1909,11 @@ export function Chat({ } catch (error) { console.error("[图像拓展] 错误:", error); console.log("[图像拓展] 外层 catch 执行,重置 isProcessingExtend"); - toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); + toast.error( + getUserFacingRequestErrorMessage(error, "图像拓展失败,请稍后重试", { + rateLimitMessage: getFriendlyRateLimitMessage("当前图像拓展请求过于频繁,请稍后再试"), + }) + ); setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); setIsProcessingExtend(false); } diff --git a/lib/errors.ts b/lib/errors.ts index 7e96b8c6e..d7b92263c 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -97,7 +97,7 @@ export function getMessageByErrorCode(errorCode: ErrorCode): string { return "Your account does not have access to this feature."; case "rate_limit:chat": - return "You have exceeded your maximum number of messages for the day. Please try again later."; + return "今日消息次数已达上限,请稍后再试。"; case "not_found:chat": return "The requested chat was not found. Please check the chat ID and try again."; case "forbidden:chat": diff --git a/lib/request-error.ts b/lib/request-error.ts new file mode 100644 index 000000000..f343b7e25 --- /dev/null +++ b/lib/request-error.ts @@ -0,0 +1,190 @@ +const RATE_LIMIT_PATTERNS = [ + /\btoo many requests\b/i, + /\brate limit(?:ed|ing)?\b/i, + /\bquota exceeded\b/i, + /请求过于频繁/, + /请求太频繁/, + /访问过于频繁/, + /频率过高/, + /达到速率限制/, + /限流/, +]; +const JSON_EMBEDDED_MESSAGE_PATTERN = /^[["{]/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeTextCandidate(value: string) { + return value.trim().replace(/^["']|["']$/g, ""); +} + +function collectTextCandidates( + value: unknown, + results: string[], + seen = new WeakSet(), + depth = 0 +) { + if (depth > 4 || value == null) { + return; + } + + if (typeof value === "string") { + const normalized = normalizeTextCandidate(value); + if (normalized) { + results.push(normalized); + } + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectTextCandidates(item, results, seen, depth + 1); + } + return; + } + + if (!isRecord(value)) { + return; + } + + if (seen.has(value)) { + return; + } + seen.add(value); + + const preferredKeys = [ + "message", + "error", + "detail", + "cause", + "statusText", + "responseText", + "body", + "data", + ]; + + for (const key of preferredKeys) { + if (key in value) { + collectTextCandidates(value[key], results, seen, depth + 1); + } + } + + for (const [key, candidate] of Object.entries(value)) { + if (preferredKeys.includes(key)) { + continue; + } + collectTextCandidates(candidate, results, seen, depth + 1); + } +} + +function parseJsonEmbeddedMessage(value: string): string | null { + const normalized = normalizeTextCandidate(value); + if (!normalized || !JSON_EMBEDDED_MESSAGE_PATTERN.test(normalized)) { + return null; + } + + try { + const parsed = JSON.parse(normalized); + const messages: string[] = []; + collectTextCandidates(parsed, messages); + return messages.find(Boolean) || null; + } catch { + return null; + } +} + +export function getRequestErrorStatus(error: unknown): number | null { + const candidates: unknown[] = []; + + if (isRecord(error)) { + candidates.push( + error.status, + error.statusCode, + isRecord(error.response) ? error.response.status : null, + isRecord(error.cause) ? error.cause.status : null, + isRecord(error.data) ? error.data.status : null, + isRecord(error.data) ? error.data.statusCode : null + ); + } + + for (const candidate of candidates) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric >= 100) { + return Math.round(numeric); + } + } + + return null; +} + +export function getRequestErrorMessage(error: unknown): string | null { + const candidates: string[] = []; + + if (error instanceof Error) { + collectTextCandidates( + { + message: error.message, + cause: error.cause, + ...(isRecord(error) ? error : {}), + }, + candidates + ); + } else { + collectTextCandidates(error, candidates); + } + + for (const candidate of candidates) { + const embeddedMessage = parseJsonEmbeddedMessage(candidate); + const normalized = normalizeTextCandidate(embeddedMessage || candidate); + if (normalized && normalized !== "[object Object]") { + return normalized; + } + } + + return null; +} + +export function isRateLimitError(error: unknown): boolean { + if (getRequestErrorStatus(error) === 429) { + return true; + } + + const message = getRequestErrorMessage(error); + if (!message) { + return false; + } + + return RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function getFriendlyRateLimitMessage( + customMessage = "当前请求过于频繁,请稍后再试" +) { + return ( + normalizeTextCandidate(customMessage) || "当前请求过于频繁,请稍后再试" + ); +} + +export function getUserFacingRequestErrorMessage( + error: unknown, + fallbackMessage = "请求失败,请稍后重试", + options?: { + rateLimitMessage?: string; + } +) { + if (isRateLimitError(error)) { + return getFriendlyRateLimitMessage(options?.rateLimitMessage); + } + + const message = getRequestErrorMessage(error); + if (!message) { + return fallbackMessage; + } + + if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message))) { + return getFriendlyRateLimitMessage(options?.rateLimitMessage); + } + + return message; +} diff --git a/lib/utils.ts b/lib/utils.ts index 8b328c83a..4c5a604a3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -9,6 +9,7 @@ import { formatISO } from 'date-fns'; import { twMerge } from 'tailwind-merge'; import type { DBMessage, Document } from '@/lib/db/schema'; import { ChatSDKError, type ErrorCode } from './errors'; +import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from './request-error'; import type { ChatMessage, ChatTools, CustomUIDataTypes } from './types'; export function cn(...inputs: ClassValue[]) { @@ -39,12 +40,21 @@ export async function fetchWithErrorHandlers( if (!response.ok) { let payload: any = null; + let responseText = ""; try { - payload = await response.json(); + payload = await response.clone().json(); } catch { payload = null; } + if (payload == null) { + try { + responseText = (await response.text()).trim(); + } catch { + responseText = ""; + } + } + const code = typeof payload?.code === "string" && payload.code.includes(":") ? (payload.code as ErrorCode) @@ -60,16 +70,30 @@ export async function fetchWithErrorHandlers( } const fallbackMessage = - (typeof payload?.message === "string" && payload.message) || - (typeof payload?.error === "string" && payload.error) || - `Request failed with status ${response.status}`; + getUserFacingRequestErrorMessage( + { + status: response.status, + data: payload, + message: responseText, + }, + `Request failed with status ${response.status}`, + { + rateLimitMessage: getFriendlyRateLimitMessage(), + } + ); const requestError = new Error(fallbackMessage) as Error & { status?: number; data?: any; }; requestError.name = "RequestError"; requestError.status = response.status; - requestError.data = payload; + requestError.data = + payload ?? + (responseText + ? { + message: responseText, + } + : null); throw requestError; }