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 type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||||
import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding";
|
|
||||||
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
||||||
|
|
||||||
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
||||||
@@ -116,7 +115,6 @@ export const authGateway = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async uploadAvatar(file: File) {
|
async uploadAvatar(file: File) {
|
||||||
const uploadFile = await encodeUploadImage(file);
|
|
||||||
const upload = await apiRequest<{
|
const upload = await apiRequest<{
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
@@ -124,9 +122,9 @@ export const authGateway = {
|
|||||||
}>("/api/assets/upload-url", {
|
}>("/api/assets/upload-url", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fileName: uploadFile.name,
|
fileName: file.name,
|
||||||
contentType: uploadFile.type || file.type || "application/octet-stream",
|
contentType: file.type || "application/octet-stream",
|
||||||
size: uploadFile.size
|
size: file.size
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +132,7 @@ export const authGateway = {
|
|||||||
await fetch(upload.uploadUrl, {
|
await fetch(upload.uploadUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: upload.headers,
|
headers: upload.headers,
|
||||||
body: uploadFile
|
body: file
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return upload.publicUrl;
|
return upload.publicUrl;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import type {
|
|||||||
} from "@/domain/design";
|
} from "@/domain/design";
|
||||||
import { authHeaders } from "@/infrastructure/authGateway";
|
import { authHeaders } from "@/infrastructure/authGateway";
|
||||||
import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages";
|
import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages";
|
||||||
import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding";
|
|
||||||
|
|
||||||
type ImageGenerationOptions = {
|
type ImageGenerationOptions = {
|
||||||
imageModel?: string;
|
imageModel?: string;
|
||||||
@@ -45,6 +44,108 @@ type ProjectEventsOptions = {
|
|||||||
eventsUrl?: string;
|
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) {
|
function isVersionConflictError(error: unknown) {
|
||||||
return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409;
|
return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409;
|
||||||
}
|
}
|
||||||
@@ -99,8 +200,18 @@ export const designGateway = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getProject(id: string) {
|
async getProject(id: string) {
|
||||||
return request<{ project: Project }>(`/api/projects/${id}`);
|
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) {
|
updateProjectTitle(id: string, title: string) {
|
||||||
@@ -130,12 +241,49 @@ export const designGateway = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectHistory(id: string) {
|
async getProjectHistory(id: string, threadId?: string) {
|
||||||
return request<Pick<Project, "messages"> & { projectId: string }>(`/api/projects/${id}/history`);
|
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) {
|
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`);
|
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) {
|
generate(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) {
|
||||||
@@ -289,16 +437,45 @@ export const designGateway = {
|
|||||||
viewport: CanvasViewport,
|
viewport: CanvasViewport,
|
||||||
nodes: CanvasNode[],
|
nodes: CanvasNode[],
|
||||||
connections: CanvasConnection[],
|
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 () => {
|
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) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
return await save();
|
return await save();
|
||||||
@@ -313,7 +490,6 @@ export const designGateway = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async uploadImage(file: File) {
|
async uploadImage(file: File) {
|
||||||
const uploadFile = await encodeUploadImage(file);
|
|
||||||
const upload = await request<{
|
const upload = await request<{
|
||||||
driver: string;
|
driver: string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
@@ -322,9 +498,9 @@ export const designGateway = {
|
|||||||
}>("/api/assets/upload-url", {
|
}>("/api/assets/upload-url", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fileName: uploadFile.name,
|
fileName: file.name,
|
||||||
contentType: uploadFile.type || file.type || "application/octet-stream",
|
contentType: file.type || "application/octet-stream",
|
||||||
size: uploadFile.size
|
size: file.size
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,13 +508,357 @@ export const designGateway = {
|
|||||||
await fetch(upload.uploadUrl, {
|
await fetch(upload.uploadUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: upload.headers,
|
headers: upload.headers,
|
||||||
body: uploadFile
|
body: file
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return upload.publicUrl;
|
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) {
|
function projectEventsUrl(projectId: string, options: ProjectEventsOptions) {
|
||||||
if (options.eventsUrl) return options.eventsUrl;
|
if (options.eventsUrl) return options.eventsUrl;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"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 type { CanvasNode, CanvasViewport, Project } from "@/domain/design";
|
||||||
import { designGateway } from "@/infrastructure/designGateway";
|
import { designGateway } from "@/infrastructure/designGateway";
|
||||||
import { useUserMessage } from "@/ui/hooks/useUserMessage";
|
import { useUserMessage } from "@/ui/hooks/useUserMessage";
|
||||||
|
|
||||||
|
const canvasSaveDebounceMs = 1200;
|
||||||
|
|
||||||
export function useCanvasPersistence({
|
export function useCanvasPersistence({
|
||||||
project,
|
project,
|
||||||
projectRef,
|
projectRef,
|
||||||
@@ -38,10 +40,18 @@ export function useCanvasPersistence({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const toUserMessageText = useUserMessage();
|
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(
|
const saveCanvasSnapshot = useCallback(
|
||||||
async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||||
const currentProject = projectRef.current;
|
const currentProject = projectRef.current;
|
||||||
if (!currentProject) return null;
|
if (!currentProject) return null;
|
||||||
|
clearScheduledSave();
|
||||||
if (!quiet) setSaving(true);
|
if (!quiet) setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -50,11 +60,7 @@ export function useCanvasPersistence({
|
|||||||
viewportRef.current,
|
viewportRef.current,
|
||||||
nodesRef.current,
|
nodesRef.current,
|
||||||
[],
|
[],
|
||||||
{
|
currentProject
|
||||||
version: currentProject.version,
|
|
||||||
snapshotId: currentProject.snapshotId,
|
|
||||||
canvas: ""
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
projectRef.current = savedProject;
|
projectRef.current = savedProject;
|
||||||
setProject(savedProject);
|
setProject(savedProject);
|
||||||
@@ -67,7 +73,22 @@ export function useCanvasPersistence({
|
|||||||
if (!quiet) setSaving(false);
|
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(
|
const updateProjectTitle = useCallback(
|
||||||
@@ -91,15 +112,18 @@ export function useCanvasPersistence({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project || !dirty || anyGenerating) return;
|
if (!project || !dirty || anyGenerating) {
|
||||||
const timer = window.setTimeout(() => {
|
clearScheduledSave();
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
return;
|
||||||
}, 900);
|
}
|
||||||
return () => window.clearTimeout(timer);
|
scheduleCanvasSave();
|
||||||
}, [anyGenerating, dirty, nodes, project, saveCanvasSnapshot, viewport]);
|
}, [anyGenerating, clearScheduledSave, dirty, nodes, project, scheduleCanvasSave, viewport]);
|
||||||
|
|
||||||
|
useEffect(() => clearScheduledSave, [clearScheduledSave]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
saveCanvasSnapshot,
|
saveCanvasSnapshot,
|
||||||
|
scheduleCanvasSave,
|
||||||
updateProjectTitle
|
updateProjectTitle
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
}, [projectRef]);
|
}, [projectRef]);
|
||||||
|
|
||||||
const { saveCanvasSnapshot, updateProjectTitle } = useCanvasPersistence({
|
const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({
|
||||||
project,
|
project,
|
||||||
projectRef,
|
projectRef,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -859,7 +859,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
if (history && !agentStreamingProjectIdRef.current) {
|
if (history && !agentStreamingProjectIdRef.current) {
|
||||||
const currentProject = projectRef.current;
|
const currentProject = projectRef.current;
|
||||||
if (currentProject) {
|
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 {
|
} else {
|
||||||
setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current));
|
setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current));
|
||||||
}
|
}
|
||||||
@@ -1200,7 +1207,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
window.removeEventListener("pointerup", handleUp);
|
window.removeEventListener("pointerup", handleUp);
|
||||||
window.removeEventListener("pointercancel", handleUp);
|
window.removeEventListener("pointercancel", handleUp);
|
||||||
setCanvasPanning(false);
|
setCanvasPanning(false);
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handleMove);
|
window.addEventListener("pointermove", handleMove);
|
||||||
@@ -1627,7 +1634,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
void refreshMockupPrintTextures(Array.from(dragIds));
|
void refreshMockupPrintTextures(Array.from(dragIds));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handleMove);
|
window.addEventListener("pointermove", handleMove);
|
||||||
@@ -1686,7 +1693,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
void refreshMockupPrintTextures([node.id]);
|
void refreshMockupPrintTextures([node.id]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handleMove);
|
window.addEventListener("pointermove", handleMove);
|
||||||
@@ -1724,7 +1731,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
window.removeEventListener("pointermove", handleMove);
|
window.removeEventListener("pointermove", handleMove);
|
||||||
window.removeEventListener("pointerup", handleUp);
|
window.removeEventListener("pointerup", handleUp);
|
||||||
setAlignmentGuides([]);
|
setAlignmentGuides([]);
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handleMove);
|
window.addEventListener("pointermove", handleMove);
|
||||||
@@ -1751,7 +1758,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
window.removeEventListener("pointermove", handleMove);
|
window.removeEventListener("pointermove", handleMove);
|
||||||
window.removeEventListener("pointerup", handleUp);
|
window.removeEventListener("pointerup", handleUp);
|
||||||
setAlignmentGuides([]);
|
setAlignmentGuides([]);
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handleMove);
|
window.addEventListener("pointermove", handleMove);
|
||||||
@@ -1804,7 +1811,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
window.removeEventListener("pointermove", handleMove);
|
window.removeEventListener("pointermove", handleMove);
|
||||||
window.removeEventListener("pointerup", handleUp);
|
window.removeEventListener("pointerup", handleUp);
|
||||||
setAlignmentGuides([]);
|
setAlignmentGuides([]);
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handleMove);
|
window.addEventListener("pointermove", handleMove);
|
||||||
@@ -2517,6 +2524,22 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
const currentProject = projectRef.current;
|
const currentProject = projectRef.current;
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId));
|
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);
|
setMockupSurface(null);
|
||||||
setMockupModel(null);
|
setMockupModel(null);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshMockupPrintTextures = async (nodeIds: string[]) => {
|
const refreshMockupPrintTextures = async (nodeIds: string[]) => {
|
||||||
@@ -2860,13 +2883,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nodesRef.current = nextNodes;
|
nodesRef.current = nextNodes;
|
||||||
setNodes(nextNodes);
|
setNodes(nextNodes);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
void saveCanvasSnapshot({ quiet: true });
|
scheduleCanvasSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreQuickEditViewport = useCallback(() => {
|
const restoreQuickEditViewport = useCallback(() => {
|
||||||
@@ -3131,11 +3154,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
setCropSelection(null);
|
setCropSelection(null);
|
||||||
setCropPrompt("");
|
setCropPrompt("");
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], {
|
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], currentProject);
|
||||||
version: currentProject.version,
|
|
||||||
snapshotId: currentProject.snapshotId,
|
|
||||||
canvas: currentProject.canvas
|
|
||||||
});
|
|
||||||
applyProject(savedProject);
|
applyProject(savedProject);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3211,7 +3230,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
for (const [index, file] of imageFiles.entries()) {
|
for (const [index, file] of imageFiles.entries()) {
|
||||||
const previewUrl = URL.createObjectURL(file);
|
const previewUrl = URL.createObjectURL(file);
|
||||||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
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";
|
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
|
||||||
preparedUploads.push({
|
preparedUploads.push({
|
||||||
file,
|
file,
|
||||||
@@ -3269,11 +3288,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
setError(t("generateError"));
|
setError(t("generateError"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], {
|
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
|
||||||
version: currentProject.version,
|
|
||||||
snapshotId: currentProject.snapshotId,
|
|
||||||
canvas: ""
|
|
||||||
});
|
|
||||||
applyProject(savedProject);
|
applyProject(savedProject);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess"));
|
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess"));
|
||||||
@@ -3754,7 +3769,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
setViewport(nextViewport);
|
setViewport(nextViewport);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}}
|
}}
|
||||||
onViewportCommit={() => void saveCanvasSnapshot({ quiet: true })}
|
onViewportCommit={scheduleCanvasSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="canvas-statusbar">
|
<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) {
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
return { width: 420, height: 420 };
|
return { width: 512, height: 512 };
|
||||||
}
|
|
||||||
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 {
|
return {
|
||||||
width: Math.round(width * scale),
|
width: Math.round(width),
|
||||||
height: Math.round(height * scale)
|
height: Math.round(height)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-4
@@ -177,6 +177,17 @@ Returns all project summaries, sorted by `updatedAt` descending.
|
|||||||
|
|
||||||
Returns the full project, including viewport, nodes, connections, and agent messages.
|
Returns the full project, including viewport, nodes, connections, and agent messages.
|
||||||
|
|
||||||
|
`POST /api/canva/project/queryProject`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "project-id",
|
||||||
|
"cid": "1781596271730ngdozsck"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a Lovart-style project document with `assetMeta`, compressed `canvas`, `validProjectId`, `projectType`, `version`, and permission metadata. The frontend uses this for lightweight project opening, then decodes the `SHAKKERDATA://` canvas locally.
|
||||||
|
|
||||||
`PATCH /api/projects/:id/title`
|
`PATCH /api/projects/:id/title`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -240,19 +251,57 @@ Returns replayable assistant/user chat history for the project.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Lovart-style agent thread endpoints are also available:
|
||||||
|
|
||||||
|
`POST /api/canva/agent/queryAgentLastThread`
|
||||||
|
|
||||||
|
Returns `{ "code": 0, "msg": null, "data": "<thread-id>" }`.
|
||||||
|
|
||||||
|
`POST /api/canva/agent/agentThreadList`
|
||||||
|
|
||||||
|
Accepts `{ "projectId": "project-id", "page": 1, "pageSize": 100, "cid": "..." }` and returns paginated thread summaries.
|
||||||
|
|
||||||
|
`GET /api/canda/chat-history?thread_id=<thread-id>&project_id=<project-id>`
|
||||||
|
|
||||||
|
Returns Lovart-style thread history items grouped by `thread_meta`.
|
||||||
|
|
||||||
|
`GET /api/canda/thread/status?thread_id=<thread-id>&project_id=<project-id>`
|
||||||
|
|
||||||
|
Returns `{ "code": 0, "msg": "success", "data": { "status": "done" } }` or the current project thread status when `project_id` is present.
|
||||||
|
|
||||||
## Save Canvas
|
## Save Canvas
|
||||||
|
|
||||||
|
`POST /api/canva/project/saveProject`
|
||||||
|
|
||||||
`PATCH /api/projects/:id/canvas`
|
`PATCH /api/projects/:id/canvas`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"viewport": { "x": 420, "y": 260, "k": 0.72 },
|
"canvas": "SHAKKERDATA://<gzip-base64-canvas-snapshot>",
|
||||||
"nodes": [],
|
"projectCoverList": ["https://cdn.example.com/canvas/image.png"],
|
||||||
"connections": []
|
"picCount": 1,
|
||||||
|
"projectName": "Untitled",
|
||||||
|
"projectId": "project-id",
|
||||||
|
"version": "1783472149000",
|
||||||
|
"sessionId": "browser-session-id",
|
||||||
|
"canvasV2Gray": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Saves canvas layout changes. The frontend uses this after dragging or zooming.
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": null,
|
||||||
|
"data": {
|
||||||
|
"projectId": "project-id",
|
||||||
|
"newCreated": false,
|
||||||
|
"validProjectId": true,
|
||||||
|
"version": "1783498482000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Saves canvas layout changes with a Lovart-style lightweight payload. The frontend posts to `/api/canva/project/saveProject`; `/api/projects/:id/canvas` remains as a compatibility route. The `canvas` value is a `SHAKKERDATA://` gzip+base64 encoded snapshot containing viewport, nodes, and connections. Legacy uncompressed `{ "viewport": ..., "nodes": ..., "connections": ... }` payloads are still accepted.
|
||||||
|
|
||||||
When a canvas image node disappears from the saved node list, its backing OSS asset is queued for asynchronous deletion after the canvas save succeeds.
|
When a canvas image node disappears from the saved node list, its backing OSS asset is queued for asynchronous deletion after the canvas save succeeds.
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ curl -X PATCH http://localhost:8888/api/projects/<project-id>/title \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"title":"夏季活动主视觉"}'
|
-d '{"title":"夏季活动主视觉"}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8888/api/canva/project/saveProject \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"canvas":"SHAKKERDATA://<gzip-base64-canvas-snapshot>","projectCoverList":[],"picCount":0,"projectName":"Untitled","projectId":"<project-id>","version":"1783472149000","sessionId":"browser-session-id","canvasV2Gray":true}'
|
||||||
|
|
||||||
curl -X POST http://localhost:8888/api/assets/upload-url \
|
curl -X POST http://localhost:8888/api/assets/upload-url \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"fileName":"ref.png","contentType":"image/png"}'
|
-d '{"fileName":"ref.png","contentType":"image/png"}'
|
||||||
|
|||||||
@@ -320,6 +320,11 @@ type ProjectIdRequest {
|
|||||||
Id string `path:"id"`
|
Id string `path:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueryProjectRequest {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Cid string `json:"cid,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateProjectRequest {
|
type CreateProjectRequest {
|
||||||
Title string `json:"title,optional"`
|
Title string `json:"title,optional"`
|
||||||
Prompt string `json:"prompt,optional"`
|
Prompt string `json:"prompt,optional"`
|
||||||
@@ -338,6 +343,53 @@ type ProjectResponse {
|
|||||||
Project Project `json:"project"`
|
Project Project `json:"project"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueryProjectExtra {
|
||||||
|
PicCount int64 `json:"picCount"`
|
||||||
|
ProjectCoverList []string `json:"projectCoverList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectMeta {
|
||||||
|
UniqId string `json:"uniqId"`
|
||||||
|
ParentUniqId string `json:"parentUniqId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectAssetMeta {
|
||||||
|
AssetId string `json:"assetId"`
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProjectType int64 `json:"projectType"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
OwnerType string `json:"ownerType"`
|
||||||
|
OwnerId string `json:"ownerId"`
|
||||||
|
Extra QueryProjectExtra `json:"extra"`
|
||||||
|
Meta QueryProjectMeta `json:"meta"`
|
||||||
|
EffectivePermission string `json:"effectivePermission"`
|
||||||
|
CopyTaskStatus *string `json:"copyTaskStatus"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
CreateTime string `json:"createTime"`
|
||||||
|
UpdateTime string `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectData {
|
||||||
|
AssetMeta QueryProjectAssetMeta `json:"assetMeta"`
|
||||||
|
UserId string `json:"userId"`
|
||||||
|
ProjectName string `json:"projectName"`
|
||||||
|
ProjectId *string `json:"projectId"`
|
||||||
|
Canvas string `json:"canvas"`
|
||||||
|
ValidProjectId bool `json:"validProjectId"`
|
||||||
|
ProjectType int64 `json:"projectType"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SnapshotId *string `json:"snapshotId"`
|
||||||
|
ItemId *string `json:"itemId"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectResponse {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data QueryProjectData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteProjectResponse {
|
type DeleteProjectResponse {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
@@ -356,6 +408,105 @@ type ChatHistoryResponse {
|
|||||||
Messages []AgentMessage `json:"messages"`
|
Messages []AgentMessage `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueryAgentLastThreadRequest {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Cid string `json:"cid,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryAgentLastThreadResponse {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentThreadListRequest {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Page int64 `json:"page,optional"`
|
||||||
|
PageSize int64 `json:"pageSize,optional"`
|
||||||
|
Cid string `json:"cid,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LovartAgentThreadSummary {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
UserId string `json:"userId"`
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
AgentThreadId string `json:"agentThreadId"`
|
||||||
|
ThreadIdType int64 `json:"threadIdType"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CoverImageUrl *string `json:"coverImageUrl"`
|
||||||
|
FileType *string `json:"fileType"`
|
||||||
|
UpdateTimestamp int64 `json:"updateTimestamp"`
|
||||||
|
Source int64 `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LovartAgentThreadListData {
|
||||||
|
Page int64 `json:"page"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
PageSize int64 `json:"pageSize"`
|
||||||
|
Data []LovartAgentThreadSummary `json:"data"`
|
||||||
|
UserIp string `json:"userIp"`
|
||||||
|
TraceId string `json:"traceId"`
|
||||||
|
HasMore bool `json:"hasMore"`
|
||||||
|
RankExpId *string `json:"rankExpId"`
|
||||||
|
AbVariant *string `json:"abVariant"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LovartAgentThreadListResponse {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data LovartAgentThreadListData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaChatHistoryRequest {
|
||||||
|
ThreadId string `form:"thread_id"`
|
||||||
|
ProjectId string `form:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadMeta {
|
||||||
|
ActionId string `json:"action_id"`
|
||||||
|
SessionId string `json:"session_id"`
|
||||||
|
StepId string `json:"step_id"`
|
||||||
|
Depth int64 `json:"depth"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
ProjectId string `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaChatHistoryItem {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ActionId string `json:"action_id"`
|
||||||
|
StepId string `json:"step_id"`
|
||||||
|
Text string `json:"text,optional"`
|
||||||
|
Content string `json:"content,optional"`
|
||||||
|
Status string `json:"status,optional"`
|
||||||
|
ToolCallId string `json:"tool_call_id,optional"`
|
||||||
|
Name string `json:"name,optional"`
|
||||||
|
ThreadMeta CandaThreadMeta `json:"thread_meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaChatHistoryResponse {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []CandaChatHistoryItem `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadStatusRequest {
|
||||||
|
ThreadId string `form:"thread_id"`
|
||||||
|
ProjectId string `form:"project_id,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadStatusData {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadStatusResponse {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data CandaThreadStatusData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type GenerateRequest {
|
type GenerateRequest {
|
||||||
Id string `path:"id"`
|
Id string `path:"id"`
|
||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
@@ -642,9 +793,43 @@ type SaveCanvasRequest {
|
|||||||
Version string `json:"version,optional"`
|
Version string `json:"version,optional"`
|
||||||
SnapshotId string `json:"snapshotId,optional"`
|
SnapshotId string `json:"snapshotId,optional"`
|
||||||
Canvas string `json:"canvas,optional"`
|
Canvas string `json:"canvas,optional"`
|
||||||
Viewport CanvasViewport `json:"viewport"`
|
Viewport CanvasViewport `json:"viewport,optional"`
|
||||||
Nodes []CanvasNode `json:"nodes"`
|
Nodes []CanvasNode `json:"nodes,optional"`
|
||||||
Connections []CanvasConnection `json:"connections"`
|
Connections []CanvasConnection `json:"connections,optional"`
|
||||||
|
ProjectCoverList []string `json:"projectCoverList,optional"`
|
||||||
|
PicCount int64 `json:"picCount,optional"`
|
||||||
|
ProjectName string `json:"projectName,optional"`
|
||||||
|
ProjectId string `json:"projectId,optional"`
|
||||||
|
SessionId string `json:"sessionId,optional"`
|
||||||
|
CanvasV2Gray bool `json:"canvasV2Gray,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveProjectRequest {
|
||||||
|
Canvas string `json:"canvas,optional"`
|
||||||
|
ProjectCoverList []string `json:"projectCoverList,optional"`
|
||||||
|
PicCount int64 `json:"picCount,optional"`
|
||||||
|
ProjectName string `json:"projectName,optional"`
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Version string `json:"version,optional"`
|
||||||
|
SessionId string `json:"sessionId,optional"`
|
||||||
|
CanvasV2Gray bool `json:"canvasV2Gray,optional"`
|
||||||
|
SnapshotId string `json:"snapshotId,optional"`
|
||||||
|
Viewport CanvasViewport `json:"viewport,optional"`
|
||||||
|
Nodes []CanvasNode `json:"nodes,optional"`
|
||||||
|
Connections []CanvasConnection `json:"connections,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveProjectData {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
NewCreated bool `json:"newCreated"`
|
||||||
|
ValidProjectId bool `json:"validProjectId"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveProjectResponse {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data SaveProjectData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAssetUploadRequest {
|
type CreateAssetUploadRequest {
|
||||||
@@ -732,6 +917,24 @@ service img_infinite_canvas-api {
|
|||||||
@handler createProjectAsync
|
@handler createProjectAsync
|
||||||
post /projects/async (CreateProjectRequest) returns (ProjectResponse)
|
post /projects/async (CreateProjectRequest) returns (ProjectResponse)
|
||||||
|
|
||||||
|
@handler queryProject
|
||||||
|
post /canva/project/queryProject (QueryProjectRequest) returns (QueryProjectResponse)
|
||||||
|
|
||||||
|
@handler saveProject
|
||||||
|
post /canva/project/saveProject (SaveProjectRequest) returns (SaveProjectResponse)
|
||||||
|
|
||||||
|
@handler queryAgentLastThread
|
||||||
|
post /canva/agent/queryAgentLastThread (QueryAgentLastThreadRequest) returns (QueryAgentLastThreadResponse)
|
||||||
|
|
||||||
|
@handler agentThreadList
|
||||||
|
post /canva/agent/agentThreadList (AgentThreadListRequest) returns (LovartAgentThreadListResponse)
|
||||||
|
|
||||||
|
@handler candaChatHistory
|
||||||
|
get /canda/chat-history (CandaChatHistoryRequest) returns (CandaChatHistoryResponse)
|
||||||
|
|
||||||
|
@handler candaThreadStatus
|
||||||
|
get /canda/thread/status (CandaThreadStatusRequest) returns (CandaThreadStatusResponse)
|
||||||
|
|
||||||
@handler deleteProjects
|
@handler deleteProjects
|
||||||
delete /projects/batch-delete (DeleteProjectsRequest) returns (DeleteProjectsResponse)
|
delete /projects/batch-delete (DeleteProjectsRequest) returns (DeleteProjectsResponse)
|
||||||
|
|
||||||
@@ -802,7 +1005,7 @@ service img_infinite_canvas-api {
|
|||||||
post /projects/:id/layers/merge (MergeLayersRequest) returns (ProjectResponse)
|
post /projects/:id/layers/merge (MergeLayersRequest) returns (ProjectResponse)
|
||||||
|
|
||||||
@handler saveCanvas
|
@handler saveCanvas
|
||||||
patch /projects/:id/canvas (SaveCanvasRequest) returns (ProjectResponse)
|
patch /projects/:id/canvas (SaveCanvasRequest) returns (SaveProjectResponse)
|
||||||
|
|
||||||
@handler createAssetUpload
|
@handler createAssetUpload
|
||||||
post /assets/upload-url (CreateAssetUploadRequest) returns (CreateAssetUploadResponse)
|
post /assets/upload-url (CreateAssetUploadRequest) returns (CreateAssetUploadResponse)
|
||||||
|
|||||||
@@ -122,6 +122,66 @@ func TestSaveCanvasQueuesDeletedImageAsset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveCanvasAcceptsCompressedSnapshotPayload(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := memory.NewProjectRepository()
|
||||||
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
||||||
|
service.now = func() time.Time {
|
||||||
|
return time.Unix(101, 0).UTC()
|
||||||
|
}
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-compressed-save",
|
||||||
|
Title: "Compressed save",
|
||||||
|
Brief: "brief",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
UpdatedAt: time.Unix(100, 0).UTC(),
|
||||||
|
Version: "100000",
|
||||||
|
SnapshotID: "snapshot-old",
|
||||||
|
Viewport: design.Viewport{X: 1, Y: 2, K: 1},
|
||||||
|
Nodes: []design.Node{
|
||||||
|
{ID: "old-image", Type: design.NodeTypeImage, Title: "Old", Content: "http://localhost:19000/canvas-assets/old.png", Status: "success"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.Save(ctx, project); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshotProject := project
|
||||||
|
snapshotProject.Version = project.Version
|
||||||
|
snapshotProject.SnapshotID = "snapshot-client"
|
||||||
|
snapshotProject.Viewport = design.Viewport{X: 12, Y: 34, K: 0.8}
|
||||||
|
snapshotProject.Nodes = []design.Node{
|
||||||
|
{ID: "new-image", Type: design.NodeTypeImage, Title: "New", Content: "http://localhost:19000/canvas-assets/new.png", Status: "success"},
|
||||||
|
}
|
||||||
|
snapshotProject.Connections = []design.Connection{
|
||||||
|
{ID: "connection-1", FromNodeID: "old-image", ToNodeID: "new-image"},
|
||||||
|
}
|
||||||
|
canvas, err := design.EncodeCanvasSnapshot(snapshotProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{}, nil, nil, "", "", canvas)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if saved.Version != "101000" {
|
||||||
|
t.Fatalf("expected refreshed version, got %s", saved.Version)
|
||||||
|
}
|
||||||
|
if saved.SnapshotID != "snapshot-client" {
|
||||||
|
t.Fatalf("expected client snapshot id, got %s", saved.SnapshotID)
|
||||||
|
}
|
||||||
|
if saved.Viewport != snapshotProject.Viewport {
|
||||||
|
t.Fatalf("unexpected viewport: %#v", saved.Viewport)
|
||||||
|
}
|
||||||
|
if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "new-image" {
|
||||||
|
t.Fatalf("unexpected saved nodes: %#v", saved.Nodes)
|
||||||
|
}
|
||||||
|
if len(saved.Connections) != 1 || saved.Connections[0].ID != "connection-1" {
|
||||||
|
t.Fatalf("unexpected saved connections: %#v", saved.Connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveCanvasDoesNotDeleteImageAssetReferencedByAgentMessage(t *testing.T) {
|
func TestSaveCanvasDoesNotDeleteImageAssetReferencedByAgentMessage(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repo := memory.NewProjectRepository()
|
repo := memory.NewProjectRepository()
|
||||||
|
|||||||
@@ -1054,7 +1054,7 @@ func (s *DesignService) DeleteAsset(ctx context.Context, publicURL string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewport design.Viewport, nodes []design.Node, _ []design.Connection, clientVersion string, snapshotID string, canvas string) (design.Project, error) {
|
func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewport design.Viewport, nodes []design.Node, connections []design.Connection, clientVersion string, snapshotID string, canvas string) (design.Project, error) {
|
||||||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return design.Project{}, err
|
return design.Project{}, err
|
||||||
@@ -1064,10 +1064,16 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo
|
|||||||
project = normalizedProject
|
project = normalizedProject
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(canvas) != "" {
|
if strings.TrimSpace(canvas) != "" {
|
||||||
if snapshot, err := design.DecodeCanvasSnapshot(canvas); err == nil {
|
snapshot, err := design.DecodeCanvasSnapshot(canvas)
|
||||||
|
if err != nil {
|
||||||
|
return design.Project{}, fmt.Errorf("%w: invalid canvas snapshot", design.ErrInvalidInput)
|
||||||
|
}
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
nodes = snapshot.Nodes
|
nodes = snapshot.Nodes
|
||||||
}
|
}
|
||||||
|
if len(connections) == 0 {
|
||||||
|
connections = snapshot.Connections
|
||||||
|
}
|
||||||
if viewport.K == 0 {
|
if viewport.K == 0 {
|
||||||
viewport = snapshot.Viewport
|
viewport = snapshot.Viewport
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1084,6 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo
|
|||||||
clientVersion = snapshot.Version
|
clientVersion = snapshot.Version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
nodes, err = s.persistNodeImageAssets(ctx, project.ID, nodes)
|
nodes, err = s.persistNodeImageAssets(ctx, project.ID, nodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return design.Project{}, err
|
return design.Project{}, err
|
||||||
@@ -1090,7 +1095,7 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo
|
|||||||
deletedAssetURLs := deletedCanvasAssetURLs(project.Nodes, nodes, project.Messages)
|
deletedAssetURLs := deletedCanvasAssetURLs(project.Nodes, nodes, project.Messages)
|
||||||
project.Viewport = viewport
|
project.Viewport = viewport
|
||||||
project.Nodes = nodes
|
project.Nodes = nodes
|
||||||
project.Connections = nil
|
project.Connections = append([]design.Connection(nil), connections...)
|
||||||
project.UpdatedAt = now
|
project.UpdatedAt = now
|
||||||
if strings.TrimSpace(snapshotID) != "" && strings.TrimSpace(snapshotID) != project.SnapshotID {
|
if strings.TrimSpace(snapshotID) != "" && strings.TrimSpace(snapshotID) != project.SnapshotID {
|
||||||
project.SnapshotID = strings.TrimSpace(snapshotID)
|
project.SnapshotID = strings.TrimSpace(snapshotID)
|
||||||
@@ -4042,15 +4047,25 @@ func (s *DesignService) clearStaleGeneratingPlaceholders(project design.Project,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) {
|
func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Project, now time.Time) (design.Project, bool) {
|
||||||
artifactsByID := generatedImageArtifactsByID(project.Messages)
|
artifacts := generatedImageArtifacts(project.Messages)
|
||||||
if len(artifactsByID) == 0 {
|
if len(artifacts) == 0 {
|
||||||
return project, false
|
return project, false
|
||||||
}
|
}
|
||||||
|
artifactsByID := generatedImageArtifactsByID(project.Messages)
|
||||||
|
usedArtifacts := make(map[string]bool)
|
||||||
next := make([]design.Node, 0, len(project.Nodes))
|
next := make([]design.Node, 0, len(project.Nodes))
|
||||||
changed := false
|
changed := false
|
||||||
for _, node := range project.Nodes {
|
for _, node := range project.Nodes {
|
||||||
if artifact, ok := artifactsByID[node.ID]; ok && node.Status == "generating" {
|
if artifact, ok := artifactsByID[node.ID]; ok && node.Status == "generating" {
|
||||||
node = restoreNodeFromGeneratedArtifact(node, artifact)
|
node = restoreNodeFromGeneratedArtifact(node, artifact)
|
||||||
|
usedArtifacts[artifactKey(artifact)] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if blankSuccessfulGeneratedImageNode(node) {
|
||||||
|
if artifact, ok := firstUnusedGeneratedArtifact(artifacts, usedArtifacts); ok {
|
||||||
|
node = restoreNodeFromGeneratedArtifact(node, artifact)
|
||||||
|
usedArtifacts[artifactKey(artifact)] = true
|
||||||
|
}
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) {
|
if project.Status == design.StatusReady && node.Type == design.NodeTypeImage && node.Status == "generating" && !isGeneratedImageContent(node.Content) {
|
||||||
@@ -4068,23 +4083,56 @@ func (s *DesignService) restoreFinishedGeneratedImageNodes(project design.Projec
|
|||||||
return project, true
|
return project, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatedImageArtifactsByID(messages []design.Message) map[string]design.GeneratorTaskArtifact {
|
func generatedImageArtifacts(messages []design.Message) []design.GeneratorTaskArtifact {
|
||||||
artifactsByID := make(map[string]design.GeneratorTaskArtifact)
|
artifacts := make([]design.GeneratorTaskArtifact, 0)
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
if !isToolArtifactsMessage(message) || !strings.EqualFold(strings.TrimSpace(message.Status), "success") {
|
if !isToolArtifactsMessage(message) || !strings.EqualFold(strings.TrimSpace(message.Status), "success") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, artifact := range parseGeneratorArtifacts(message.Content) {
|
for _, artifact := range parseGeneratorArtifacts(message.Content) {
|
||||||
id := strings.TrimSpace(artifact.Metadata.ArtifactID)
|
if strings.TrimSpace(artifact.Content) == "" {
|
||||||
if id == "" || strings.TrimSpace(artifact.Content) == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
artifacts = append(artifacts, artifact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedImageArtifactsByID(messages []design.Message) map[string]design.GeneratorTaskArtifact {
|
||||||
|
artifactsByID := make(map[string]design.GeneratorTaskArtifact)
|
||||||
|
for _, artifact := range generatedImageArtifacts(messages) {
|
||||||
|
id := strings.TrimSpace(artifact.Metadata.ArtifactID)
|
||||||
|
if id != "" {
|
||||||
artifactsByID[id] = artifact
|
artifactsByID[id] = artifact
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return artifactsByID
|
return artifactsByID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func blankSuccessfulGeneratedImageNode(node design.Node) bool {
|
||||||
|
return node.Type == design.NodeTypeImage && strings.TrimSpace(node.Content) == "" && strings.EqualFold(strings.TrimSpace(node.Status), "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstUnusedGeneratedArtifact(artifacts []design.GeneratorTaskArtifact, used map[string]bool) (design.GeneratorTaskArtifact, bool) {
|
||||||
|
for index := len(artifacts) - 1; index >= 0; index-- {
|
||||||
|
artifact := artifacts[index]
|
||||||
|
key := artifactKey(artifact)
|
||||||
|
if key == "" || used[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return artifact, true
|
||||||
|
}
|
||||||
|
return design.GeneratorTaskArtifact{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func artifactKey(artifact design.GeneratorTaskArtifact) string {
|
||||||
|
if id := strings.TrimSpace(artifact.Metadata.ArtifactID); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(artifact.Content)
|
||||||
|
}
|
||||||
|
|
||||||
func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.GeneratorTaskArtifact) design.Node {
|
func restoreNodeFromGeneratedArtifact(node design.Node, artifact design.GeneratorTaskArtifact) design.Node {
|
||||||
node.Type = design.NodeTypeImage
|
node.Type = design.NodeTypeImage
|
||||||
if title := strings.TrimSpace(artifact.Metadata.NodeName); title != "" {
|
if title := strings.TrimSpace(artifact.Metadata.NodeName); title != "" {
|
||||||
|
|||||||
@@ -119,6 +119,48 @@ func TestRestoreFinishedGeneratedImageNodesRepairsStaleCanvasSnapshot(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestoreFinishedGeneratedImageNodesRepairsBlankSuccessfulImageNode(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-blank-generated-node",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
Nodes: []design.Node{
|
||||||
|
{
|
||||||
|
ID: "blank-node",
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Status: "success",
|
||||||
|
Width: 320,
|
||||||
|
Height: 240,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Messages: []design.Message{
|
||||||
|
toolArtifactsMessage("thread-image", "pants", []design.Node{
|
||||||
|
{
|
||||||
|
ID: "artifact-node",
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: "浅麻灰长裤抠图",
|
||||||
|
Content: "http://localhost:19000/canvas-assets/pants.png",
|
||||||
|
Status: "success",
|
||||||
|
Width: 1024,
|
||||||
|
Height: 1024,
|
||||||
|
},
|
||||||
|
}, now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, changed := (*DesignService)(nil).restoreFinishedGeneratedImageNodes(project, now.Add(time.Second))
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected blank generated node to be repaired")
|
||||||
|
}
|
||||||
|
if len(restored.Nodes) != 1 {
|
||||||
|
t.Fatalf("expected one repaired node, got %#v", restored.Nodes)
|
||||||
|
}
|
||||||
|
node := restored.Nodes[0]
|
||||||
|
if node.ID != "blank-node" || node.Content != "http://localhost:19000/canvas-assets/pants.png" || node.Title != "浅麻灰长裤抠图" || node.Width != 1024 || node.Height != 1024 {
|
||||||
|
t.Fatalf("expected blank node repaired from artifact, got %#v", node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeSavedCanvasWorkStateDoesNotReapplyStaleGeneratingNodes(t *testing.T) {
|
func TestMergeSavedCanvasWorkStateDoesNotReapplyStaleGeneratingNodes(t *testing.T) {
|
||||||
existing := []design.Node{
|
existing := []design.Node{
|
||||||
{ID: "generated", Type: design.NodeTypeImage, Title: "Generated", Content: "http://localhost:19000/canvas-assets/generated.png", Status: "success"},
|
{ID: "generated", Type: design.NodeTypeImage, Title: "Generated", Content: "http://localhost:19000/canvas-assets/generated.png", Status: "success"},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
"img_infinite_canvas/internal/logic"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func agentThreadListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.AgentThreadListRequest
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := logic.NewAgentThreadListLogic(r.Context(), svcCtx)
|
||||||
|
resp, err := l.AgentThreadList(&req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
} else {
|
||||||
|
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
"img_infinite_canvas/internal/logic"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func candaChatHistoryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.CandaChatHistoryRequest
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := logic.NewCandaChatHistoryLogic(r.Context(), svcCtx)
|
||||||
|
resp, err := l.CandaChatHistory(&req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
} else {
|
||||||
|
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
"img_infinite_canvas/internal/logic"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func candaThreadStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.CandaThreadStatusRequest
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := logic.NewCandaThreadStatusLogic(r.Context(), svcCtx)
|
||||||
|
resp, err := l.CandaThreadStatus(&req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
} else {
|
||||||
|
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
"img_infinite_canvas/internal/logic"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func queryAgentLastThreadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.QueryAgentLastThreadRequest
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := logic.NewQueryAgentLastThreadLogic(r.Context(), svcCtx)
|
||||||
|
resp, err := l.QueryAgentLastThread(&req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
} else {
|
||||||
|
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
"img_infinite_canvas/internal/logic"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func queryProjectHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.QueryProjectRequest
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := logic.NewQueryProjectLogic(r.Context(), svcCtx)
|
||||||
|
resp, err := l.QueryProject(&req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
} else {
|
||||||
|
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,36 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||||||
Path: "/auth/options",
|
Path: "/auth/options",
|
||||||
Handler: getAuthOptionsHandler(serverCtx),
|
Handler: getAuthOptionsHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/canda/chat-history",
|
||||||
|
Handler: candaChatHistoryHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/canda/thread/status",
|
||||||
|
Handler: candaThreadStatusHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/canva/agent/agentThreadList",
|
||||||
|
Handler: agentThreadListHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/canva/agent/queryAgentLastThread",
|
||||||
|
Handler: queryAgentLastThreadHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/canva/project/queryProject",
|
||||||
|
Handler: queryProjectHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/canva/project/saveProject",
|
||||||
|
Handler: saveProjectHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/context/vision/recognize_object",
|
Path: "/context/vision/recognize_object",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
"img_infinite_canvas/internal/logic"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func saveProjectHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.SaveProjectRequest
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := logic.NewSaveProjectLogic(r.Context(), svcCtx)
|
||||||
|
resp, err := l.SaveProject(&req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ErrorCtx(r.Context(), w, err)
|
||||||
|
} else {
|
||||||
|
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentThreadListLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentThreadListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AgentThreadListLogic {
|
||||||
|
return &AgentThreadListLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AgentThreadListLogic) AgentThreadList(req *types.AgentThreadListRequest) (resp *types.LovartAgentThreadListResponse, err error) {
|
||||||
|
project, err := l.svcCtx.DesignService.GetProject(l.ctx, req.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
threads, err := l.svcCtx.DesignService.AgentThreads(l.ctx, req.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
page := req.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := req.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
total := int64(len(threads))
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
items := make([]types.LovartAgentThreadSummary, 0, end-start)
|
||||||
|
for index, thread := range threads[start:end] {
|
||||||
|
var cover *string
|
||||||
|
if thread.CoverImageURL != "" {
|
||||||
|
value := thread.CoverImageURL
|
||||||
|
cover = &value
|
||||||
|
}
|
||||||
|
items = append(items, types.LovartAgentThreadSummary{
|
||||||
|
Id: start + int64(index) + 1,
|
||||||
|
UserId: project.UserID,
|
||||||
|
ProjectId: req.ProjectId,
|
||||||
|
AgentThreadId: thread.AgentThreadID,
|
||||||
|
ThreadIdType: thread.ThreadIDType,
|
||||||
|
Title: "",
|
||||||
|
Text: thread.Text,
|
||||||
|
CoverImageUrl: cover,
|
||||||
|
FileType: nil,
|
||||||
|
UpdateTimestamp: thread.UpdateTimestamp,
|
||||||
|
Source: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &types.LovartAgentThreadListResponse{
|
||||||
|
Code: 0,
|
||||||
|
Msg: nil,
|
||||||
|
Data: types.LovartAgentThreadListData{
|
||||||
|
Page: page,
|
||||||
|
Total: total,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Data: items,
|
||||||
|
UserIp: "",
|
||||||
|
TraceId: "",
|
||||||
|
HasMore: end < total,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CandaChatHistoryLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCandaChatHistoryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CandaChatHistoryLogic {
|
||||||
|
return &CandaChatHistoryLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CandaChatHistoryLogic) CandaChatHistory(req *types.CandaChatHistoryRequest) (resp *types.CandaChatHistoryResponse, err error) {
|
||||||
|
replay, err := l.svcCtx.DesignService.ReplayHistory(l.ctx, req.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]types.CandaChatHistoryItem, 0, len(replay.Messages))
|
||||||
|
for _, message := range replay.Messages {
|
||||||
|
threadID := message.ThreadID
|
||||||
|
if threadID == "" {
|
||||||
|
threadID = req.ThreadId
|
||||||
|
}
|
||||||
|
if req.ThreadId != "" && threadID != req.ThreadId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, toCandaChatHistoryItem(message, req.ProjectId, threadID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CandaChatHistoryResponse{
|
||||||
|
Code: 0,
|
||||||
|
Message: "Successfully got history view for " + req.ThreadId,
|
||||||
|
Data: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CandaThreadStatusLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCandaThreadStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CandaThreadStatusLogic {
|
||||||
|
return &CandaThreadStatusLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CandaThreadStatusLogic) CandaThreadStatus(req *types.CandaThreadStatusRequest) (resp *types.CandaThreadStatusResponse, err error) {
|
||||||
|
status := "done"
|
||||||
|
if req.ProjectId != "" {
|
||||||
|
thread, err := l.svcCtx.DesignService.AgentThreadStatus(l.ctx, req.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status = thread.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CandaThreadStatusResponse{
|
||||||
|
Code: 0,
|
||||||
|
Msg: "success",
|
||||||
|
Data: types.CandaThreadStatusData{Status: status},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func latestLovartThreadID(project design.Project) string {
|
||||||
|
if threadID := strings.TrimSpace(project.LastThreadID); threadID != "" {
|
||||||
|
return threadID
|
||||||
|
}
|
||||||
|
for index := len(project.Messages) - 1; index >= 0; index-- {
|
||||||
|
message := project.Messages[index]
|
||||||
|
if isHiddenAgentMessage(message) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if threadID := strings.TrimSpace(message.ThreadID); threadID != "" {
|
||||||
|
return threadID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCandaChatHistoryItem(message design.Message, projectID string, threadID string) types.CandaChatHistoryItem {
|
||||||
|
messageType := strings.TrimSpace(message.Type)
|
||||||
|
if messageType == "" {
|
||||||
|
messageType = strings.TrimSpace(message.Role)
|
||||||
|
}
|
||||||
|
if messageType == "tool" {
|
||||||
|
messageType = "tool_use"
|
||||||
|
}
|
||||||
|
item := types.CandaChatHistoryItem{
|
||||||
|
Id: message.ID,
|
||||||
|
Timestamp: formatCandaTimestamp(message.CreatedAt),
|
||||||
|
Type: messageType,
|
||||||
|
ActionId: nonEmpty(message.ActionID, threadID),
|
||||||
|
StepId: nonEmpty(message.StepID, message.ID),
|
||||||
|
Status: message.Status,
|
||||||
|
ToolCallId: message.ToolCallID,
|
||||||
|
Name: message.Name,
|
||||||
|
ThreadMeta: types.CandaThreadMeta{
|
||||||
|
ActionId: nonEmpty(message.ActionID, threadID),
|
||||||
|
SessionId: threadID + "_main",
|
||||||
|
StepId: nonEmpty(message.StepID, message.ID),
|
||||||
|
Depth: 0,
|
||||||
|
AgentName: "next_agent",
|
||||||
|
ProjectId: projectID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if message.Role == "user" {
|
||||||
|
item.Text = message.Content
|
||||||
|
} else {
|
||||||
|
item.Content = message.Content
|
||||||
|
if item.Name == "" {
|
||||||
|
item.Name = message.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCandaTimestamp(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
value = time.Now()
|
||||||
|
}
|
||||||
|
return value.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QueryAgentLastThreadLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueryAgentLastThreadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryAgentLastThreadLogic {
|
||||||
|
return &QueryAgentLastThreadLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryAgentLastThreadLogic) QueryAgentLastThread(req *types.QueryAgentLastThreadRequest) (resp *types.QueryAgentLastThreadResponse, err error) {
|
||||||
|
project, err := l.svcCtx.DesignService.GetProject(l.ctx, req.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.QueryAgentLastThreadResponse{
|
||||||
|
Code: 0,
|
||||||
|
Msg: nil,
|
||||||
|
Data: latestLovartThreadID(project),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
const lovartProjectType int64 = 6
|
||||||
|
|
||||||
|
type QueryProjectLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueryProjectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryProjectLogic {
|
||||||
|
return &QueryProjectLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryProjectLogic) QueryProject(req *types.QueryProjectRequest) (resp *types.QueryProjectResponse, err error) {
|
||||||
|
project, err := l.svcCtx.DesignService.GetProject(l.ctx, req.ProjectId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return toQueryProjectResponse(project), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse {
|
||||||
|
covers := queryProjectCoverList(project)
|
||||||
|
updatedAt := formatLovartTime(project.UpdatedAt)
|
||||||
|
return &types.QueryProjectResponse{
|
||||||
|
Code: 0,
|
||||||
|
Msg: nil,
|
||||||
|
Data: types.QueryProjectData{
|
||||||
|
AssetMeta: types.QueryProjectAssetMeta{
|
||||||
|
AssetId: project.ID,
|
||||||
|
ProjectId: project.ID,
|
||||||
|
Name: project.Title,
|
||||||
|
ProjectType: lovartProjectType,
|
||||||
|
ContentType: "PROJECT_LOVART_V2",
|
||||||
|
OwnerType: "USER",
|
||||||
|
OwnerId: project.UserID,
|
||||||
|
Extra: types.QueryProjectExtra{
|
||||||
|
PicCount: int64(len(covers)),
|
||||||
|
ProjectCoverList: covers,
|
||||||
|
},
|
||||||
|
Meta: types.QueryProjectMeta{
|
||||||
|
UniqId: project.SnapshotID,
|
||||||
|
ParentUniqId: "",
|
||||||
|
},
|
||||||
|
EffectivePermission: "OWNER",
|
||||||
|
CopyTaskStatus: nil,
|
||||||
|
Visibility: "PRIVATE",
|
||||||
|
CreateTime: updatedAt,
|
||||||
|
UpdateTime: updatedAt,
|
||||||
|
},
|
||||||
|
UserId: project.UserID,
|
||||||
|
ProjectName: project.Title,
|
||||||
|
ProjectId: nil,
|
||||||
|
Canvas: project.Canvas,
|
||||||
|
ValidProjectId: true,
|
||||||
|
ProjectType: lovartProjectType,
|
||||||
|
Version: project.Version,
|
||||||
|
SnapshotId: nil,
|
||||||
|
ItemId: nil,
|
||||||
|
Permission: "OWNER",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryProjectCoverList(project design.Project) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
covers := make([]string, 0, 4)
|
||||||
|
add := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || strings.HasPrefix(value, "data:") || strings.HasPrefix(value, "blob:") || seen[value] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
covers = append(covers, value)
|
||||||
|
}
|
||||||
|
add(project.Thumbnail)
|
||||||
|
for _, node := range project.Nodes {
|
||||||
|
if len(covers) >= 12 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (node.Type == design.NodeTypeImage || node.Type == design.NodeTypeFrame) && node.Status == "success" {
|
||||||
|
add(node.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return covers
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLovartTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
value = time.Now()
|
||||||
|
}
|
||||||
|
return value.Format("2006-01-02T15:04:05")
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package logic
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
"img_infinite_canvas/internal/svc"
|
"img_infinite_canvas/internal/svc"
|
||||||
"img_infinite_canvas/internal/types"
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ func NewSaveCanvasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveCa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types.ProjectResponse, err error) {
|
func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types.SaveProjectResponse, err error) {
|
||||||
project, err := l.svcCtx.DesignService.SaveCanvas(
|
project, err := l.svcCtx.DesignService.SaveCanvas(
|
||||||
l.ctx,
|
l.ctx,
|
||||||
req.Id,
|
req.Id,
|
||||||
@@ -41,5 +42,18 @@ func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return toProjectResponse(project), nil
|
return saveProjectResponse(project), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveProjectResponse(project design.Project) *types.SaveProjectResponse {
|
||||||
|
return &types.SaveProjectResponse{
|
||||||
|
Code: 0,
|
||||||
|
Msg: nil,
|
||||||
|
Data: types.SaveProjectData{
|
||||||
|
ProjectId: project.ID,
|
||||||
|
NewCreated: false,
|
||||||
|
ValidProjectId: true,
|
||||||
|
Version: project.Version,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/application"
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
|
"img_infinite_canvas/internal/infrastructure/memory"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveCanvasReturnsSaveProjectMetadata(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := memory.NewProjectRepository()
|
||||||
|
service := application.NewDesignService(repo, nil, nil, nil, nil)
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-save-metadata",
|
||||||
|
Title: "Save metadata",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
UpdatedAt: time.Unix(100, 0).UTC(),
|
||||||
|
Version: "100000",
|
||||||
|
SnapshotID: "snapshot-old",
|
||||||
|
Viewport: design.Viewport{K: 1},
|
||||||
|
}
|
||||||
|
if err := repo.Save(ctx, project); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logic := NewSaveCanvasLogic(ctx, &svc.ServiceContext{DesignService: service})
|
||||||
|
resp, err := logic.SaveCanvas(&types.SaveCanvasRequest{
|
||||||
|
Id: project.ID,
|
||||||
|
Version: project.Version,
|
||||||
|
Viewport: types.CanvasViewport{
|
||||||
|
K: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Code != 0 || resp.Msg != nil {
|
||||||
|
t.Fatalf("unexpected wrapper: %#v", resp)
|
||||||
|
}
|
||||||
|
if resp.Data.ProjectId != project.ID || resp.Data.NewCreated || !resp.Data.ValidProjectId || resp.Data.Version == "" {
|
||||||
|
t.Fatalf("unexpected save project data: %#v", resp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveProjectReturnsSaveProjectMetadata(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := memory.NewProjectRepository()
|
||||||
|
service := application.NewDesignService(repo, nil, nil, nil, nil)
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-lovart-save",
|
||||||
|
Title: "Lovart save",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
UpdatedAt: time.Unix(100, 0).UTC(),
|
||||||
|
Version: "100000",
|
||||||
|
SnapshotID: "snapshot-old",
|
||||||
|
Viewport: design.Viewport{K: 1},
|
||||||
|
}
|
||||||
|
if err := repo.Save(ctx, project); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logic := NewSaveProjectLogic(ctx, &svc.ServiceContext{DesignService: service})
|
||||||
|
resp, err := logic.SaveProject(&types.SaveProjectRequest{
|
||||||
|
ProjectId: project.ID,
|
||||||
|
Version: project.Version,
|
||||||
|
Viewport: types.CanvasViewport{
|
||||||
|
K: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Code != 0 || resp.Msg != nil {
|
||||||
|
t.Fatalf("unexpected wrapper: %#v", resp)
|
||||||
|
}
|
||||||
|
if resp.Data.ProjectId != project.ID || resp.Data.NewCreated || !resp.Data.ValidProjectId || resp.Data.Version == "" {
|
||||||
|
t.Fatalf("unexpected save project data: %#v", resp.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||||
|
// goctl 1.10.1
|
||||||
|
|
||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
|
"img_infinite_canvas/internal/svc"
|
||||||
|
"img_infinite_canvas/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SaveProjectLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSaveProjectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveProjectLogic {
|
||||||
|
return &SaveProjectLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SaveProjectLogic) SaveProject(req *types.SaveProjectRequest) (resp *types.SaveProjectResponse, err error) {
|
||||||
|
projectID := strings.TrimSpace(req.ProjectId)
|
||||||
|
if projectID == "" {
|
||||||
|
return nil, fmt.Errorf("%w: projectId is required", design.ErrInvalidInput)
|
||||||
|
}
|
||||||
|
project, err := l.svcCtx.DesignService.SaveCanvas(
|
||||||
|
l.ctx,
|
||||||
|
projectID,
|
||||||
|
toDomainView(req.Viewport),
|
||||||
|
toDomainNodes(req.Nodes),
|
||||||
|
toDomainConnections(req.Connections),
|
||||||
|
req.Version,
|
||||||
|
req.SnapshotId,
|
||||||
|
req.Canvas,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveProjectResponse(project), nil
|
||||||
|
}
|
||||||
@@ -75,6 +75,13 @@ type AgentPreferToolCategories struct {
|
|||||||
Image []string `json:"IMAGE,optional"`
|
Image []string `json:"IMAGE,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentThreadListRequest struct {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Page int64 `json:"page,optional"`
|
||||||
|
PageSize int64 `json:"pageSize,optional"`
|
||||||
|
Cid string `json:"cid,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
type AgentThreadListResponse struct {
|
type AgentThreadListResponse struct {
|
||||||
ProjectId string `json:"projectId"`
|
ProjectId string `json:"projectId"`
|
||||||
Threads []AgentThreadSummary `json:"threads"`
|
Threads []AgentThreadSummary `json:"threads"`
|
||||||
@@ -170,6 +177,55 @@ type AuthUser struct {
|
|||||||
AvatarUrl string `json:"avatarUrl,optional"`
|
AvatarUrl string `json:"avatarUrl,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CandaChatHistoryItem struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ActionId string `json:"action_id"`
|
||||||
|
StepId string `json:"step_id"`
|
||||||
|
Text string `json:"text,optional"`
|
||||||
|
Content string `json:"content,optional"`
|
||||||
|
Status string `json:"status,optional"`
|
||||||
|
ToolCallId string `json:"tool_call_id,optional"`
|
||||||
|
Name string `json:"name,optional"`
|
||||||
|
ThreadMeta CandaThreadMeta `json:"thread_meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaChatHistoryRequest struct {
|
||||||
|
ThreadId string `form:"thread_id"`
|
||||||
|
ProjectId string `form:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaChatHistoryResponse struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []CandaChatHistoryItem `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadMeta struct {
|
||||||
|
ActionId string `json:"action_id"`
|
||||||
|
SessionId string `json:"session_id"`
|
||||||
|
StepId string `json:"step_id"`
|
||||||
|
Depth int64 `json:"depth"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
ProjectId string `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadStatusData struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadStatusRequest struct {
|
||||||
|
ThreadId string `form:"thread_id"`
|
||||||
|
ProjectId string `form:"project_id,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandaThreadStatusResponse struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data CandaThreadStatusData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type CanvasConnection struct {
|
type CanvasConnection struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
FromNodeId string `json:"fromNodeId"`
|
FromNodeId string `json:"fromNodeId"`
|
||||||
@@ -471,6 +527,38 @@ type InspirationItem struct {
|
|||||||
Accent string `json:"accent"`
|
Accent string `json:"accent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LovartAgentThreadListData struct {
|
||||||
|
Page int64 `json:"page"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
PageSize int64 `json:"pageSize"`
|
||||||
|
Data []LovartAgentThreadSummary `json:"data"`
|
||||||
|
UserIp string `json:"userIp"`
|
||||||
|
TraceId string `json:"traceId"`
|
||||||
|
HasMore bool `json:"hasMore"`
|
||||||
|
RankExpId *string `json:"rankExpId"`
|
||||||
|
AbVariant *string `json:"abVariant"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LovartAgentThreadListResponse struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data LovartAgentThreadListData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LovartAgentThreadSummary struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
UserId string `json:"userId"`
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
AgentThreadId string `json:"agentThreadId"`
|
||||||
|
ThreadIdType int64 `json:"threadIdType"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CoverImageUrl *string `json:"coverImageUrl"`
|
||||||
|
FileType *string `json:"fileType"`
|
||||||
|
UpdateTimestamp int64 `json:"updateTimestamp"`
|
||||||
|
Source int64 `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
type MergeLayersRequest struct {
|
type MergeLayersRequest struct {
|
||||||
Id string `path:"id"`
|
Id string `path:"id"`
|
||||||
NodeIds []string `json:"nodeIds"`
|
NodeIds []string `json:"nodeIds"`
|
||||||
@@ -613,6 +701,69 @@ type ProjectSummary struct {
|
|||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueryAgentLastThreadRequest struct {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Cid string `json:"cid,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryAgentLastThreadResponse struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectAssetMeta struct {
|
||||||
|
AssetId string `json:"assetId"`
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProjectType int64 `json:"projectType"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
OwnerType string `json:"ownerType"`
|
||||||
|
OwnerId string `json:"ownerId"`
|
||||||
|
Extra QueryProjectExtra `json:"extra"`
|
||||||
|
Meta QueryProjectMeta `json:"meta"`
|
||||||
|
EffectivePermission string `json:"effectivePermission"`
|
||||||
|
CopyTaskStatus *string `json:"copyTaskStatus"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
CreateTime string `json:"createTime"`
|
||||||
|
UpdateTime string `json:"updateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectData struct {
|
||||||
|
AssetMeta QueryProjectAssetMeta `json:"assetMeta"`
|
||||||
|
UserId string `json:"userId"`
|
||||||
|
ProjectName string `json:"projectName"`
|
||||||
|
ProjectId *string `json:"projectId"`
|
||||||
|
Canvas string `json:"canvas"`
|
||||||
|
ValidProjectId bool `json:"validProjectId"`
|
||||||
|
ProjectType int64 `json:"projectType"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SnapshotId *string `json:"snapshotId"`
|
||||||
|
ItemId *string `json:"itemId"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectExtra struct {
|
||||||
|
PicCount int64 `json:"picCount"`
|
||||||
|
ProjectCoverList []string `json:"projectCoverList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectMeta struct {
|
||||||
|
UniqId string `json:"uniqId"`
|
||||||
|
ParentUniqId string `json:"parentUniqId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectRequest struct {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Cid string `json:"cid,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryProjectResponse struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data QueryProjectData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type QuickAction struct {
|
type QuickAction struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -641,9 +792,43 @@ type SaveCanvasRequest struct {
|
|||||||
Version string `json:"version,optional"`
|
Version string `json:"version,optional"`
|
||||||
SnapshotId string `json:"snapshotId,optional"`
|
SnapshotId string `json:"snapshotId,optional"`
|
||||||
Canvas string `json:"canvas,optional"`
|
Canvas string `json:"canvas,optional"`
|
||||||
Viewport CanvasViewport `json:"viewport"`
|
Viewport CanvasViewport `json:"viewport,optional"`
|
||||||
Nodes []CanvasNode `json:"nodes"`
|
Nodes []CanvasNode `json:"nodes,optional"`
|
||||||
Connections []CanvasConnection `json:"connections"`
|
Connections []CanvasConnection `json:"connections,optional"`
|
||||||
|
ProjectCoverList []string `json:"projectCoverList,optional"`
|
||||||
|
PicCount int64 `json:"picCount,optional"`
|
||||||
|
ProjectName string `json:"projectName,optional"`
|
||||||
|
ProjectId string `json:"projectId,optional"`
|
||||||
|
SessionId string `json:"sessionId,optional"`
|
||||||
|
CanvasV2Gray bool `json:"canvasV2Gray,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveProjectData struct {
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
NewCreated bool `json:"newCreated"`
|
||||||
|
ValidProjectId bool `json:"validProjectId"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveProjectRequest struct {
|
||||||
|
Canvas string `json:"canvas,optional"`
|
||||||
|
ProjectCoverList []string `json:"projectCoverList,optional"`
|
||||||
|
PicCount int64 `json:"picCount,optional"`
|
||||||
|
ProjectName string `json:"projectName,optional"`
|
||||||
|
ProjectId string `json:"projectId"`
|
||||||
|
Version string `json:"version,optional"`
|
||||||
|
SessionId string `json:"sessionId,optional"`
|
||||||
|
CanvasV2Gray bool `json:"canvasV2Gray,optional"`
|
||||||
|
SnapshotId string `json:"snapshotId,optional"`
|
||||||
|
Viewport CanvasViewport `json:"viewport,optional"`
|
||||||
|
Nodes []CanvasNode `json:"nodes,optional"`
|
||||||
|
Connections []CanvasConnection `json:"connections,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveProjectResponse struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Msg *string `json:"msg"`
|
||||||
|
Data SaveProjectData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StopAgentTaskRequest struct {
|
type StopAgentTaskRequest struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user