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
|
<OpenClawQuickActions
|
||||||
blocks={openClawActionBlocks}
|
blocks={openClawActionBlocks}
|
||||||
disabled={
|
disabled={
|
||||||
shouldAnimateAnalysis ||
|
|
||||||
shouldAnimatePlainText ||
|
|
||||||
isClawdSubmitting ||
|
isClawdSubmitting ||
|
||||||
isActiveChatPending
|
isActiveChatPending
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
} from "@/lib/openclaw-chat/model-interaction";
|
} from "@/lib/openclaw-chat/model-interaction";
|
||||||
import {
|
import {
|
||||||
getOpenClawExplodedOffsetMm,
|
getOpenClawExplodedOffsetMm,
|
||||||
|
getOpenClawPartForSceneNode,
|
||||||
getOpenClawSourceNodeOffset,
|
getOpenClawSourceNodeOffset,
|
||||||
mapOpenClawCadOffsetToGlb,
|
mapOpenClawCadOffsetToGlb,
|
||||||
} from "@/lib/openclaw-chat/model-interaction";
|
} from "@/lib/openclaw-chat/model-interaction";
|
||||||
@ -188,7 +189,7 @@ function ModelInteractionScene({
|
|||||||
interactionParts.map((part) => [part.nodeName, part])
|
interactionParts.map((part) => [part.nodeName, part])
|
||||||
);
|
);
|
||||||
cloned.traverse((node) => {
|
cloned.traverse((node) => {
|
||||||
const part = partsByNodeName.get(node.name);
|
const part = getOpenClawPartForSceneNode(node, partsByNodeName);
|
||||||
if (!part) return;
|
if (!part) return;
|
||||||
const offset = getOpenClawSourceNodeOffset(
|
const offset = getOpenClawSourceNodeOffset(
|
||||||
part,
|
part,
|
||||||
@ -218,7 +219,7 @@ function ModelInteractionScene({
|
|||||||
const entries = new Map<string, InteractiveEntry>();
|
const entries = new Map<string, InteractiveEntry>();
|
||||||
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
||||||
scene.traverse((node) => {
|
scene.traverse((node) => {
|
||||||
const part = partsByNodeName.get(node.name);
|
const part = getOpenClawPartForSceneNode(node, partsByNodeName);
|
||||||
if (!part || entries.has(part.nodeName)) return;
|
if (!part || entries.has(part.nodeName)) return;
|
||||||
const entry = {
|
const entry = {
|
||||||
node,
|
node,
|
||||||
|
|||||||
@ -140,6 +140,9 @@ function OpenClawProductionDrawingPanel({
|
|||||||
const [manifest, setManifest] =
|
const [manifest, setManifest] =
|
||||||
useState<OpenClawProductionDrawingManifest | null>();
|
useState<OpenClawProductionDrawingManifest | null>();
|
||||||
const [manifestError, setManifestError] = useState(false);
|
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(
|
const proxyUrl = useCallback(
|
||||||
(index: number, download = false) =>
|
(index: number, download = false) =>
|
||||||
@ -153,14 +156,13 @@ function OpenClawProductionDrawingPanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifestArtifact) {
|
if (manifestIndex < 0) {
|
||||||
setManifest(null);
|
setManifest(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setManifestError(false);
|
setManifestError(false);
|
||||||
fetch(proxyUrl(manifestArtifact.index), {
|
fetch(proxyUrl(manifestIndex), {
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@ -177,7 +179,7 @@ function OpenClawProductionDrawingPanel({
|
|||||||
setManifestError(true);
|
setManifestError(true);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [manifestArtifact, proxyUrl]);
|
}, [manifestIndex, proxyUrl]);
|
||||||
|
|
||||||
if (!manifestArtifact && pdfs.length === 0 && dxfBundles.length === 0) {
|
if (!manifestArtifact && pdfs.length === 0 && dxfBundles.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -479,6 +481,9 @@ export function OpenClawModel3DPreview({
|
|||||||
const [manifest, setManifest] =
|
const [manifest, setManifest] =
|
||||||
useState<OpenClawModelInteractionManifest | null>();
|
useState<OpenClawModelInteractionManifest | null>();
|
||||||
const [manifestError, setManifestError] = useState<string | null>(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] =
|
const [selectedPart, setSelectedPart] =
|
||||||
useState<OpenClawModelInteractionPart | null>(null);
|
useState<OpenClawModelInteractionPart | null>(null);
|
||||||
const [selectedPackagingPart, setSelectedPackagingPart] =
|
const [selectedPackagingPart, setSelectedPackagingPart] =
|
||||||
@ -642,8 +647,8 @@ export function OpenClawModel3DPreview({
|
|||||||
}, [closeViewer, isActive]);
|
}, [closeViewer, isActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive || !bundle || manifest !== undefined) return;
|
if (!isActive || manifest !== undefined) return;
|
||||||
if (bundle.manifestIndex < 0) {
|
if (interactionManifestIndex < 0) {
|
||||||
setManifest(null);
|
setManifest(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -651,10 +656,10 @@ export function OpenClawModel3DPreview({
|
|||||||
const manifestUrl = buildOpenClawArtifactProxyUrl({
|
const manifestUrl = buildOpenClawArtifactProxyUrl({
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
artifactIndex: bundle.manifestIndex,
|
artifactIndex: interactionManifestIndex,
|
||||||
});
|
});
|
||||||
setManifestError(null);
|
setManifestError(null);
|
||||||
fetch(manifestUrl, { cache: "no-store", signal: controller.signal })
|
fetch(manifestUrl, { signal: controller.signal })
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`交互清单读取失败(HTTP ${response.status})`);
|
throw new Error(`交互清单读取失败(HTTP ${response.status})`);
|
||||||
@ -673,7 +678,7 @@ export function OpenClawModel3DPreview({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [bundle, chatId, isActive, manifest, messageId]);
|
}, [interactionManifestIndex, chatId, isActive, manifest, messageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode === "product" && !productModel && packagingModel) {
|
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">
|
<div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
|
||||||
{previewUrl ? (
|
{previewUrl && failedPreviewUrl !== previewUrl ? (
|
||||||
<img
|
<img
|
||||||
alt={`${model.displayName || "3D 模型"}静态预览`}
|
alt={`${model.displayName || "3D 模型"}静态预览`}
|
||||||
className="absolute inset-0 h-full w-full object-contain"
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
loading="lazy"
|
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" />
|
<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" />
|
<Box className="h-4 w-4" />
|
||||||
查看 3D
|
查看 3D
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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_TEXT_LENGTH = 500;
|
||||||
const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000;
|
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> {
|
function readRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
|
|||||||
@ -89,6 +89,10 @@ export const rateLimiters = {
|
|||||||
// 图片代理接口:每分钟3000次请求(图片列表/画布高频加载)
|
// 图片代理接口:每分钟3000次请求(图片列表/画布高频加载)
|
||||||
imageProxy: new RateLimiter(60000, 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次请求
|
// 高频只读接口(首页/画布轮询):每分钟1200次请求
|
||||||
uiPolling: new RateLimiter(60000, 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") {
|
} else if (pathname === "/api/image-proxy" && request.method === "GET") {
|
||||||
const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy);
|
const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy);
|
||||||
if (!imageProxyRateLimit.allowed) {
|
if (!imageProxyRateLimit.allowed) {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { GLTFLoader } from "three-stdlib";
|
||||||
|
import { checkRateLimitByIdentifier, rateLimiters } from "@/lib/security/rate-limiter";
|
||||||
import {
|
import {
|
||||||
buildOpenClawArtifactProxyUrl,
|
buildOpenClawArtifactProxyUrl,
|
||||||
extractOpenClawArtifacts,
|
extractOpenClawArtifacts,
|
||||||
@ -9,6 +11,7 @@ import {
|
|||||||
} from "@/lib/openclaw-chat/artifacts";
|
} from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
getOpenClawExplodedOffsetMm,
|
getOpenClawExplodedOffsetMm,
|
||||||
|
getOpenClawPartForSceneNode,
|
||||||
getOpenClawPackagingInteractionParts,
|
getOpenClawPackagingInteractionParts,
|
||||||
getOpenClawSourceNodeOffset,
|
getOpenClawSourceNodeOffset,
|
||||||
mapOpenClawCadOffsetToGlb,
|
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 = {
|
const modelFile = {
|
||||||
type: "file",
|
type: "file",
|
||||||
filename: "wood-demo-interactive.glb",
|
filename: "wood-demo-interactive.glb",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user