From f3cded51fd0042ecf9ec8eae90c954085043e7b4 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 14 Sep 2026 09:09:31 +0800 Subject: [PATCH] fix: stop Claw artifact request loops and restore CAD actions --- components/canvas/CanvasChatPanel.tsx | 2 - .../OpenClawInteractiveModelViewer.tsx | 5 ++- .../chat-panel/OpenClawModel3DPreview.tsx | 43 ++++++++++++++----- docs/incidents/claw-3d-actions-2026-09-14.md | 24 +++++++++++ lib/openclaw-chat/model-interaction.ts | 15 +++++++ lib/security/rate-limiter.ts | 4 ++ proxy.ts | 28 ++++++++++++ tests/routes/openclaw-3d-artifacts.test.ts | 34 +++++++++++++++ 8 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 docs/incidents/claw-3d-actions-2026-09-14.md diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index e53bb72b7..d1da8a602 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -16870,8 +16870,6 @@ export function CanvasChatPanel({ [part.nodeName, part]) ); cloned.traverse((node) => { - const part = partsByNodeName.get(node.name); + const part = getOpenClawPartForSceneNode(node, partsByNodeName); if (!part) return; const offset = getOpenClawSourceNodeOffset( part, @@ -218,7 +219,7 @@ function ModelInteractionScene({ const entries = new Map(); const objectEntries = new Map(); scene.traverse((node) => { - const part = partsByNodeName.get(node.name); + const part = getOpenClawPartForSceneNode(node, partsByNodeName); if (!part || entries.has(part.nodeName)) return; const entry = { node, diff --git a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx index 9c2bf0a59..d1b37b35f 100644 --- a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx +++ b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx @@ -140,6 +140,9 @@ function OpenClawProductionDrawingPanel({ const [manifest, setManifest] = useState(); const [manifestError, setManifestError] = useState(false); + // The parent normalizes artifacts on each render. Object identity must not + // restart an in-flight request (or refetch an already loaded manifest). + const manifestIndex = manifestArtifact?.index ?? -1; const proxyUrl = useCallback( (index: number, download = false) => @@ -153,14 +156,13 @@ function OpenClawProductionDrawingPanel({ ); useEffect(() => { - if (!manifestArtifact) { + if (manifestIndex < 0) { setManifest(null); return; } const controller = new AbortController(); setManifestError(false); - fetch(proxyUrl(manifestArtifact.index), { - cache: "no-store", + fetch(proxyUrl(manifestIndex), { signal: controller.signal, }) .then(async (response) => { @@ -177,7 +179,7 @@ function OpenClawProductionDrawingPanel({ setManifestError(true); }); return () => controller.abort(); - }, [manifestArtifact, proxyUrl]); + }, [manifestIndex, proxyUrl]); if (!manifestArtifact && pdfs.length === 0 && dxfBundles.length === 0) { return null; @@ -479,6 +481,9 @@ export function OpenClawModel3DPreview({ const [manifest, setManifest] = useState(); const [manifestError, setManifestError] = useState(null); + const interactionManifestIndex = bundle?.manifestIndex ?? -1; + const [failedPreviewUrl, setFailedPreviewUrl] = useState(null); + const [previewRetry, setPreviewRetry] = useState(0); const [selectedPart, setSelectedPart] = useState(null); const [selectedPackagingPart, setSelectedPackagingPart] = @@ -642,8 +647,8 @@ export function OpenClawModel3DPreview({ }, [closeViewer, isActive]); useEffect(() => { - if (!isActive || !bundle || manifest !== undefined) return; - if (bundle.manifestIndex < 0) { + if (!isActive || manifest !== undefined) return; + if (interactionManifestIndex < 0) { setManifest(null); return; } @@ -651,10 +656,10 @@ export function OpenClawModel3DPreview({ const manifestUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, - artifactIndex: bundle.manifestIndex, + artifactIndex: interactionManifestIndex, }); setManifestError(null); - fetch(manifestUrl, { cache: "no-store", signal: controller.signal }) + fetch(manifestUrl, { signal: controller.signal }) .then(async (response) => { if (!response.ok) { throw new Error(`交互清单读取失败(HTTP ${response.status})`); @@ -673,7 +678,7 @@ export function OpenClawModel3DPreview({ ); }); return () => controller.abort(); - }, [bundle, chatId, isActive, manifest, messageId]); + }, [interactionManifestIndex, chatId, isActive, manifest, messageId]); useEffect(() => { if (viewMode === "product" && !productModel && packagingModel) { @@ -1093,12 +1098,13 @@ export function OpenClawModel3DPreview({ ) ) : (
- {previewUrl ? ( + {previewUrl && failedPreviewUrl !== previewUrl ? ( {`${model.displayName setFailedPreviewUrl(previewUrl)} /> ) : ( @@ -1111,6 +1117,21 @@ export function OpenClawModel3DPreview({ 查看 3D + {previewUrl && failedPreviewUrl === previewUrl ? ( +
+

静态预览加载失败,可以直接查看 3D 或重试预览。

+ +
+ ) : null}
)} diff --git a/docs/incidents/claw-3d-actions-2026-09-14.md b/docs/incidents/claw-3d-actions-2026-09-14.md new file mode 100644 index 000000000..ca11a749b --- /dev/null +++ b/docs/incidents/claw-3d-actions-2026-09-14.md @@ -0,0 +1,24 @@ +# Claw 3D 预览与快捷回复故障(2026-09-14) + +## 现场证据 + +花城“产品成本评估”对话,08:00:11(北京时间)发起的报价任务已完成,回复含 31 个附件;BOM 成本 182.25 元、出厂报价 210.61 元,与反馈截图一致。整装 GLB、静态预览和交互清单均可读取,未重新生成或修改用户对话。 + +同一回复的 production-drawings.json 被重复请求。09:00 前的 Nginx 日志样本中,该文件有 368 次 499、1,365 次 429;08:15:40 assembled-preview.png 返回 429,08:16:20 对话 POST 返回 429。附件读取和对话提交原先共享通用 API 每分钟 100 次/IP 的预算。日志证实快捷按钮有提交请求到达服务端,不能把这次失败归因为用户没点中或切换浏览器。 + +## 修复 + +- 生产图纸和交互清单 effect 依赖稳定的附件索引、对话 ID 和消息 ID,避免父组件重新规范化附件对象时取消、重发请求;恢复使用私有 HTTP 缓存。 +- Claw 附件 GET 使用独立的每分钟 600 次/用户预算,匿名访问按 IP,仍保留鉴权、所有权校验、SSRF 防护及 429/Retry-After。 +- 静态预览失败时显示明确提示和手动重试,保留“查看 3D”入口。 +- 已完成回复的快捷按钮不再被展示用的打字动画禁用;实际任务运行期间仍禁用,并保留发送去重保护。 +- GLTFLoader 会规范化 `SOURCE-001:1` 等节点名,交互清单保留原名。按加载器保存的 `userData.name` 匹配,恢复配件选中、拆解、组合及包装操作。 + +## 验证 + +- Chrome 中渲染实际预览组件,重建附件对象并每 20ms 更新父组件:修复前清单请求 121 次,修复后 1 次。 +- 人为让首个静态预览请求返回 429,手动重试后图片正常显示。 +- 从实际组件取快捷按钮及其禁用表达式:打字动画仍为 true 时点击可提交正确的确认文本;进入任务运行状态后禁止重复点击。发送回调在测试环境内记录,未向真实对话发送消息。 +- 使用现场实际 GLB 和交互清单:整装模型 23/23 配件匹配,拆解前后 Canvas 截图不同;包装模型可加载,包装层全部匹配。真实文件仅保存在本地 QA 目录,未提交仓库。 +- GLTFLoader 原始节点名回归测试覆盖两个实例、普通名字和不存在的节点;限流测试覆盖 600 次上限、对话预算隔离和另一用户隔离。 +- 27 项相关测试通过;TypeScript 检查、Next.js 生产构建及 git diff --check 通过。构建有既存 HEIC 动态 require 警告。 diff --git a/lib/openclaw-chat/model-interaction.ts b/lib/openclaw-chat/model-interaction.ts index 1b7c6d8dd..3f4a59179 100644 --- a/lib/openclaw-chat/model-interaction.ts +++ b/lib/openclaw-chat/model-interaction.ts @@ -89,6 +89,21 @@ const MAX_ACTIONS = 32; const MAX_TEXT_LENGTH = 500; const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000; +export function getOpenClawPartForSceneNode( + node: { name: string; userData?: Record }, + partsByNodeName: ReadonlyMap +): T | undefined { + // GLTFLoader sanitizes names such as SOURCE-001:1 to SOURCE-0011, but + // preserves the original GLB node name in userData.name. The manifest uses + // that original name, including the instance suffix. + const originalName = node.userData?.name; + return ( + (typeof originalName === "string" + ? partsByNodeName.get(originalName) + : undefined) ?? partsByNodeName.get(node.name) + ); +} + function readRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/lib/security/rate-limiter.ts b/lib/security/rate-limiter.ts index dd08c8066..2eee24dad 100644 --- a/lib/security/rate-limiter.ts +++ b/lib/security/rate-limiter.ts @@ -89,6 +89,10 @@ export const rateLimiters = { // 图片代理接口:每分钟3000次请求(图片列表/画布高频加载) imageProxy: new RateLimiter(60000, 3000), + // CAD replies include many previews, drawings and range requests. Keep their + // read budget separate from conversation submissions, scoped per user. + clawdArtifact: new RateLimiter(60000, 600), + // 高频只读接口(首页/画布轮询):每分钟1200次请求 uiPolling: new RateLimiter(60000, 1200), diff --git a/proxy.ts b/proxy.ts index 777dcba96..2c23ea941 100644 --- a/proxy.ts +++ b/proxy.ts @@ -461,6 +461,34 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) { } ); } + } else if ( + pathname === "/api/canvas/clawd-chat/artifact" && + request.method === "GET" + ) { + const token = await resolveToken(); + const userId = typeof token?.sub === "string" && token.sub ? token.sub : null; + const artifactRateLimit = checkRateLimitByIdentifier( + userId ? `user:${userId}:clawd-artifact` : `ip:${clientIP}:clawd-artifact`, + rateLimiters.clawdArtifact + ); + if (!artifactRateLimit.allowed) { + const retryAfter = Math.max( + 1, + Math.ceil((artifactRateLimit.resetTime - Date.now()) / 1000) + ); + return withAccessLog( + NextResponse.json( + { error: `附件读取过于频繁,请 ${retryAfter} 秒后重试`, retryAfter }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ), + { + outcome: "rate_limited", + severity: "watch", + reasons: ["Claw附件读取限流"], + userId, + } + ); + } } else if (pathname === "/api/image-proxy" && request.method === "GET") { const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy); if (!imageProxyRateLimit.allowed) { diff --git a/tests/routes/openclaw-3d-artifacts.test.ts b/tests/routes/openclaw-3d-artifacts.test.ts index 08e1de730..f1b04035b 100644 --- a/tests/routes/openclaw-3d-artifacts.test.ts +++ b/tests/routes/openclaw-3d-artifacts.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "@playwright/test"; +import { GLTFLoader } from "three-stdlib"; +import { checkRateLimitByIdentifier, rateLimiters } from "@/lib/security/rate-limiter"; import { buildOpenClawArtifactProxyUrl, extractOpenClawArtifacts, @@ -9,6 +11,7 @@ import { } from "@/lib/openclaw-chat/artifacts"; import { getOpenClawExplodedOffsetMm, + getOpenClawPartForSceneNode, getOpenClawPackagingInteractionParts, getOpenClawSourceNodeOffset, mapOpenClawCadOffsetToGlb, @@ -34,6 +37,37 @@ function createMemoryStorage() { }; } +test("matches original CAD instance names after GLTFLoader sanitizes them", async () => { + const gltf = await new GLTFLoader().parseAsync( + JSON.stringify({ + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1, 2] }], + nodes: [{ name: "SOURCE-001:1" }, { name: "SOURCE-001:2" }, { name: "plain" }], + }), + "" + ); + const parts = new Map([ + ["SOURCE-001:1", "first instance"], + ["SOURCE-001:2", "second instance"], + ["plain", "plain node"], + ]); + expect(gltf.scene.children[0].name).not.toBe("SOURCE-001:1"); + expect(gltf.scene.children.map((node) => getOpenClawPartForSceneNode(node, parts))) + .toEqual(["first instance", "second instance", "plain node"]); + expect(getOpenClawPartForSceneNode({ name: "missing" }, parts)).toBeUndefined(); +}); + +test("CAD artifact bursts cannot exhaust the conversation API budget", () => { + const identifier = `claw-artifact-test-${Date.now()}`; + for (let request = 0; request < 600; request += 1) { + expect(checkRateLimitByIdentifier(identifier, rateLimiters.clawdArtifact).allowed).toBe(true); + } + expect(checkRateLimitByIdentifier(identifier, rateLimiters.clawdArtifact).allowed).toBe(false); + expect(checkRateLimitByIdentifier(identifier, rateLimiters.api).allowed).toBe(true); + expect(checkRateLimitByIdentifier(`${identifier}-other-user`, rateLimiters.clawdArtifact).allowed).toBe(true); +}); + const modelFile = { type: "file", filename: "wood-demo-interactive.glb",