fix(claw): persist 3d preview and restore chat history

This commit is contained in:
134 2026-09-06 09:14:25 +08:00
parent 9f907c30a4
commit 256611407c
9 changed files with 502 additions and 137 deletions

View File

@ -25,7 +25,7 @@ export async function GET(request: Request) {
const url = new URL(request.url);
const limit = Math.max(
1,
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50)))
Math.min(500, Math.round(Number(url.searchParams.get("limit") || 100)))
);
const chats = await getClawdChatsByUserId({

View File

@ -271,11 +271,14 @@ import {
isGenericCanvasPrompt,
isValidChatHttpUrl,
isValidChatUuid,
mergeClawdHistoryIntoCanvasSessions,
MAX_CHAT_AUTO_CONTEXT_LENGTH,
MAX_CHAT_HIDDEN_PROMPT_LENGTH,
MAX_CHAT_TEXT_PART_LENGTH,
normalizeChatAttachmentName,
normalizeChatMediaType,
type CanvasChatSessionRecord,
type ClawdChatHistoryRecord,
type PickedObjectAttachment,
sanitizeChatAttachment,
toRawCanvasChatId,
@ -3008,23 +3011,11 @@ type MessageVote = {
isUpvoted: boolean;
};
interface CanvasChatSession {
id: string;
title: string;
type CanvasChatSession = Omit<CanvasChatSessionRecord, "mode"> & {
mode: CanvasComposerMode;
createdAt: number;
updatedAt: number;
clawdSkillTagId?: string | null;
clawdSkillTagPromptApplied?: boolean;
}
};
interface ClawdHistoryItem {
id: string;
title: string;
createdAt: string | Date;
updatedAt: string | Date;
messageCount: number;
}
type ClawdHistoryItem = ClawdChatHistoryRecord;
const CANVAS_CHAT_QUERY_PARAM = "chat";
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
@ -5978,18 +5969,26 @@ export function CanvasChatPanel({
activeComposerMode
);
const clawdHistorySWRKey =
isClawdMode && isClawdHistoryMenuOpen
? "/api/canvas/clawd-chat/history?limit=80"
isClawdMode && session?.user?.id
? "/api/canvas/clawd-chat/history?limit=500"
: null;
const {
data: clawdHistoryData,
error: clawdHistoryError,
isLoading: isClawdHistoryLoading,
mutate: mutateClawdHistory,
} = useSWR<{ chats: ClawdHistoryItem[] }>(clawdHistorySWRKey, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 5000,
});
useEffect(() => {
const persistedChats = clawdHistoryData?.chats;
if (!persistedChats) return;
updateChatSessions((previousSessions) =>
mergeClawdHistoryIntoCanvasSessions(previousSessions, persistedChats)
);
}, [clawdHistoryData?.chats, updateChatSessions]);
useEffect(() => {
if (activeChatSessionMode !== "clawd") return;
if (!isClawdModeEnabled) return;
@ -15523,6 +15522,14 @@ export function CanvasChatPanel({
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载 Claw 历史
</div>
) : clawdHistoryError ? (
<button
type="button"
className="w-full rounded-md px-2 py-3 text-left text-xs text-amber-700 hover:bg-amber-50 dark:text-amber-300 dark:hover:bg-amber-500/10"
onClick={() => void mutateClawdHistory()}
>
历史加载失败,点击重试
</button>
) : (clawdHistoryData?.chats || []).length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
暂无 Claw 历史

View File

@ -38,10 +38,6 @@ type AnimationState = {
starts: Map<string, THREE.Vector3>;
targets: Map<string, THREE.Vector3>;
easing: string;
cameraStart: THREE.Vector3;
cameraTarget: THREE.Vector3;
controlsTargetStart: THREE.Vector3;
controlsTargetEnd: THREE.Vector3;
};
type DragState = {
@ -199,51 +195,6 @@ function ModelInteractionScene({
const currentAmountRef = useRef(0);
const selectionHelperRef = useRef<THREE.BoxHelper | null>(null);
const getCameraFrame = (
starts: Map<string, THREE.Vector3>,
targets: Map<string, THREE.Vector3>
) => {
for (const [nodeName, target] of targets) {
entriesRef.current.get(nodeName)?.node.position.copy(target);
}
scene.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(scene);
for (const [nodeName, start] of starts) {
entriesRef.current.get(nodeName)?.node.position.copy(start);
}
scene.updateMatrixWorld(true);
const controlsTargetStart = controlsRef.current?.target?.clone?.() ||
new THREE.Vector3();
if (bounds.isEmpty()) {
return {
cameraTarget: camera.position.clone(),
controlsTargetEnd: controlsTargetStart.clone(),
};
}
const sphere = bounds.getBoundingSphere(new THREE.Sphere());
const perspectiveCamera = camera as THREE.PerspectiveCamera;
const verticalHalfFov = THREE.MathUtils.degToRad(perspectiveCamera.fov) / 2;
const horizontalHalfFov = Math.atan(
Math.tan(verticalHalfFov) * perspectiveCamera.aspect
);
const limitingHalfFov = Math.max(
0.1,
Math.min(verticalHalfFov, horizontalHalfFov)
);
const distance = Math.max(
1.2,
(sphere.radius / Math.sin(limitingHalfFov)) * 1.12
);
const direction = camera.position.clone().sub(controlsTargetStart);
if (direction.lengthSq() < 0.0001) direction.set(1, 0.75, 1);
direction.normalize();
return {
cameraTarget: sphere.center.clone().addScaledVector(direction, distance),
controlsTargetEnd: sphere.center.clone(),
};
};
useEffect(() => {
const partsByNodeName = new Map(
manifest.parts.map((part) => [part.nodeName, part])
@ -306,19 +257,11 @@ function ModelInteractionScene({
starts.set(nodeName, entry.node.position.clone());
targets.set(nodeName, getPartTargetPosition(entry, manifest, amount));
}
const cameraFrame = getCameraFrame(starts, targets);
if (!transformRequest.animate) {
animationRef.current = null;
for (const [nodeName, target] of targets) {
entriesRef.current.get(nodeName)?.node.position.copy(target);
}
camera.position.copy(cameraFrame.cameraTarget);
if (controlsRef.current) {
controlsRef.current.target.copy(cameraFrame.controlsTargetEnd);
controlsRef.current.update();
} else {
camera.lookAt(cameraFrame.controlsTargetEnd);
}
return;
}
const targetState = amount >= 0.5 ? "exploded" : "assembled";
@ -331,13 +274,8 @@ function ModelInteractionScene({
starts,
targets,
easing: action?.easing || "easeInOutCubic",
cameraStart: camera.position.clone(),
cameraTarget: cameraFrame.cameraTarget,
controlsTargetStart:
controlsRef.current?.target?.clone?.() || new THREE.Vector3(),
controlsTargetEnd: cameraFrame.controlsTargetEnd,
};
}, [camera, manifest, scene, transformRequest]);
}, [manifest, transformRequest]);
useEffect(() => {
if (resetAllToken <= 0) return;
@ -507,21 +445,6 @@ function ModelInteractionScene({
const start = animation.starts.get(nodeName);
if (entry && start) entry.node.position.lerpVectors(start, target, progress);
}
camera.position.lerpVectors(
animation.cameraStart,
animation.cameraTarget,
progress
);
if (controlsRef.current) {
controlsRef.current.target.lerpVectors(
animation.controlsTargetStart,
animation.controlsTargetEnd,
progress
);
controlsRef.current.update();
} else {
camera.lookAt(animation.controlsTargetEnd);
}
if (rawProgress >= 1) animationRef.current = null;
});

View File

@ -12,7 +12,14 @@ import {
Undo2,
X,
} from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import {
buildOpenClawArtifactProxyUrl,
getOpenClaw3DArtifactVariant,
@ -24,6 +31,11 @@ import {
type OpenClawModelInteractionManifest,
type OpenClawModelInteractionPart,
} from "@/lib/openclaw-chat/model-interaction";
import {
clearOpenClaw3DViewerSession,
readOpenClaw3DViewerSession,
writeOpenClaw3DViewerSession,
} from "@/lib/openclaw-chat/viewer-session";
const Model3DViewer = dynamic(
() =>
@ -70,6 +82,14 @@ function supportsWebGl() {
}
}
function getViewerSessionStorage() {
try {
return window.sessionStorage;
} catch {
return null;
}
}
function formatDimensions(part: OpenClawModelInteractionPart) {
const dimensions = part.dimensionsMm;
if (!dimensions) return null;
@ -110,12 +130,23 @@ export function OpenClawModel3DPreview({
durationMs: 700,
nonce: 0,
});
const hasRestoredSessionRef = useRef(false);
const restoredSelectedNodeNameRef = useRef<string | null>(null);
const closeViewer = useCallback(() => {
setIsActive(false);
setSelectedPart(null);
setMatchState({ matched: 0, total: 0 });
}, []);
if (typeof window !== "undefined") {
const storage = getViewerSessionStorage();
if (!storage) return;
clearOpenClaw3DViewerSession({
storage,
chatId,
messageId,
});
}
}, [chatId, messageId]);
const handleMatchChange = useCallback((matched: number, total: number) => {
setMatchState((current) =>
current.matched === matched && current.total === total
@ -124,6 +155,63 @@ export function OpenClawModel3DPreview({
);
}, []);
useEffect(() => {
if (hasRestoredSessionRef.current || !model) return;
hasRestoredSessionRef.current = true;
const storage = getViewerSessionStorage();
if (!storage) return;
const session = readOpenClaw3DViewerSession({
storage,
chatId,
});
if (session?.messageId !== messageId) return;
if (!supportsWebGl()) {
setWebGlUnavailable(true);
clearOpenClaw3DViewerSession({
storage,
chatId,
messageId,
});
return;
}
restoredSelectedNodeNameRef.current = session.selectedNodeName;
setExplodeAmount(session.explodeAmount);
setTransformRequest((current) => ({
actionId: null,
amount: session.explodeAmount,
animate: false,
durationMs: model.viewer?.transitionDurationMs || 700,
nonce: current.nonce + 1,
}));
window.dispatchEvent(
new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId })
);
setIsActive(true);
}, [chatId, messageId, model, viewerId]);
useEffect(() => {
if (!isActive) return;
const storage = getViewerSessionStorage();
if (!storage) return;
writeOpenClaw3DViewerSession({
storage,
chatId,
messageId,
explodeAmount,
selectedNodeName: selectedPart?.nodeName || null,
});
}, [chatId, explodeAmount, isActive, messageId, selectedPart?.nodeName]);
useEffect(() => {
const nodeName = restoredSelectedNodeNameRef.current;
if (!manifest || !nodeName) return;
restoredSelectedNodeNameRef.current = null;
setSelectedPart(
manifest.parts.find((part) => part.nodeName === nodeName) || null
);
}, [manifest]);
useEffect(() => {
const handleOtherViewerActivated = (event: Event) => {
const activeId = (event as CustomEvent<string>).detail;
@ -348,37 +436,55 @@ export function OpenClawModel3DPreview({
<div className="absolute inset-x-0 bottom-0 border-t border-black/10 bg-white/94 px-3 py-2.5 backdrop-blur-md dark:border-white/10 dark:bg-zinc-950/94">
<div className="flex flex-wrap items-center gap-2">
{canExplode ? (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-[#202631] px-3 text-xs font-medium text-white hover:bg-[#11151c] dark:bg-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
onClick={() =>
updateExplodeAmount(
1,
true,
model.viewer?.explodeActionId || "explode_all"
)
}
{canExplode || canAssemble ? (
<div
aria-label="3D模型状态"
className="inline-flex h-8 items-center rounded-md border border-black/10 bg-black/[0.025] p-0.5 dark:border-white/15 dark:bg-white/[0.06]"
role="group"
>
<Maximize2 className="h-3.5 w-3.5" />
一键炸开
</button>
) : null}
{canAssemble ? (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/10 px-3 text-xs font-medium text-[#3e4652] hover:bg-black/5 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10"
onClick={() =>
updateExplodeAmount(
0,
true,
model.viewer?.assembleActionId || "assemble_all"
)
}
>
<Minimize2 className="h-3.5 w-3.5" />
一键还原
</button>
{canAssemble ? (
<button
type="button"
aria-pressed={explodeAmount < 0.5}
className={
explodeAmount < 0.5
? "inline-flex h-7 items-center gap-1.5 rounded-[4px] bg-white px-2.5 text-xs font-medium text-[#202631] shadow-sm dark:bg-[#3ecf8e] dark:text-[#07120d]"
: "inline-flex h-7 items-center gap-1.5 rounded-[4px] px-2.5 text-xs font-medium text-[#687386] hover:text-[#202631] dark:text-zinc-400 dark:hover:text-zinc-100"
}
onClick={() =>
updateExplodeAmount(
0,
true,
model.viewer?.assembleActionId || "assemble_all"
)
}
>
<Minimize2 className="h-3.5 w-3.5" />
组合状态
</button>
) : null}
{canExplode ? (
<button
type="button"
aria-pressed={explodeAmount >= 0.5}
className={
explodeAmount >= 0.5
? "inline-flex h-7 items-center gap-1.5 rounded-[4px] bg-white px-2.5 text-xs font-medium text-[#202631] shadow-sm dark:bg-[#3ecf8e] dark:text-[#07120d]"
: "inline-flex h-7 items-center gap-1.5 rounded-[4px] px-2.5 text-xs font-medium text-[#687386] hover:text-[#202631] dark:text-zinc-400 dark:hover:text-zinc-100"
}
onClick={() =>
updateExplodeAmount(
1,
true,
model.viewer?.explodeActionId || "explode_all"
)
}
>
<Maximize2 className="h-3.5 w-3.5" />
爆炸状态
</button>
) : null}
</div>
) : null}
{selectedPart && canResetParts ? (
<button

View File

@ -44,6 +44,24 @@ export interface PickedObjectAttachment extends Attachment {
};
}
export interface CanvasChatSessionRecord {
id: string;
title: string;
mode: string;
createdAt: number;
updatedAt: number;
clawdSkillTagId?: string | null;
clawdSkillTagPromptApplied?: boolean;
}
export interface ClawdChatHistoryRecord {
id: string;
title: string;
createdAt: string | Date;
updatedAt: string | Date;
messageCount: number;
}
export function toRawCanvasChatId(value: string): string {
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
}
@ -117,6 +135,76 @@ export function getSessionRootStorageKey(canvasId: string, scope: "canvas" | "ag
return `canvas-chat-root:${scope}:${canvasId}`;
}
function parseHistoryTimestamp(value: string | Date) {
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function mergeClawdHistoryIntoCanvasSessions<
T extends CanvasChatSessionRecord,
>(sessions: T[], history: ClawdChatHistoryRecord[]): T[] {
if (history.length === 0) return sessions;
const nextSessions = [...sessions];
const indexById = new Map(
nextSessions.map((session, index) => [session.id, index])
);
let changed = false;
for (const item of history) {
const rawId = toRawCanvasChatId(String(item.id || "").trim());
if (!isValidChatUuid(rawId)) continue;
const id = `canvas-${rawId}`;
const existingIndex = indexById.get(id);
const historyCreatedAt = parseHistoryTimestamp(item.createdAt);
const historyUpdatedAt =
parseHistoryTimestamp(item.updatedAt) || historyCreatedAt;
const historyTitle = String(item.title || "").trim();
if (existingIndex === undefined) {
const created = {
id,
title: historyTitle || "Claw 对话",
mode: "clawd",
createdAt: historyCreatedAt || historyUpdatedAt,
updatedAt: historyUpdatedAt || historyCreatedAt,
} as T;
indexById.set(id, nextSessions.length);
nextSessions.push(created);
changed = true;
continue;
}
const existing = nextSessions[existingIndex];
const nextTitle = historyTitle || existing.title || "Claw 对话";
const nextCreatedAt =
Number(existing.createdAt) || historyCreatedAt || historyUpdatedAt;
const nextUpdatedAt = Math.max(
Number(existing.updatedAt) || 0,
historyUpdatedAt
);
if (
existing.title === nextTitle &&
existing.mode === "clawd" &&
existing.createdAt === nextCreatedAt &&
existing.updatedAt === nextUpdatedAt
) {
continue;
}
nextSessions[existingIndex] = {
...existing,
title: nextTitle,
mode: "clawd",
createdAt: nextCreatedAt,
updatedAt: nextUpdatedAt,
};
changed = true;
}
if (!changed) return sessions;
return nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
}
export function extractUserTextPrompt(message: ChatMessage): string {
const parts = (message as any)?.parts || [];
const textFromParts = parts

View File

@ -191,8 +191,13 @@ export async function getClawdChatsByUserId({
userId: string;
limit?: number;
}) {
const safeLimit = Math.max(1, Math.min(100, Math.round(limit) || 50));
const safeLimit = Math.max(1, Math.min(500, Math.round(limit) || 50));
const latestMessageAt = sql<Date>`max(${message.createdAt})`;
const hasClawdMessage = sql<boolean>`bool_or(
coalesce(${message.videoMetadata}->>'composerMode', '') = 'clawd'
or coalesce(${message.videoMetadata}->>'taskKind', '') = 'clawd_chat'
or coalesce(${message.videoMetadata}->>'source', '') = 'openclaw'
)`;
try {
return await db
@ -205,13 +210,9 @@ export async function getClawdChatsByUserId({
})
.from(chat)
.innerJoin(message, eq(message.chatId, chat.id))
.where(
and(
eq(chat.userId, userId),
sql`coalesce(${message.videoMetadata}->>'composerMode', '') = 'clawd'`
)
)
.where(eq(chat.userId, userId))
.groupBy(chat.id, chat.title, chat.createdAt)
.having(hasClawdMessage)
.orderBy(desc(latestMessageAt))
.limit(safeLimit);
} catch (_error) {

View File

@ -0,0 +1,106 @@
const OPENCLAW_3D_VIEWER_SESSION_PREFIX = "openclaw-3d-viewer:v1:";
const OPENCLAW_3D_VIEWER_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export type OpenClaw3DViewerSession = {
messageId: string;
explodeAmount: number;
selectedNodeName: string | null;
updatedAt: number;
};
type ViewerSessionStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
function getViewerSessionKey(chatId: string) {
return `${OPENCLAW_3D_VIEWER_SESSION_PREFIX}${chatId}`;
}
function removeViewerSession(storage: ViewerSessionStorage, key: string) {
try {
storage.removeItem(key);
} catch {
// Storage can be unavailable under strict browser privacy policies.
}
}
function normalizeExplodeAmount(value: unknown) {
const amount = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(amount)) return 0;
return Math.max(0, Math.min(1, amount));
}
export function readOpenClaw3DViewerSession(params: {
storage: ViewerSessionStorage;
chatId: string;
now?: number;
}): OpenClaw3DViewerSession | null {
const key = getViewerSessionKey(params.chatId);
try {
const raw = params.storage.getItem(key);
if (!raw) return null;
const value = JSON.parse(raw) as Record<string, unknown>;
const messageId =
typeof value.messageId === "string" ? value.messageId.trim() : "";
const updatedAt = Number(value.updatedAt);
const now = params.now ?? Date.now();
if (
!messageId ||
!Number.isFinite(updatedAt) ||
updatedAt <= 0 ||
now - updatedAt > OPENCLAW_3D_VIEWER_SESSION_MAX_AGE_MS
) {
removeViewerSession(params.storage, key);
return null;
}
return {
messageId,
explodeAmount: normalizeExplodeAmount(value.explodeAmount),
selectedNodeName:
typeof value.selectedNodeName === "string" &&
value.selectedNodeName.trim()
? value.selectedNodeName.trim()
: null,
updatedAt,
};
} catch {
removeViewerSession(params.storage, key);
return null;
}
}
export function writeOpenClaw3DViewerSession(params: {
storage: ViewerSessionStorage;
chatId: string;
messageId: string;
explodeAmount: number;
selectedNodeName?: string | null;
now?: number;
}) {
const messageId = params.messageId.trim();
if (!messageId) return;
try {
params.storage.setItem(
getViewerSessionKey(params.chatId),
JSON.stringify({
messageId,
explodeAmount: normalizeExplodeAmount(params.explodeAmount),
selectedNodeName: params.selectedNodeName?.trim() || null,
updatedAt: params.now ?? Date.now(),
} satisfies OpenClaw3DViewerSession)
);
} catch {
// Private browsing and storage policies may disable sessionStorage.
}
}
export function clearOpenClaw3DViewerSession(params: {
storage: ViewerSessionStorage;
chatId: string;
messageId: string;
}) {
const current = readOpenClaw3DViewerSession({
storage: params.storage,
chatId: params.chatId,
});
if (current?.messageId !== params.messageId) return;
removeViewerSession(params.storage, getViewerSessionKey(params.chatId));
}

View File

@ -236,6 +236,7 @@ import {
validateCanvasImageUploadFile,
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
import {
mergeClawdHistoryIntoCanvasSessions,
normalizeChatMediaType,
sanitizeChatAttachment,
type PickedObjectAttachment,
@ -257,6 +258,62 @@ function dbTextMessage(
}
test.describe("canvas chat pure helpers", () => {
test("rebuilds Claw session history from persisted database records", () => {
const localSession = {
id: "canvas-11111111-1111-4111-8111-111111111111",
title: "当前对话",
mode: "clawd",
createdAt: 3_000,
updatedAt: 4_000,
};
const merged = mergeClawdHistoryIntoCanvasSessions([localSession], [
{
id: "22222222-2222-4222-8222-222222222222",
title: "数据库历史对话",
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-02T00:00:00.000Z",
messageCount: 8,
},
]);
expect(merged).toHaveLength(2);
expect(merged[0]).toMatchObject({
id: "canvas-22222222-2222-4222-8222-222222222222",
title: "数据库历史对话",
mode: "clawd",
});
expect(merged[1]).toBe(localSession);
});
test("keeps local Claw session fields while refreshing database timestamps", () => {
const existing = {
id: "canvas-33333333-3333-4333-8333-333333333333",
title: "本地标题",
mode: "image",
createdAt: 1_000,
updatedAt: 2_000,
clawdSkillTagId: "skill-id",
};
const updatedAt = "2026-09-03T00:00:00.000Z";
const merged = mergeClawdHistoryIntoCanvasSessions([existing], [
{
id: "33333333-3333-4333-8333-333333333333",
title: "已持久化标题",
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt,
messageCount: 4,
},
]);
expect(merged[0]).toMatchObject({
title: "已持久化标题",
mode: "clawd",
createdAt: 1_000,
updatedAt: Date.parse(updatedAt),
clawdSkillTagId: "skill-id",
});
});
test("preserves spreadsheet attachment content types", () => {
const spreadsheetContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

View File

@ -14,6 +14,20 @@ import {
normalizeOpenClawModelInteractionManifest,
} from "@/lib/openclaw-chat/model-interaction";
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
import {
clearOpenClaw3DViewerSession,
readOpenClaw3DViewerSession,
writeOpenClaw3DViewerSession,
} from "@/lib/openclaw-chat/viewer-session";
function createMemoryStorage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
}
const modelFile = {
type: "file",
@ -298,4 +312,67 @@ test.describe("OpenClaw 3D artifacts", () => {
expect(task?.artifacts).toHaveLength(1);
expect(task?.artifacts?.[0].viewer?.format).toBe("glb");
});
test("restores the active viewer and exploded state after a refresh", () => {
const storage = createMemoryStorage();
writeOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "message-id",
explodeAmount: 1,
selectedNodeName: "DOOR-001:1",
now: 1_000,
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: 2_000,
})
).toEqual({
messageId: "message-id",
explodeAmount: 1,
selectedNodeName: "DOOR-001:1",
updatedAt: 1_000,
});
});
test("only clears the viewer state owned by the matching message", () => {
const storage = createMemoryStorage();
const now = Date.now();
writeOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "new-message-id",
explodeAmount: 0,
now,
});
clearOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "old-message-id",
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: now + 1_000,
})?.messageId
).toBe("new-message-id");
clearOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "new-message-id",
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: now + 1_000,
})
).toBeNull();
});
});