风格化粒子动画依赖外部 CDN 的 Three.js,全站同域脚本策略会阻止其加载,导致 THREE 未定义。现在使用同域 ES module,保留原 Three.js 0.160.0、动画参数及 MIT 许可。 扶苏在 2026-11-04 09:44 收到的 gpt-image-2.5 HTTP 524 响应是 Cloudflare HTML 页面,错误正文被直接保存和显示。现在将这类响应显示为结果未确认的超时提示;已有历史消息在渲染时同样归一化,不改动原始记录。524 不触发自动模型切换,避免原任务结果未确认时再次生成。普通 HTML 文档和代码示例保持原样。 验证:原生产 CSP 下的本地浏览器验证粒子有可见像素、连续帧变化、窗口缩放及无外部请求、无脚本错误;637 项辅助测试通过、1 项原有跳过,包含供应商 524 只提交一次、结果未确认、不自动切换及正常 503 行为。TypeScript、改动文件 lint、凭据、日志脱敏、环境、元数据、测试与制品检查通过。 已知问题:全量 lint 仍有 1004 项历史诊断。524 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
278 lines
7.8 KiB
TypeScript
278 lines
7.8 KiB
TypeScript
const RATE_LIMIT_PATTERNS = [
|
||
/\btoo many requests\b/i,
|
||
/\brate limit(?:ed|ing)?\b/i,
|
||
/\bquota exceeded\b/i,
|
||
/请求过于频繁/,
|
||
/请求太频繁/,
|
||
/访问过于频繁/,
|
||
/频率过高/,
|
||
/达到速率限制/,
|
||
/限流/,
|
||
];
|
||
const PROVIDER_BALANCE_PATTERNS = [
|
||
/余额(?:不足|不够|已?耗尽)/i,
|
||
/额度(?:不足|不够|已?耗尽)/i,
|
||
/预扣费额度(?:不足|失败)/i,
|
||
/\binsufficient[ _-]?(?:balance|credits?|funds?|quota)\b/i,
|
||
/\b(?:balance|credits?|funds?)\s+(?:is\s+)?(?:not enough|insufficient|exhausted)\b/i,
|
||
/\b(?:exceeded_current_quota|pre_consume_token_quota_failed)\b/i,
|
||
/\btoken quota is not enough\b/i,
|
||
/\bquota is not enough\b/i,
|
||
/\b(?:not enough credits?|credits? is not enough)\b/i,
|
||
/\b(?:billing_hard_limit_reached|payment_required)\b/i,
|
||
];
|
||
const NETWORK_TRANSPORT_PATTERNS = [
|
||
/\bfailed to fetch\b/i,
|
||
/\bnetworkerror\b/i,
|
||
/\bload failed\b/i,
|
||
/network request failed/i,
|
||
/having trouble sending your message/i,
|
||
/check your internet connection/i,
|
||
];
|
||
const JSON_EMBEDDED_MESSAGE_PATTERN = /^[["{]/;
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null;
|
||
}
|
||
|
||
function normalizeTextCandidate(value: string) {
|
||
return value.trim().replace(/^["']|["']$/g, "");
|
||
}
|
||
|
||
function collectTextCandidates(
|
||
value: unknown,
|
||
results: string[],
|
||
seen = new WeakSet<object>(),
|
||
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;
|
||
}
|
||
|
||
function getRequestErrorText(error: unknown) {
|
||
const candidates: string[] = [];
|
||
if (error instanceof Error) {
|
||
collectTextCandidates({ ...error, message: error.message, cause: error.cause }, candidates);
|
||
} else {
|
||
collectTextCandidates(error, candidates);
|
||
}
|
||
return candidates.join(" ").slice(0, 4000);
|
||
}
|
||
|
||
export function getGatewayErrorPageMessage(content: string): string | null {
|
||
if (!/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(content)) return null;
|
||
if (!/\bcf-error-details\b/i.test(content)) return null;
|
||
if (/\b524\b/.test(content)) {
|
||
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
|
||
}
|
||
return "模型服务暂时不可用,请稍后再试或联系管理员。";
|
||
}
|
||
|
||
export function isGatewayResponseTimeout(error: unknown): boolean {
|
||
return getRequestErrorStatus(error) === 524 ||
|
||
/\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error));
|
||
}
|
||
|
||
function getPublicGatewayErrorMessage(error: unknown): string | null {
|
||
if (isGatewayResponseTimeout(error)) {
|
||
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
|
||
}
|
||
const message = getRequestErrorMessage(error);
|
||
return message ? getGatewayErrorPageMessage(message) : null;
|
||
}
|
||
|
||
export function isProviderBalanceError(error: unknown): boolean {
|
||
const message = getRequestErrorText(error);
|
||
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
|
||
}
|
||
|
||
export function isRateLimitError(error: unknown): boolean {
|
||
// Some providers return HTTP 429 for an actual unpaid balance. The status
|
||
// alone must not override an explicit billing error in the response body.
|
||
if (isProviderBalanceError(error)) {
|
||
return false;
|
||
}
|
||
if (getRequestErrorStatus(error) === 429) {
|
||
return true;
|
||
}
|
||
|
||
const message = getRequestErrorText(error);
|
||
if (!message) {
|
||
return false;
|
||
}
|
||
|
||
return /(?:^|\D)429(?:\D|$)/.test(message) ||
|
||
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
|
||
}
|
||
|
||
export function getUserFacingImageGenerationErrorMessage(
|
||
error: unknown,
|
||
fallbackMessage = "图片生成失败,请稍后重试",
|
||
) {
|
||
const gatewayMessage = getPublicGatewayErrorMessage(error);
|
||
if (gatewayMessage) return gatewayMessage;
|
||
if (isProviderBalanceError(error)) {
|
||
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
||
}
|
||
if (isRateLimitError(error)) {
|
||
return "图片服务请求过于频繁,请稍后重试或切换模型。";
|
||
}
|
||
return getRequestErrorMessage(error) || fallbackMessage;
|
||
}
|
||
|
||
export function getFriendlyRateLimitMessage(
|
||
customMessage = "当前请求过于频繁,请稍后再试"
|
||
) {
|
||
return (
|
||
normalizeTextCandidate(customMessage) || "当前请求过于频繁,请稍后再试"
|
||
);
|
||
}
|
||
|
||
export function getUserFacingRequestErrorMessage(
|
||
error: unknown,
|
||
fallbackMessage = "请求失败,请稍后重试",
|
||
options?: {
|
||
rateLimitMessage?: string;
|
||
}
|
||
) {
|
||
const gatewayMessage = getPublicGatewayErrorMessage(error);
|
||
if (gatewayMessage) return gatewayMessage;
|
||
if (isProviderBalanceError(error)) {
|
||
return "模型服务商线路余额或额度不足,请切换模型或联系管理员。";
|
||
}
|
||
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);
|
||
}
|
||
|
||
if (NETWORK_TRANSPORT_PATTERNS.some((pattern) => pattern.test(message))) {
|
||
return "请求发送失败,请检查网络或稍后重试。";
|
||
}
|
||
|
||
return message;
|
||
}
|