feat(api): add Lovart-style project and agent thread endpoints
Add canva/canda-compatible routes for lightweight project open/save and agent thread history: - POST /canva/project/queryProject, saveProject - POST /canva/agent/queryAgentLastThread, agentThreadList - GET /canda/chat-history, /canda/thread/status Canvas save now accepts a compressed SHAKKERDATA:// snapshot payload, preserves connections, and returns a SaveProject response; blank successful image nodes are backfilled from generated artifacts. Frontend designGateway adopts the new save/query flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useRef, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
import type { CanvasNode, CanvasViewport, Project } from "@/domain/design";
|
||||
import { designGateway } from "@/infrastructure/designGateway";
|
||||
import { useUserMessage } from "@/ui/hooks/useUserMessage";
|
||||
|
||||
const canvasSaveDebounceMs = 1200;
|
||||
|
||||
export function useCanvasPersistence({
|
||||
project,
|
||||
projectRef,
|
||||
@@ -38,10 +40,18 @@ export function useCanvasPersistence({
|
||||
};
|
||||
}) {
|
||||
const toUserMessageText = useUserMessage();
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const clearScheduledSave = useCallback(() => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const saveCanvasSnapshot = useCallback(
|
||||
async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||
const currentProject = projectRef.current;
|
||||
if (!currentProject) return null;
|
||||
clearScheduledSave();
|
||||
if (!quiet) setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -50,11 +60,7 @@ export function useCanvasPersistence({
|
||||
viewportRef.current,
|
||||
nodesRef.current,
|
||||
[],
|
||||
{
|
||||
version: currentProject.version,
|
||||
snapshotId: currentProject.snapshotId,
|
||||
canvas: ""
|
||||
}
|
||||
currentProject
|
||||
);
|
||||
projectRef.current = savedProject;
|
||||
setProject(savedProject);
|
||||
@@ -67,7 +73,22 @@ export function useCanvasPersistence({
|
||||
if (!quiet) setSaving(false);
|
||||
}
|
||||
},
|
||||
[toUserMessageText, nodesRef, projectRef, setDirty, setError, setProject, setSaving, viewportRef]
|
||||
[clearScheduledSave, toUserMessageText, nodesRef, projectRef, setDirty, setError, setProject, setSaving, viewportRef]
|
||||
);
|
||||
|
||||
const scheduleCanvasSave = useCallback(
|
||||
({ delayMs = canvasSaveDebounceMs }: { delayMs?: number } = {}) => {
|
||||
if (!projectRef.current || anyGenerating) {
|
||||
clearScheduledSave();
|
||||
return;
|
||||
}
|
||||
clearScheduledSave();
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
}, delayMs);
|
||||
},
|
||||
[anyGenerating, clearScheduledSave, projectRef, saveCanvasSnapshot]
|
||||
);
|
||||
|
||||
const updateProjectTitle = useCallback(
|
||||
@@ -91,15 +112,18 @@ export function useCanvasPersistence({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !dirty || anyGenerating) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
}, 900);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [anyGenerating, dirty, nodes, project, saveCanvasSnapshot, viewport]);
|
||||
if (!project || !dirty || anyGenerating) {
|
||||
clearScheduledSave();
|
||||
return;
|
||||
}
|
||||
scheduleCanvasSave();
|
||||
}, [anyGenerating, clearScheduledSave, dirty, nodes, project, scheduleCanvasSave, viewport]);
|
||||
|
||||
useEffect(() => clearScheduledSave, [clearScheduledSave]);
|
||||
|
||||
return {
|
||||
saveCanvasSnapshot,
|
||||
scheduleCanvasSave,
|
||||
updateProjectTitle
|
||||
};
|
||||
}
|
||||
|
||||
@@ -757,7 +757,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}, [projectRef]);
|
||||
|
||||
const { saveCanvasSnapshot, updateProjectTitle } = useCanvasPersistence({
|
||||
const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({
|
||||
project,
|
||||
projectRef,
|
||||
viewport,
|
||||
@@ -859,7 +859,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
if (history && !agentStreamingProjectIdRef.current) {
|
||||
const currentProject = projectRef.current;
|
||||
if (currentProject) {
|
||||
setMessages(visibleAgentMessagesForThread({ ...currentProject, messages: history.messages }, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
|
||||
const projectWithHistory = {
|
||||
...currentProject,
|
||||
lastThreadId: history.threadId || currentProject.lastThreadId,
|
||||
messages: history.messages
|
||||
};
|
||||
projectRef.current = projectWithHistory;
|
||||
setProject(projectWithHistory);
|
||||
setMessages(visibleAgentMessagesForThread(projectWithHistory, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
|
||||
} else {
|
||||
setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current));
|
||||
}
|
||||
@@ -1200,7 +1207,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("pointercancel", handleUp);
|
||||
setCanvasPanning(false);
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
@@ -1627,7 +1634,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
void refreshMockupPrintTextures(Array.from(dragIds));
|
||||
return;
|
||||
}
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
@@ -1686,7 +1693,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
void refreshMockupPrintTextures([node.id]);
|
||||
return;
|
||||
}
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
@@ -1724,7 +1731,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
setAlignmentGuides([]);
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
@@ -1751,7 +1758,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
setAlignmentGuides([]);
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
@@ -1804,7 +1811,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
setAlignmentGuides([]);
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
@@ -2517,6 +2524,22 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
const currentProject = projectRef.current;
|
||||
if (currentProject) {
|
||||
setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId));
|
||||
void designGateway
|
||||
.getProjectHistory(currentProject.id, threadId)
|
||||
.then((history) => {
|
||||
if (selectedAgentThreadIdRef.current !== threadId) return;
|
||||
const nextProject = {
|
||||
...currentProject,
|
||||
lastThreadId: history.threadId || currentProject.lastThreadId,
|
||||
messages: history.messages
|
||||
};
|
||||
projectRef.current = nextProject;
|
||||
setProject(nextProject);
|
||||
setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, threadId));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Failed to load agent thread history", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2836,7 +2859,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
setMockupSurface(null);
|
||||
setMockupModel(null);
|
||||
setDirty(true);
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
const refreshMockupPrintTextures = async (nodeIds: string[]) => {
|
||||
@@ -2860,13 +2883,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
return;
|
||||
}
|
||||
nodesRef.current = nextNodes;
|
||||
setNodes(nextNodes);
|
||||
setDirty(true);
|
||||
void saveCanvasSnapshot({ quiet: true });
|
||||
scheduleCanvasSave();
|
||||
};
|
||||
|
||||
const restoreQuickEditViewport = useCallback(() => {
|
||||
@@ -3131,11 +3154,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
setCropSelection(null);
|
||||
setCropPrompt("");
|
||||
setDirty(true);
|
||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], {
|
||||
version: currentProject.version,
|
||||
snapshotId: currentProject.snapshotId,
|
||||
canvas: currentProject.canvas
|
||||
});
|
||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], currentProject);
|
||||
applyProject(savedProject);
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
@@ -3211,7 +3230,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
for (const [index, file] of imageFiles.entries()) {
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
||||
const size = fitCanvasImageSize(dimensions.width, dimensions.height);
|
||||
const size = originalCanvasImageSize(dimensions.width, dimensions.height);
|
||||
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
|
||||
preparedUploads.push({
|
||||
file,
|
||||
@@ -3269,11 +3288,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
setError(t("generateError"));
|
||||
}
|
||||
|
||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], {
|
||||
version: currentProject.version,
|
||||
snapshotId: currentProject.snapshotId,
|
||||
canvas: ""
|
||||
});
|
||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
|
||||
applyProject(savedProject);
|
||||
setDirty(false);
|
||||
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess"));
|
||||
@@ -3754,7 +3769,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
setViewport(nextViewport);
|
||||
setDirty(true);
|
||||
}}
|
||||
onViewportCommit={() => void saveCanvasSnapshot({ quiet: true })}
|
||||
onViewportCommit={scheduleCanvasSave}
|
||||
/>
|
||||
)}
|
||||
<div className="canvas-statusbar">
|
||||
@@ -5305,19 +5320,13 @@ function readImageDimensions(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
function fitCanvasImageSize(width: number, height: number) {
|
||||
function originalCanvasImageSize(width: number, height: number) {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return { width: 420, height: 420 };
|
||||
}
|
||||
const maxSide = 520;
|
||||
const minSide = 120;
|
||||
let scale = Math.min(1, maxSide / Math.max(width, height));
|
||||
if (Math.max(width, height) * scale < minSide) {
|
||||
scale = minSide / Math.max(width, height);
|
||||
return { width: 512, height: 512 };
|
||||
}
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
width: Math.round(width),
|
||||
height: Math.round(height)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user