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:
2026-07-08 19:10:08 +08:00
parent ada29cee12
commit 263748af4a
27 changed files with 2045 additions and 114 deletions
@@ -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
};
}
+42 -33
View File
@@ -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)
};
}