fix: stop Claw artifact request loops and restore CAD actions

This commit is contained in:
134 2026-09-14 09:09:31 +08:00
parent 0aff83f61a
commit b45d9ee314
8 changed files with 140 additions and 15 deletions

View File

@ -16870,8 +16870,6 @@ export function CanvasChatPanel({
<OpenClawQuickActions
blocks={openClawActionBlocks}
disabled={
shouldAnimateAnalysis ||
shouldAnimatePlainText ||
isClawdSubmitting ||
isActiveChatPending
}

View File

@ -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,

View File

@ -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>

View 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 警告。

View File

@ -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>)

View File

@ -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),

View File

@ -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) {

View File

@ -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",