fix(claw): persist 3d preview and restore chat history
This commit is contained in:
parent
9f907c30a4
commit
256611407c
@ -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({
|
||||
|
||||
@ -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 历史
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
106
lib/openclaw-chat/viewer-session.ts
Normal file
106
lib/openclaw-chat/viewer-session.ts
Normal 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));
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user