fix: stop Claw artifact request loops and restore CAD actions
This commit is contained in:
parent
0aff83f61a
commit
b45d9ee314
@ -16870,8 +16870,6 @@ export function CanvasChatPanel({
|
||||
<OpenClawQuickActions
|
||||
blocks={openClawActionBlocks}
|
||||
disabled={
|
||||
shouldAnimateAnalysis ||
|
||||
shouldAnimatePlainText ||
|
||||
isClawdSubmitting ||
|
||||
isActiveChatPending
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
} from "@/lib/openclaw-chat/model-interaction";
|
||||
import {
|
||||
getOpenClawExplodedOffsetMm,
|
||||
getOpenClawPartForSceneNode,
|
||||
getOpenClawSourceNodeOffset,
|
||||
mapOpenClawCadOffsetToGlb,
|
||||
} from "@/lib/openclaw-chat/model-interaction";
|
||||
@ -188,7 +189,7 @@ function ModelInteractionScene({
|
||||
interactionParts.map((part) => [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<string, InteractiveEntry>();
|
||||
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
||||
scene.traverse((node) => {
|
||||
const part = partsByNodeName.get(node.name);
|
||||
const part = getOpenClawPartForSceneNode(node, partsByNodeName);
|
||||
if (!part || entries.has(part.nodeName)) return;
|
||||
const entry = {
|
||||
node,
|
||||
|
||||
@ -140,6 +140,9 @@ function OpenClawProductionDrawingPanel({
|
||||
const [manifest, setManifest] =
|
||||
useState<OpenClawProductionDrawingManifest | null>();
|
||||
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<OpenClawModelInteractionManifest | null>();
|
||||
const [manifestError, setManifestError] = useState<string | null>(null);
|
||||
const interactionManifestIndex = bundle?.manifestIndex ?? -1;
|
||||
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
|
||||
const [previewRetry, setPreviewRetry] = useState(0);
|
||||
const [selectedPart, setSelectedPart] =
|
||||
useState<OpenClawModelInteractionPart | null>(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({
|
||||
)
|
||||
) : (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
|
||||
{previewUrl ? (
|
||||
{previewUrl && failedPreviewUrl !== previewUrl ? (
|
||||
<img
|
||||
alt={`${model.displayName || "3D 模型"}静态预览`}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
src={previewUrl}
|
||||
src={previewRetry ? `${previewUrl}&previewRetry=${previewRetry}` : previewUrl}
|
||||
onError={() => setFailedPreviewUrl(previewUrl)}
|
||||
/>
|
||||
) : (
|
||||
<Box className="h-16 w-16 text-[#c7cdd6] dark:text-zinc-700" />
|
||||
@ -1111,6 +1117,21 @@ export function OpenClawModel3DPreview({
|
||||
<Box className="h-4 w-4" />
|
||||
查看 3D
|
||||
</button>
|
||||
{previewUrl && failedPreviewUrl === previewUrl ? (
|
||||
<div className="absolute inset-x-0 bottom-3 text-center text-xs text-[#687386]">
|
||||
<p>静态预览加载失败,可以直接查看 3D 或重试预览。</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setPreviewRetry((value) => value + 1);
|
||||
setFailedPreviewUrl(null);
|
||||
}}
|
||||
>
|
||||
重试预览
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
24
docs/incidents/claw-3d-actions-2026-09-14.md
Normal file
24
docs/incidents/claw-3d-actions-2026-09-14.md
Normal file
@ -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 警告。
|
||||
@ -89,6 +89,21 @@ const MAX_ACTIONS = 32;
|
||||
const MAX_TEXT_LENGTH = 500;
|
||||
const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000;
|
||||
|
||||
export function getOpenClawPartForSceneNode<T>(
|
||||
node: { name: string; userData?: Record<string, unknown> },
|
||||
partsByNodeName: ReadonlyMap<string, T>
|
||||
): 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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
28
proxy.ts
28
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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user