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,5 +1,4 @@
|
||||
import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||
import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding";
|
||||
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
||||
|
||||
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
||||
@@ -116,7 +115,6 @@ export const authGateway = {
|
||||
},
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
const uploadFile = await encodeUploadImage(file);
|
||||
const upload = await apiRequest<{
|
||||
uploadUrl: string;
|
||||
publicUrl: string;
|
||||
@@ -124,9 +122,9 @@ export const authGateway = {
|
||||
}>("/api/assets/upload-url", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fileName: uploadFile.name,
|
||||
contentType: uploadFile.type || file.type || "application/octet-stream",
|
||||
size: uploadFile.size
|
||||
fileName: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
size: file.size
|
||||
})
|
||||
});
|
||||
|
||||
@@ -134,7 +132,7 @@ export const authGateway = {
|
||||
await fetch(upload.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: upload.headers,
|
||||
body: uploadFile
|
||||
body: file
|
||||
});
|
||||
}
|
||||
return upload.publicUrl;
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
} from "@/domain/design";
|
||||
import { authHeaders } from "@/infrastructure/authGateway";
|
||||
import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages";
|
||||
import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding";
|
||||
|
||||
type ImageGenerationOptions = {
|
||||
imageModel?: string;
|
||||
@@ -45,6 +44,108 @@ type ProjectEventsOptions = {
|
||||
eventsUrl?: string;
|
||||
};
|
||||
|
||||
type CanvasSaveProject = Pick<Project, "version" | "snapshotId" | "canvas"> & Partial<Project>;
|
||||
|
||||
type SaveProjectResponse = {
|
||||
code: number;
|
||||
msg: string | null;
|
||||
data: {
|
||||
projectId: string;
|
||||
newCreated: boolean;
|
||||
validProjectId: boolean;
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
|
||||
type QueryProjectResponse = {
|
||||
code: number;
|
||||
msg: string | null;
|
||||
data: {
|
||||
assetMeta: {
|
||||
assetId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
extra?: {
|
||||
projectCoverList?: string[];
|
||||
};
|
||||
updateTime: string;
|
||||
};
|
||||
userId: string | number;
|
||||
projectName: string;
|
||||
projectId: string | null;
|
||||
canvas: string;
|
||||
validProjectId: boolean;
|
||||
projectType: number;
|
||||
version: string;
|
||||
snapshotId: string | null;
|
||||
permission: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CanvasSnapshotDraft = {
|
||||
snapshotId: string;
|
||||
updatedAt: string;
|
||||
projectCoverList: string[];
|
||||
};
|
||||
|
||||
type QueryProjectCanvasSnapshot = {
|
||||
projectId?: string;
|
||||
version?: string;
|
||||
snapshotId?: string;
|
||||
viewport?: CanvasViewport;
|
||||
nodes?: Array<Partial<CanvasNode> | null>;
|
||||
connections?: Array<Partial<CanvasConnection> | null>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type QueryAgentLastThreadResponse = {
|
||||
code: number;
|
||||
msg: string | null;
|
||||
data: string;
|
||||
};
|
||||
|
||||
type LovartAgentThreadListResponse = {
|
||||
code: number;
|
||||
msg: string | null;
|
||||
data: {
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
data: Array<{
|
||||
agentThreadId: string;
|
||||
threadIdType: number;
|
||||
text: string;
|
||||
coverImageUrl: string | null;
|
||||
updateTimestamp: number;
|
||||
}>;
|
||||
hasMore: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type CandaChatHistoryResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
action_id: string;
|
||||
step_id: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
thread_meta?: {
|
||||
session_id?: string;
|
||||
project_id?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const canvasDataPrefix = "SHAKKERDATA://";
|
||||
const canvasSaveSessionStorageKey = "img-infinite-canvas.canvas.saveSessionId";
|
||||
|
||||
function isVersionConflictError(error: unknown) {
|
||||
return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409;
|
||||
}
|
||||
@@ -99,8 +200,18 @@ export const designGateway = {
|
||||
});
|
||||
},
|
||||
|
||||
getProject(id: string) {
|
||||
return request<{ project: Project }>(`/api/projects/${id}`);
|
||||
async getProject(id: string) {
|
||||
try {
|
||||
const response = await request<QueryProjectResponse>("/api/canva/project/queryProject", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() })
|
||||
});
|
||||
const project = await projectFromQueryProjectResponse(response, id);
|
||||
if (project) return { project };
|
||||
} catch {
|
||||
// Keep existing workspaces open if the Lovart-compatible document route is unavailable during development.
|
||||
}
|
||||
return getProjectDocument(id);
|
||||
},
|
||||
|
||||
updateProjectTitle(id: string, title: string) {
|
||||
@@ -130,12 +241,49 @@ export const designGateway = {
|
||||
});
|
||||
},
|
||||
|
||||
getProjectHistory(id: string) {
|
||||
return request<Pick<Project, "messages"> & { projectId: string }>(`/api/projects/${id}/history`);
|
||||
async getProjectHistory(id: string, threadId?: string) {
|
||||
try {
|
||||
const resolvedThreadId = threadId || (await designGateway.queryAgentLastThread(id));
|
||||
if (!resolvedThreadId) return { projectId: id, threadId: "", messages: [] };
|
||||
const params = new URLSearchParams({ thread_id: resolvedThreadId, project_id: id });
|
||||
const response = await request<CandaChatHistoryResponse>(`/api/canda/chat-history?${params.toString()}`);
|
||||
return {
|
||||
projectId: id,
|
||||
threadId: resolvedThreadId,
|
||||
messages: response.data.map((item) => messageFromCandaHistoryItem(item, resolvedThreadId))
|
||||
};
|
||||
} catch {
|
||||
return getLegacyProjectHistory(id, threadId);
|
||||
}
|
||||
},
|
||||
|
||||
listAgentThreads(id: string) {
|
||||
return request<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
|
||||
async listAgentThreads(id: string) {
|
||||
try {
|
||||
const response = await request<LovartAgentThreadListResponse>("/api/canva/agent/agentThreadList", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: id, page: 1, pageSize: 100, cid: createQueryProjectCid() })
|
||||
});
|
||||
return {
|
||||
projectId: id,
|
||||
threads: response.data.data.map((thread) => ({
|
||||
agentThreadId: thread.agentThreadId,
|
||||
coverImageUrl: thread.coverImageUrl ?? "",
|
||||
text: thread.text,
|
||||
threadIdType: thread.threadIdType,
|
||||
updateTimestamp: thread.updateTimestamp
|
||||
}))
|
||||
} satisfies AgentThreadListResponse;
|
||||
} catch {
|
||||
return request<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
|
||||
}
|
||||
},
|
||||
|
||||
async queryAgentLastThread(id: string) {
|
||||
const response = await request<QueryAgentLastThreadResponse>("/api/canva/agent/queryAgentLastThread", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() })
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
generate(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) {
|
||||
@@ -289,16 +437,45 @@ export const designGateway = {
|
||||
viewport: CanvasViewport,
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
snapshot?: Pick<Project, "version" | "snapshotId" | "canvas">
|
||||
snapshot?: CanvasSaveProject
|
||||
) {
|
||||
const payload = { viewport, nodes, connections, ...snapshot };
|
||||
const save = () =>
|
||||
request<{ project: Project }>(`/api/projects/${projectId}/canvas`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return (async () => {
|
||||
const draft = createCanvasSnapshotDraft(nodes);
|
||||
const canvas = await encodeCanvasSnapshot({
|
||||
projectId,
|
||||
version: snapshot?.version ?? "",
|
||||
snapshotId: draft.snapshotId,
|
||||
updatedAt: draft.updatedAt,
|
||||
viewport,
|
||||
nodes,
|
||||
connections
|
||||
});
|
||||
const payload = canvas
|
||||
? {
|
||||
canvas,
|
||||
projectCoverList: draft.projectCoverList,
|
||||
picCount: draft.projectCoverList.length,
|
||||
projectName: snapshot?.title || "Untitled",
|
||||
projectId,
|
||||
version: snapshot?.version ?? "",
|
||||
sessionId: getCanvasSaveSessionId(),
|
||||
canvasV2Gray: true
|
||||
}
|
||||
: {
|
||||
viewport,
|
||||
nodes,
|
||||
connections,
|
||||
version: snapshot?.version ?? "",
|
||||
snapshotId: draft.snapshotId,
|
||||
canvas: ""
|
||||
};
|
||||
const save = async () => {
|
||||
const response = await saveProjectCanvas(projectId, payload);
|
||||
return {
|
||||
project: mergeSavedCanvasProject(projectId, viewport, nodes, connections, snapshot, draft, canvas, response)
|
||||
};
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
return await save();
|
||||
@@ -313,7 +490,6 @@ export const designGateway = {
|
||||
},
|
||||
|
||||
async uploadImage(file: File) {
|
||||
const uploadFile = await encodeUploadImage(file);
|
||||
const upload = await request<{
|
||||
driver: string;
|
||||
uploadUrl: string;
|
||||
@@ -322,9 +498,9 @@ export const designGateway = {
|
||||
}>("/api/assets/upload-url", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fileName: uploadFile.name,
|
||||
contentType: uploadFile.type || file.type || "application/octet-stream",
|
||||
size: uploadFile.size
|
||||
fileName: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
size: file.size
|
||||
})
|
||||
});
|
||||
|
||||
@@ -332,13 +508,357 @@ export const designGateway = {
|
||||
await fetch(upload.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: upload.headers,
|
||||
body: uploadFile
|
||||
body: file
|
||||
});
|
||||
}
|
||||
return upload.publicUrl;
|
||||
}
|
||||
};
|
||||
|
||||
function getProjectDocument(id: string) {
|
||||
return request<{ project: Project }>(`/api/projects/${id}`);
|
||||
}
|
||||
|
||||
async function getLegacyProjectHistory(id: string, threadId?: string) {
|
||||
const response = await request<{ projectId: string; messages: AgentMessage[] }>(`/api/projects/${id}/history`);
|
||||
const resolvedThreadId = threadId || latestThreadId(response.messages);
|
||||
const messages = resolvedThreadId ? response.messages.filter((message) => !message.threadId || message.threadId === resolvedThreadId) : response.messages;
|
||||
return {
|
||||
projectId: response.projectId,
|
||||
threadId: resolvedThreadId,
|
||||
messages
|
||||
};
|
||||
}
|
||||
|
||||
async function saveProjectCanvas(projectId: string, payload: object) {
|
||||
try {
|
||||
return await request<SaveProjectResponse>("/api/canva/project/saveProject", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} catch (error) {
|
||||
if (isVersionConflictError(error)) throw error;
|
||||
return request<SaveProjectResponse>(`/api/projects/${projectId}/canvas`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function messageFromCandaHistoryItem(item: CandaChatHistoryResponse["data"][number], fallbackThreadId: string): AgentMessage {
|
||||
const type = item.type || "assistant";
|
||||
const role: AgentMessage["role"] = type === "user" ? "user" : type === "tool_use" || item.tool_call_id ? "tool" : "assistant";
|
||||
const content = firstNonEmptyString(item.text, item.content);
|
||||
return {
|
||||
id: item.id,
|
||||
role,
|
||||
type,
|
||||
title: item.name || type,
|
||||
content,
|
||||
threadId: fallbackThreadId,
|
||||
actionId: item.action_id,
|
||||
stepId: item.step_id,
|
||||
toolCallId: item.tool_call_id,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
createdAt: item.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
function latestThreadId(messages: AgentMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const threadId = messages[index]?.threadId?.trim();
|
||||
if (threadId) return threadId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function projectFromQueryProjectResponse(response: QueryProjectResponse, requestedProjectId: string) {
|
||||
if (response.code !== 0 || !response.data.validProjectId) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = await decodeCanvasSnapshot(response.data.canvas).catch(() => null);
|
||||
if (!snapshot?.viewport || !snapshot.nodes) {
|
||||
return null;
|
||||
}
|
||||
const meta = response.data.assetMeta;
|
||||
const projectId = meta.projectId || meta.assetId || response.data.projectId || snapshot.projectId || requestedProjectId;
|
||||
const title = response.data.projectName || meta.name || "Untitled";
|
||||
const covers = meta.extra?.projectCoverList ?? [];
|
||||
const nodes = normalizeCanvasNodes(snapshot.nodes);
|
||||
const viewport = normalizeCanvasViewport(snapshot.viewport);
|
||||
return {
|
||||
id: projectId,
|
||||
userId: String(response.data.userId || ""),
|
||||
title,
|
||||
brief: "",
|
||||
status: "ready",
|
||||
thumbnail: covers[0] || "",
|
||||
updatedAt: snapshot.updatedAt || meta.updateTime || "",
|
||||
version: response.data.version || snapshot.version || "",
|
||||
snapshotId: response.data.snapshotId || snapshot.snapshotId || "",
|
||||
canvas: response.data.canvas,
|
||||
lastThreadId: "",
|
||||
viewport,
|
||||
nodes,
|
||||
connections: normalizeCanvasConnections(snapshot.connections, nodes),
|
||||
messages: []
|
||||
} satisfies Project;
|
||||
}
|
||||
|
||||
function normalizeCanvasViewport(viewport: Partial<CanvasViewport> | null | undefined): CanvasViewport {
|
||||
const k = numberValue(fieldValue(viewport, "k", "K"), 1);
|
||||
return {
|
||||
x: numberValue(fieldValue(viewport, "x", "X"), 0),
|
||||
y: numberValue(fieldValue(viewport, "y", "Y"), 0),
|
||||
k: k > 0 ? k : 1
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCanvasNodes(nodes: QueryProjectCanvasSnapshot["nodes"]) {
|
||||
const seenIds = new Set<string>();
|
||||
return (nodes ?? []).map((node, index) => normalizeCanvasNode(node, index, seenIds));
|
||||
}
|
||||
|
||||
function normalizeCanvasNode(node: Partial<CanvasNode> | null | undefined, index: number, seenIds: Set<string>): CanvasNode {
|
||||
const source = node ?? {};
|
||||
let id = stringValue(fieldValue(source, "id", "ID"));
|
||||
if (!id || seenIds.has(id)) {
|
||||
id = `node-${index}-${createClientId()}`;
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
return {
|
||||
...source,
|
||||
id,
|
||||
type: canvasNodeType(fieldValue(source, "type", "Type")),
|
||||
title: stringValue(fieldValue(source, "title", "Title")),
|
||||
x: numberValue(fieldValue(source, "x", "X"), 0),
|
||||
y: numberValue(fieldValue(source, "y", "Y"), 0),
|
||||
width: numberValue(fieldValue(source, "width", "Width"), 320),
|
||||
height: numberValue(fieldValue(source, "height", "Height"), 240),
|
||||
content: stringValue(fieldValue(source, "content", "Content")),
|
||||
tone: stringValue(fieldValue(source, "tone", "Tone")),
|
||||
status: stringValue(fieldValue(source, "status", "Status")) || "success",
|
||||
parentId: stringValue(fieldValue(source, "parentId", "ParentID")) || undefined,
|
||||
layerRole: stringValue(fieldValue(source, "layerRole", "LayerRole")) || undefined,
|
||||
fontSize: optionalNumberValue(fieldValue(source, "fontSize", "FontSize")),
|
||||
fontFamily: stringValue(fieldValue(source, "fontFamily", "FontFamily")) || undefined,
|
||||
fontWeight: stringValue(fieldValue(source, "fontWeight", "FontWeight")) || undefined,
|
||||
color: stringValue(fieldValue(source, "color", "Color")) || undefined,
|
||||
textAlign: canvasTextAlign(fieldValue(source, "textAlign", "TextAlign")),
|
||||
lineHeight: optionalNumberValue(fieldValue(source, "lineHeight", "LineHeight")),
|
||||
letterSpacing: optionalNumberValue(fieldValue(source, "letterSpacing", "LetterSpacing")),
|
||||
textDecoration: stringValue(fieldValue(source, "textDecoration", "TextDecoration")) || undefined,
|
||||
textTransform: stringValue(fieldValue(source, "textTransform", "TextTransform")) || undefined,
|
||||
opacity: optionalNumberValue(fieldValue(source, "opacity", "Opacity")),
|
||||
fillColor: stringValue(fieldValue(source, "fillColor", "FillColor")) || undefined,
|
||||
strokeColor: stringValue(fieldValue(source, "strokeColor", "StrokeColor")) || undefined,
|
||||
strokeWidth: optionalNumberValue(fieldValue(source, "strokeWidth", "StrokeWidth")),
|
||||
strokeStyle: stringValue(fieldValue(source, "strokeStyle", "StrokeStyle")) || undefined,
|
||||
flipX: Boolean(fieldValue(source, "flipX", "FlipX")),
|
||||
flipY: Boolean(fieldValue(source, "flipY", "FlipY")),
|
||||
rotation: optionalNumberValue(fieldValue(source, "rotation", "Rotation")),
|
||||
imageAdjustments: stringValue(fieldValue(source, "imageAdjustments", "ImageAdjustments")) || undefined,
|
||||
mockupSurface: (fieldValue(source, "mockupSurface", "MockupSurface") as CanvasNode["mockupSurface"]) || undefined,
|
||||
mockupModel: (fieldValue(source, "mockupModel", "MockupModel") as CanvasNode["mockupModel"]) || undefined,
|
||||
mockupSourceContent: stringValue(fieldValue(source, "mockupSourceContent", "MockupSourceContent")) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCanvasConnections(connections: QueryProjectCanvasSnapshot["connections"], nodes: CanvasNode[]) {
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const seenIds = new Set<string>();
|
||||
return (connections ?? []).flatMap((connection, index) => {
|
||||
const source = connection ?? {};
|
||||
const fromNodeId = stringValue(fieldValue(source, "fromNodeId", "FromNodeID"));
|
||||
const toNodeId = stringValue(fieldValue(source, "toNodeId", "ToNodeID"));
|
||||
if (!fromNodeId || !toNodeId || !nodeIds.has(fromNodeId) || !nodeIds.has(toNodeId)) return [];
|
||||
let id = stringValue(fieldValue(source, "id", "ID"));
|
||||
if (!id || seenIds.has(id)) {
|
||||
id = `connection-${index}-${fromNodeId}-${toNodeId}`;
|
||||
}
|
||||
seenIds.add(id);
|
||||
return [{ id, fromNodeId, toNodeId }];
|
||||
});
|
||||
}
|
||||
|
||||
function canvasNodeType(value: unknown): CanvasNode["type"] {
|
||||
return value === "brief" || value === "reference" || value === "image" || value === "text" || value === "frame" ? value : "image";
|
||||
}
|
||||
|
||||
function canvasTextAlign(value: unknown): CanvasNode["textAlign"] | undefined {
|
||||
return value === "left" || value === "center" || value === "right" ? value : undefined;
|
||||
}
|
||||
|
||||
function fieldValue(source: unknown, ...keys: string[]) {
|
||||
if (!source || typeof source !== "object") return undefined;
|
||||
const record = source as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
if (record[key] !== undefined) return record[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number) {
|
||||
const next = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||||
return Number.isFinite(next) ? next : fallback;
|
||||
}
|
||||
|
||||
function optionalNumberValue(value: unknown) {
|
||||
const next = numberValue(value, Number.NaN);
|
||||
return Number.isFinite(next) ? next : undefined;
|
||||
}
|
||||
|
||||
async function encodeCanvasSnapshot({
|
||||
projectId,
|
||||
version,
|
||||
snapshotId,
|
||||
updatedAt,
|
||||
viewport,
|
||||
nodes,
|
||||
connections
|
||||
}: {
|
||||
projectId: string;
|
||||
version: string;
|
||||
snapshotId: string;
|
||||
updatedAt: string;
|
||||
viewport: CanvasViewport;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
}) {
|
||||
if (typeof CompressionStream === "undefined" || typeof Blob === "undefined" || typeof Response === "undefined" || typeof btoa === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
projectId,
|
||||
version,
|
||||
snapshotId,
|
||||
viewport,
|
||||
nodes,
|
||||
connections,
|
||||
updatedAt
|
||||
});
|
||||
const stream = new Blob([payload], { type: "application/json" }).stream().pipeThrough(new CompressionStream("gzip"));
|
||||
const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
return `${canvasDataPrefix}${bytesToBase64(compressed)}`;
|
||||
}
|
||||
|
||||
async function decodeCanvasSnapshot(canvas: string): Promise<QueryProjectCanvasSnapshot | null> {
|
||||
if (
|
||||
!canvas.startsWith(canvasDataPrefix) ||
|
||||
typeof DecompressionStream === "undefined" ||
|
||||
typeof Blob === "undefined" ||
|
||||
typeof Response === "undefined" ||
|
||||
typeof atob === "undefined"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const compressed = base64ToBytes(canvas.slice(canvasDataPrefix.length));
|
||||
const stream = new Blob([compressed], { type: "application/gzip" }).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||
return (await new Response(stream).json()) as QueryProjectCanvasSnapshot;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
const chunkSize = 0x8000;
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function createCanvasSnapshotDraft(nodes: CanvasNode[]): CanvasSnapshotDraft {
|
||||
return {
|
||||
snapshotId: createClientId(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
projectCoverList: projectCoverListFromNodes(nodes)
|
||||
};
|
||||
}
|
||||
|
||||
function projectCoverListFromNodes(nodes: CanvasNode[]) {
|
||||
return nodes
|
||||
.filter((node) => (node.type === "image" || node.type === "frame") && node.status === "success" && isCanvasCoverUrl(node.content))
|
||||
.map((node) => node.content.trim())
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function isCanvasCoverUrl(value: string) {
|
||||
const url = value.trim();
|
||||
return Boolean(url) && !url.startsWith("data:") && !url.startsWith("blob:");
|
||||
}
|
||||
|
||||
function getCanvasSaveSessionId() {
|
||||
if (typeof window === "undefined") return createClientId();
|
||||
try {
|
||||
const current = window.sessionStorage.getItem(canvasSaveSessionStorageKey);
|
||||
if (current) return current;
|
||||
const next = createClientId();
|
||||
window.sessionStorage.setItem(canvasSaveSessionStorageKey, next);
|
||||
return next;
|
||||
} catch {
|
||||
return createClientId();
|
||||
}
|
||||
}
|
||||
|
||||
function createClientId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function createQueryProjectCid() {
|
||||
return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function mergeSavedCanvasProject(
|
||||
projectId: string,
|
||||
viewport: CanvasViewport,
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
current: CanvasSaveProject | undefined,
|
||||
draft: CanvasSnapshotDraft,
|
||||
canvas: string,
|
||||
response: SaveProjectResponse
|
||||
): Project {
|
||||
return {
|
||||
id: current?.id ?? response.data.projectId ?? projectId,
|
||||
userId: current?.userId,
|
||||
title: current?.title || "Untitled",
|
||||
brief: current?.brief ?? "",
|
||||
status: current?.status || "ready",
|
||||
thumbnail: current?.thumbnail || draft.projectCoverList[0] || "",
|
||||
updatedAt: draft.updatedAt,
|
||||
version: response.data.version || current?.version || "",
|
||||
snapshotId: draft.snapshotId,
|
||||
canvas: canvas || current?.canvas || "",
|
||||
lastThreadId: current?.lastThreadId ?? "",
|
||||
viewport,
|
||||
nodes,
|
||||
connections,
|
||||
messages: current?.messages ?? []
|
||||
};
|
||||
}
|
||||
|
||||
function projectEventsUrl(projectId: string, options: ProjectEventsOptions) {
|
||||
if (options.eventsUrl) return options.eventsUrl;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -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