328 lines
13 KiB
TypeScript
328 lines
13 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));
|
||
}
|
||
|
||
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 type RequestFailureDetails = {
|
||
code: string;
|
||
message: string;
|
||
recommendation: string;
|
||
outcome: "failed" | "unknown";
|
||
};
|
||
|
||
export function getRequestFailureDetails(
|
||
error: unknown,
|
||
fallbackMessage = "请求未能完成",
|
||
): RequestFailureDetails {
|
||
const text = getRequestErrorText(error);
|
||
const status = getRequestErrorStatus(error);
|
||
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
|
||
({ code, message, recommendation, outcome });
|
||
if (isGatewayResponseTimeout(error)) {
|
||
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
|
||
}
|
||
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||
}
|
||
if (isProviderBalanceError(error)) {
|
||
return detail("PROVIDER_BALANCE_EXHAUSTED", "图片服务商线路余额或额度不足,与您的积分余额无关。", "请切换可用模型或联系管理员处理线路额度。");
|
||
}
|
||
if (isRateLimitError(error)) {
|
||
return detail("RATE_LIMITED", "当前请求过于频繁。", "请稍后重试,避免连续点击;模型线路限流时可切换其他可用模型。");
|
||
}
|
||
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
|
||
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
|
||
}
|
||
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
||
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
||
}
|
||
if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) {
|
||
return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。");
|
||
}
|
||
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
|
||
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
|
||
}
|
||
if (/版本冲突|画布.*(?:更新|过期)|CANVAS_REVISION_CONFLICT|stale.canvas/i.test(text)) {
|
||
return detail("CANVAS_REVISION_CONFLICT", "画布已更新,本次保存与当前版本冲突。", "请先重新同步画布,再继续操作,避免覆盖较新的修改。");
|
||
}
|
||
if (/no_eligible_route|service_unavailable|service_rejected|channel.*unavailable|暂无可用服务|未配置.*模型|没有可用.*(?:模型|通道)|通道.*(?:不可用|未通过)|鉴权/i.test(text)) {
|
||
return detail("PROVIDER_UNAVAILABLE", "当前模型服务或通道暂不可用。", "请切换其他可用模型;若多个模型都失败,请联系管理员检查服务配置。");
|
||
}
|
||
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
|
||
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
|
||
}
|
||
if (/(?:抠图|去水印|图片增强|图片|扩图)处理失败/.test(text)) {
|
||
return detail("PROCESSING_FAILED", "图片处理服务执行失败,未返回具体原因。", "请联系管理员并提供请求编号,先核对原任务结果,避免重复处理。", "unknown");
|
||
}
|
||
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
|
||
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||
}
|
||
if (/timeout|timed out|超时|fetch failed|ECONNRESET|ECONNREFUSED|failed to fetch|networkerror|load failed|network request failed/i.test(text) || [502, 504].includes(status || 0)) {
|
||
return detail("RESULT_UNCONFIRMED", "请求连接中断或等待超时,任务结果尚未确认。", "请先查看原任务或历史记录,避免重复提交;持续没有结果时联系管理员。", "unknown");
|
||
}
|
||
if (/generate(?: image)? failed|generation failed|upstream_server_error|service_error|excessive system load|未返回可用图片|任务FAILED/i.test(text) || (status !== null && status >= 500)) {
|
||
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||
}
|
||
const message = getRequestErrorMessage(error);
|
||
return detail("REQUEST_FAILED", message && !/^\s*</.test(message) ? message : fallbackMessage, "");
|
||
}
|
||
|
||
export function formatRequestFailure(details: RequestFailureDetails, requestId?: string) {
|
||
const text = [details.message, details.recommendation].filter(Boolean).join("");
|
||
return requestId ? `${text}\n\n请求编号:${requestId}` : text;
|
||
}
|
||
|
||
export function getUserFacingImageGenerationErrorMessage(
|
||
error: unknown,
|
||
fallbackMessage = "图片生成失败,请稍后重试",
|
||
) {
|
||
return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
|
||
}
|
||
|
||
export function getFriendlyRateLimitMessage(
|
||
customMessage = "当前请求过于频繁,请稍后再试"
|
||
) {
|
||
return (
|
||
normalizeTextCandidate(customMessage) || "当前请求过于频繁,请稍后再试"
|
||
);
|
||
}
|
||
|
||
export function getUserFacingRequestErrorMessage(
|
||
error: unknown,
|
||
fallbackMessage = "请求失败,请稍后重试",
|
||
options?: {
|
||
rateLimitMessage?: string;
|
||
}
|
||
) {
|
||
if (isRateLimitError(error)) {
|
||
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
|
||
}
|
||
|
||
const failure = getRequestFailureDetails(error, fallbackMessage);
|
||
if (failure.code !== "REQUEST_FAILED") return formatRequestFailure(failure);
|
||
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;
|
||
}
|