修复粒子动画加载与生图 524 错误展示

风格化粒子动画依赖外部 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 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
This commit is contained in:
WuYongJie 2026-11-04 10:26:01 +08:00
parent 70f954382a
commit 61e2bbe0e4
9 changed files with 116 additions and 9 deletions

View File

@ -18,6 +18,7 @@ import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-do
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import {
Collapsible,
CollapsibleContent,
@ -1028,7 +1029,10 @@ export const Response = ({
const rawText =
typeof props.children === "string"
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
? normalizeMarkdownContent(stableStreamingMarkdown(
getGatewayErrorPageMessage(props.children) || props.children,
isMarkdownStreaming
))
: null;
const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));

View File

@ -7,6 +7,7 @@ import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
@ -521,7 +522,7 @@ const PurePreviewMessage = ({
const displayModelName = extractedModelName || inferredFallbackModelName;
const filteredText =
message.role === "assistant"
? appendImageDoneSummary(contentWithoutBadge)
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
: contentWithoutBadge;
const useAssistantTypewriter =
message.role === "assistant" &&

View File

@ -48,6 +48,7 @@ import {
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
} from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import {
IMAGE_UPLOAD_MAX_BYTES,
@ -1188,9 +1189,9 @@ async function submitTask(params: {
continue;
}
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|| [502, 504].includes(response.status)
|| [502, 504, 524].includes(response.status)
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`);
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
throw new Error(`${errorMessage} (HTTP ${response.status})`);
}

View File

@ -12,7 +12,7 @@ import {
isMediaInputFailure,
isNonProviderRequestStatus,
} from "@/lib/ai/runtime/request-failure-classification";
import { isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
export type AiModelHealthEventType =
| "success"
@ -343,6 +343,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
}
export function shouldFailoverAiModelError(error: unknown) {
if (isGatewayResponseTimeout(error)) return false;
const classification = classifyAiModelHealthFailure(error);
const normalized = getErrorMessage(error).toLowerCase();
const imageProviderTaskFailed =

View File

@ -175,6 +175,28 @@ function getRequestErrorText(error: unknown) {
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));
@ -203,6 +225,8 @@ export function getUserFacingImageGenerationErrorMessage(
error: unknown,
fallbackMessage = "图片生成失败,请稍后重试",
) {
const gatewayMessage = getPublicGatewayErrorMessage(error);
if (gatewayMessage) return gatewayMessage;
if (isProviderBalanceError(error)) {
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
@ -227,6 +251,8 @@ export function getUserFacingRequestErrorMessage(
rateLimitMessage?: string;
}
) {
const gatewayMessage = getPublicGatewayErrorMessage(error);
if (gatewayMessage) return gatewayMessage;
if (isProviderBalanceError(error)) {
return "模型服务商线路余额或额度不足,请切换模型或联系管理员。";
}

View File

@ -4,7 +4,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neural Particle - Scatter Effect</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background-color: #000; }
@ -14,7 +13,8 @@
</head>
<body>
<div id="particle-container"></div>
<script>
<script type="module">
import * as THREE from "/three/0.160.0/three.module.min.js";
const CONFIG = {
effect: "scatter",
effectMode: 1,

View File

@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test";
import {
getUserFacingImageGenerationErrorMessage,
getUserFacingRequestErrorMessage,
getGatewayErrorPageMessage,
isGatewayResponseTimeout,
isProviderBalanceError,
isRateLimitError,
} from "@/lib/request-error";
@ -16,6 +18,49 @@ test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
expect(getUserFacingRequestErrorMessage(error)).toContain("请求过于频繁");
});
const gatewayHtml = '<!DOCTYPE html><html><head><title>vip98k.com | 524: A timeout occurred</title></head><body><div id="cf-error-details">Error code 524</div><script>window.gatewayDebug = true</script></body></html> (HTTP 524)';
test("gateway timeout pages become a concise unconfirmed-result message", () => {
for (const error of [new Error(gatewayHtml), { status: 524, message: "Gateway failed" }, { error: { message: "Request failed (HTTP 524)" } }]) {
expect(isGatewayResponseTimeout(error)).toBe(true);
for (const text of [getUserFacingImageGenerationErrorMessage(error), getUserFacingRequestErrorMessage(error)]) {
expect(text).toContain("HTTP 524");
expect(text).toContain("结果尚未确认");
expect(text).not.toMatch(/<html|<script|cf-error-details/);
}
}
expect(getGatewayErrorPageMessage(gatewayHtml)).toContain("避免重复提交");
});
test("ordinary HTML documents and fenced examples retain their content", () => {
for (const text of ['<!DOCTYPE html><html><title>My page</title><p>Hosted on Cloudflare</p></html>', `\`\`\`html\n${gatewayHtml}\n\`\`\``, "Cloudflare 文档示例", "普通图片错误"]) {
expect(getGatewayErrorPageMessage(text)).toBeNull();
}
});
test("HTTP 524 does not start another model while the original result is unconfirmed", () => {
const { health } = healthHarness();
expect(health.shouldFailoverAiModelError(new Error(gatewayHtml))).toBe(false);
expect(health.shouldFailoverAiModelError({ status: 524, message: "Gateway timeout" })).toBe(false);
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
});
test("the provider submits once and keeps an HTTP 524 response unconfirmed", async () => {
let submissions = 0;
const provider = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@/lib/storage/service": {},
fetch: async () => {
submissions++;
return new Response(gatewayHtml, { status: 524, headers: { "content-type": "text/html" } });
},
})("lib/ai/providers/vip98k.ts");
const model = provider.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5");
await expect(model.doGenerate({ messages: [{ role: "user", content: "A desk" }] })).rejects.toThrow(/结果未确认.*HTTP 524/);
expect(submissions).toBe(1);
});
test("an explicit provider balance error takes precedence over HTTP 429", () => {
const error = Object.assign(new Error("余额不足,请点击充值页面续费(HTTP 429)"), { status: 429 });
expect(isProviderBalanceError(error)).toBe(true);
@ -62,8 +107,10 @@ function healthHarness() {
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
const events: any[] = [];
const query = (rows: () => any[]) => ({ from() { return this; }, where() { return this; },
limit: async () => rows(), then: (resolve: any) => Promise.resolve(rows()).then(resolve), returning: async () => rows() });
const query = (rows: () => any[]) => Object.assign(Promise.resolve().then(rows), {
from() { return this; }, where() { return this; },
limit: async () => rows(), returning: async () => rows(),
});
const db = {
select: () => query(() => [model]),
insert: () => ({ values: async (value: any) => { events.push(value); } }),