From 263748af4a66c04e42a317fb4f61a6e186ee985d Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 19:10:08 +0800 Subject: [PATCH] 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) --- frontend/src/infrastructure/authGateway.ts | 10 +- frontend/src/infrastructure/designGateway.ts | 560 +++++++++++++++++- .../canvas/hooks/useCanvasPersistence.ts | 50 +- .../src/ui/pages/CanvasWorkspace/index.tsx | 75 +-- server/API.md | 57 +- server/README.md | 4 + server/img_infinite_canvas.api | 219 ++++++- .../application/asset_cleanup_test.go | 60 ++ server/internal/application/design_service.go | 90 ++- .../image_generator_target_test.go | 42 ++ .../handler/agent_thread_list_handler.go | 31 + .../handler/canda_chat_history_handler.go | 31 + .../handler/canda_thread_status_handler.go | 31 + .../query_agent_last_thread_handler.go | 31 + .../internal/handler/query_project_handler.go | 31 + server/internal/handler/routes.go | 30 + .../internal/handler/save_project_handler.go | 31 + .../internal/logic/agent_thread_list_logic.go | 93 +++ .../logic/canda_chat_history_logic.go | 51 ++ .../logic/canda_thread_status_logic.go | 44 ++ server/internal/logic/lovart_agent_mapper.go | 78 +++ .../logic/query_agent_last_thread_logic.go | 40 ++ server/internal/logic/query_project_logic.go | 114 ++++ server/internal/logic/save_canvas_logic.go | 18 +- .../internal/logic/save_canvas_logic_test.go | 87 +++ server/internal/logic/save_project_logic.go | 52 ++ server/internal/types/types.go | 199 ++++++- 27 files changed, 2045 insertions(+), 114 deletions(-) create mode 100644 server/internal/handler/agent_thread_list_handler.go create mode 100644 server/internal/handler/canda_chat_history_handler.go create mode 100644 server/internal/handler/canda_thread_status_handler.go create mode 100644 server/internal/handler/query_agent_last_thread_handler.go create mode 100644 server/internal/handler/query_project_handler.go create mode 100644 server/internal/handler/save_project_handler.go create mode 100644 server/internal/logic/agent_thread_list_logic.go create mode 100644 server/internal/logic/canda_chat_history_logic.go create mode 100644 server/internal/logic/canda_thread_status_logic.go create mode 100644 server/internal/logic/lovart_agent_mapper.go create mode 100644 server/internal/logic/query_agent_last_thread_logic.go create mode 100644 server/internal/logic/query_project_logic.go create mode 100644 server/internal/logic/save_canvas_logic_test.go create mode 100644 server/internal/logic/save_project_logic.go diff --git a/frontend/src/infrastructure/authGateway.ts b/frontend/src/infrastructure/authGateway.ts index e76d6d1..eb28cbf 100644 --- a/frontend/src/infrastructure/authGateway.ts +++ b/frontend/src/infrastructure/authGateway.ts @@ -1,5 +1,4 @@ import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth"; -import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding"; import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages"; const sessionStorageKey = "img-infinite-canvas.auth.session"; @@ -116,7 +115,6 @@ export const authGateway = { }, async uploadAvatar(file: File) { - const uploadFile = await encodeUploadImage(file); const upload = await apiRequest<{ uploadUrl: string; publicUrl: string; @@ -124,9 +122,9 @@ export const authGateway = { }>("/api/assets/upload-url", { method: "POST", body: JSON.stringify({ - fileName: uploadFile.name, - contentType: uploadFile.type || file.type || "application/octet-stream", - size: uploadFile.size + fileName: file.name, + contentType: file.type || "application/octet-stream", + size: file.size }) }); @@ -134,7 +132,7 @@ export const authGateway = { await fetch(upload.uploadUrl, { method: "PUT", headers: upload.headers, - body: uploadFile + body: file }); } return upload.publicUrl; diff --git a/frontend/src/infrastructure/designGateway.ts b/frontend/src/infrastructure/designGateway.ts index bd55a7e..aebe8fd 100644 --- a/frontend/src/infrastructure/designGateway.ts +++ b/frontend/src/infrastructure/designGateway.ts @@ -22,7 +22,6 @@ import type { } from "@/domain/design"; import { authHeaders } from "@/infrastructure/authGateway"; import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages"; -import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding"; type ImageGenerationOptions = { imageModel?: string; @@ -45,6 +44,108 @@ type ProjectEventsOptions = { eventsUrl?: string; }; +type CanvasSaveProject = Pick & Partial; + +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 | null>; + connections?: Array | null>; + updatedAt?: string; +}; + +type QueryAgentLastThreadResponse = { + code: number; + msg: string | null; + data: string; +}; + +type LovartAgentThreadListResponse = { + code: number; + msg: string | null; + data: { + page: number; + total: number; + pageSize: number; + data: Array<{ + agentThreadId: string; + threadIdType: number; + text: string; + coverImageUrl: string | null; + updateTimestamp: number; + }>; + hasMore: boolean; + }; +}; + +type CandaChatHistoryResponse = { + code: number; + message: string; + data: Array<{ + id: string; + timestamp: string; + type: string; + action_id: string; + step_id: string; + text?: string; + content?: string; + status?: string; + tool_call_id?: string; + name?: string; + thread_meta?: { + session_id?: string; + project_id?: string; + }; + }>; +}; + +const canvasDataPrefix = "SHAKKERDATA://"; +const canvasSaveSessionStorageKey = "img-infinite-canvas.canvas.saveSessionId"; + function isVersionConflictError(error: unknown) { return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409; } @@ -99,8 +200,18 @@ export const designGateway = { }); }, - getProject(id: string) { - return request<{ project: Project }>(`/api/projects/${id}`); + async getProject(id: string) { + try { + const response = await request("/api/canva/project/queryProject", { + method: "POST", + body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() }) + }); + const project = await projectFromQueryProjectResponse(response, id); + if (project) return { project }; + } catch { + // Keep existing workspaces open if the Lovart-compatible document route is unavailable during development. + } + return getProjectDocument(id); }, updateProjectTitle(id: string, title: string) { @@ -130,12 +241,49 @@ export const designGateway = { }); }, - getProjectHistory(id: string) { - return request & { projectId: string }>(`/api/projects/${id}/history`); + async getProjectHistory(id: string, threadId?: string) { + try { + const resolvedThreadId = threadId || (await designGateway.queryAgentLastThread(id)); + if (!resolvedThreadId) return { projectId: id, threadId: "", messages: [] }; + const params = new URLSearchParams({ thread_id: resolvedThreadId, project_id: id }); + const response = await request(`/api/canda/chat-history?${params.toString()}`); + return { + projectId: id, + threadId: resolvedThreadId, + messages: response.data.map((item) => messageFromCandaHistoryItem(item, resolvedThreadId)) + }; + } catch { + return getLegacyProjectHistory(id, threadId); + } }, - listAgentThreads(id: string) { - return request(`/api/projects/${id}/agent/threads`); + async listAgentThreads(id: string) { + try { + const response = await request("/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(`/api/projects/${id}/agent/threads`); + } + }, + + async queryAgentLastThread(id: string) { + const response = await request("/api/canva/agent/queryAgentLastThread", { + method: "POST", + body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() }) + }); + return response.data; }, generate(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) { @@ -289,16 +437,45 @@ export const designGateway = { viewport: CanvasViewport, nodes: CanvasNode[], connections: CanvasConnection[], - snapshot?: Pick + snapshot?: CanvasSaveProject ) { - const payload = { viewport, nodes, connections, ...snapshot }; - const save = () => - request<{ project: Project }>(`/api/projects/${projectId}/canvas`, { - method: "PATCH", - body: JSON.stringify(payload) - }); - return (async () => { + const draft = createCanvasSnapshotDraft(nodes); + const canvas = await encodeCanvasSnapshot({ + projectId, + version: snapshot?.version ?? "", + snapshotId: draft.snapshotId, + updatedAt: draft.updatedAt, + viewport, + nodes, + connections + }); + const payload = canvas + ? { + canvas, + projectCoverList: draft.projectCoverList, + picCount: draft.projectCoverList.length, + projectName: snapshot?.title || "Untitled", + projectId, + version: snapshot?.version ?? "", + sessionId: getCanvasSaveSessionId(), + canvasV2Gray: true + } + : { + viewport, + nodes, + connections, + version: snapshot?.version ?? "", + snapshotId: draft.snapshotId, + canvas: "" + }; + const save = async () => { + const response = await saveProjectCanvas(projectId, payload); + return { + project: mergeSavedCanvasProject(projectId, viewport, nodes, connections, snapshot, draft, canvas, response) + }; + }; + for (let attempt = 0; attempt < 3; attempt += 1) { try { return await save(); @@ -313,7 +490,6 @@ export const designGateway = { }, async uploadImage(file: File) { - const uploadFile = await encodeUploadImage(file); const upload = await request<{ driver: string; uploadUrl: string; @@ -322,9 +498,9 @@ export const designGateway = { }>("/api/assets/upload-url", { method: "POST", body: JSON.stringify({ - fileName: uploadFile.name, - contentType: uploadFile.type || file.type || "application/octet-stream", - size: uploadFile.size + fileName: file.name, + contentType: file.type || "application/octet-stream", + size: file.size }) }); @@ -332,13 +508,357 @@ export const designGateway = { await fetch(upload.uploadUrl, { method: "PUT", headers: upload.headers, - body: uploadFile + body: file }); } return upload.publicUrl; } }; +function getProjectDocument(id: string) { + return request<{ project: Project }>(`/api/projects/${id}`); +} + +async function getLegacyProjectHistory(id: string, threadId?: string) { + const response = await request<{ projectId: string; messages: AgentMessage[] }>(`/api/projects/${id}/history`); + const resolvedThreadId = threadId || latestThreadId(response.messages); + const messages = resolvedThreadId ? response.messages.filter((message) => !message.threadId || message.threadId === resolvedThreadId) : response.messages; + return { + projectId: response.projectId, + threadId: resolvedThreadId, + messages + }; +} + +async function saveProjectCanvas(projectId: string, payload: object) { + try { + return await request("/api/canva/project/saveProject", { + method: "POST", + body: JSON.stringify(payload) + }); + } catch (error) { + if (isVersionConflictError(error)) throw error; + return request(`/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 | 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(); + return (nodes ?? []).map((node, index) => normalizeCanvasNode(node, index, seenIds)); +} + +function normalizeCanvasNode(node: Partial | null | undefined, index: number, seenIds: Set): 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(); + 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; + 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 { + if ( + !canvas.startsWith(canvasDataPrefix) || + typeof DecompressionStream === "undefined" || + typeof Blob === "undefined" || + typeof Response === "undefined" || + typeof atob === "undefined" + ) { + return null; + } + const compressed = base64ToBytes(canvas.slice(canvasDataPrefix.length)); + const stream = new Blob([compressed], { type: "application/gzip" }).stream().pipeThrough(new DecompressionStream("gzip")); + return (await new Response(stream).json()) as QueryProjectCanvasSnapshot; +} + +function bytesToBase64(bytes: Uint8Array) { + const chunkSize = 0x8000; + let binary = ""; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +} + +function base64ToBytes(value: string) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function createCanvasSnapshotDraft(nodes: CanvasNode[]): CanvasSnapshotDraft { + return { + snapshotId: createClientId(), + updatedAt: new Date().toISOString(), + projectCoverList: projectCoverListFromNodes(nodes) + }; +} + +function projectCoverListFromNodes(nodes: CanvasNode[]) { + return nodes + .filter((node) => (node.type === "image" || node.type === "frame") && node.status === "success" && isCanvasCoverUrl(node.content)) + .map((node) => node.content.trim()) + .slice(0, 12); +} + +function isCanvasCoverUrl(value: string) { + const url = value.trim(); + return Boolean(url) && !url.startsWith("data:") && !url.startsWith("blob:"); +} + +function getCanvasSaveSessionId() { + if (typeof window === "undefined") return createClientId(); + try { + const current = window.sessionStorage.getItem(canvasSaveSessionStorageKey); + if (current) return current; + const next = createClientId(); + window.sessionStorage.setItem(canvasSaveSessionStorageKey, next); + return next; + } catch { + return createClientId(); + } +} + +function createClientId() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function createQueryProjectCid() { + return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`; +} + +function mergeSavedCanvasProject( + projectId: string, + viewport: CanvasViewport, + nodes: CanvasNode[], + connections: CanvasConnection[], + current: CanvasSaveProject | undefined, + draft: CanvasSnapshotDraft, + canvas: string, + response: SaveProjectResponse +): Project { + return { + id: current?.id ?? response.data.projectId ?? projectId, + userId: current?.userId, + title: current?.title || "Untitled", + brief: current?.brief ?? "", + status: current?.status || "ready", + thumbnail: current?.thumbnail || draft.projectCoverList[0] || "", + updatedAt: draft.updatedAt, + version: response.data.version || current?.version || "", + snapshotId: draft.snapshotId, + canvas: canvas || current?.canvas || "", + lastThreadId: current?.lastThreadId ?? "", + viewport, + nodes, + connections, + messages: current?.messages ?? [] + }; +} + function projectEventsUrl(projectId: string, options: ProjectEventsOptions) { if (options.eventsUrl) return options.eventsUrl; const params = new URLSearchParams(); diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/hooks/useCanvasPersistence.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/hooks/useCanvasPersistence.ts index 1e142b5..c40cc97 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/hooks/useCanvasPersistence.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/hooks/useCanvasPersistence.ts @@ -1,10 +1,12 @@ "use client"; -import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, type Dispatch, type RefObject, type SetStateAction } from "react"; import type { CanvasNode, CanvasViewport, Project } from "@/domain/design"; import { designGateway } from "@/infrastructure/designGateway"; import { useUserMessage } from "@/ui/hooks/useUserMessage"; +const canvasSaveDebounceMs = 1200; + export function useCanvasPersistence({ project, projectRef, @@ -38,10 +40,18 @@ export function useCanvasPersistence({ }; }) { const toUserMessageText = useUserMessage(); + const saveTimerRef = useRef(null); + const clearScheduledSave = useCallback(() => { + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }, []); const saveCanvasSnapshot = useCallback( async ({ quiet = false }: { quiet?: boolean } = {}) => { const currentProject = projectRef.current; if (!currentProject) return null; + clearScheduledSave(); if (!quiet) setSaving(true); setError(""); try { @@ -50,11 +60,7 @@ export function useCanvasPersistence({ viewportRef.current, nodesRef.current, [], - { - version: currentProject.version, - snapshotId: currentProject.snapshotId, - canvas: "" - } + currentProject ); projectRef.current = savedProject; setProject(savedProject); @@ -67,7 +73,22 @@ export function useCanvasPersistence({ if (!quiet) setSaving(false); } }, - [toUserMessageText, nodesRef, projectRef, setDirty, setError, setProject, setSaving, viewportRef] + [clearScheduledSave, toUserMessageText, nodesRef, projectRef, setDirty, setError, setProject, setSaving, viewportRef] + ); + + const scheduleCanvasSave = useCallback( + ({ delayMs = canvasSaveDebounceMs }: { delayMs?: number } = {}) => { + if (!projectRef.current || anyGenerating) { + clearScheduledSave(); + return; + } + clearScheduledSave(); + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null; + void saveCanvasSnapshot({ quiet: true }); + }, delayMs); + }, + [anyGenerating, clearScheduledSave, projectRef, saveCanvasSnapshot] ); const updateProjectTitle = useCallback( @@ -91,15 +112,18 @@ export function useCanvasPersistence({ ); useEffect(() => { - if (!project || !dirty || anyGenerating) return; - const timer = window.setTimeout(() => { - void saveCanvasSnapshot({ quiet: true }); - }, 900); - return () => window.clearTimeout(timer); - }, [anyGenerating, dirty, nodes, project, saveCanvasSnapshot, viewport]); + if (!project || !dirty || anyGenerating) { + clearScheduledSave(); + return; + } + scheduleCanvasSave(); + }, [anyGenerating, clearScheduledSave, dirty, nodes, project, scheduleCanvasSave, viewport]); + + useEffect(() => clearScheduledSave, [clearScheduledSave]); return { saveCanvasSnapshot, + scheduleCanvasSave, updateProjectTitle }; } diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index 6255e67..8fec978 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -757,7 +757,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { } }, [projectRef]); - const { saveCanvasSnapshot, updateProjectTitle } = useCanvasPersistence({ + const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({ project, projectRef, viewport, @@ -859,7 +859,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { if (history && !agentStreamingProjectIdRef.current) { const currentProject = projectRef.current; if (currentProject) { - setMessages(visibleAgentMessagesForThread({ ...currentProject, messages: history.messages }, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); + const projectWithHistory = { + ...currentProject, + lastThreadId: history.threadId || currentProject.lastThreadId, + messages: history.messages + }; + projectRef.current = projectWithHistory; + setProject(projectWithHistory); + setMessages(visibleAgentMessagesForThread(projectWithHistory, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); } else { setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current)); } @@ -1200,7 +1207,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointercancel", handleUp); setCanvasPanning(false); - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; window.addEventListener("pointermove", handleMove); @@ -1627,7 +1634,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { void refreshMockupPrintTextures(Array.from(dragIds)); return; } - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; window.addEventListener("pointermove", handleMove); @@ -1686,7 +1693,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { void refreshMockupPrintTextures([node.id]); return; } - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; window.addEventListener("pointermove", handleMove); @@ -1724,7 +1731,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; window.addEventListener("pointermove", handleMove); @@ -1751,7 +1758,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; window.addEventListener("pointermove", handleMove); @@ -1804,7 +1811,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; window.addEventListener("pointermove", handleMove); @@ -2517,6 +2524,22 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const currentProject = projectRef.current; if (currentProject) { setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId)); + void designGateway + .getProjectHistory(currentProject.id, threadId) + .then((history) => { + if (selectedAgentThreadIdRef.current !== threadId) return; + const nextProject = { + ...currentProject, + lastThreadId: history.threadId || currentProject.lastThreadId, + messages: history.messages + }; + projectRef.current = nextProject; + setProject(nextProject); + setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, threadId)); + }) + .catch((err) => { + console.warn("Failed to load agent thread history", err); + }); } }; @@ -2836,7 +2859,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { setMockupSurface(null); setMockupModel(null); setDirty(true); - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; const refreshMockupPrintTextures = async (nodeIds: string[]) => { @@ -2860,13 +2883,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { } } if (!changed) { - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); return; } nodesRef.current = nextNodes; setNodes(nextNodes); setDirty(true); - void saveCanvasSnapshot({ quiet: true }); + scheduleCanvasSave(); }; const restoreQuickEditViewport = useCallback(() => { @@ -3131,11 +3154,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { setCropSelection(null); setCropPrompt(""); setDirty(true); - const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], { - version: currentProject.version, - snapshotId: currentProject.snapshotId, - canvas: currentProject.canvas - }); + const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], currentProject); applyProject(savedProject); setDirty(false); } catch (err) { @@ -3211,7 +3230,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { for (const [index, file] of imageFiles.entries()) { const previewUrl = URL.createObjectURL(file); const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 })); - const size = fitCanvasImageSize(dimensions.width, dimensions.height); + const size = originalCanvasImageSize(dimensions.width, dimensions.height); const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural"; preparedUploads.push({ file, @@ -3269,11 +3288,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { setError(t("generateError")); } - const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], { - version: currentProject.version, - snapshotId: currentProject.snapshotId, - canvas: "" - }); + const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject); applyProject(savedProject); setDirty(false); if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess")); @@ -3754,7 +3769,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { setViewport(nextViewport); setDirty(true); }} - onViewportCommit={() => void saveCanvasSnapshot({ quiet: true })} + onViewportCommit={scheduleCanvasSave} /> )}
@@ -5305,19 +5320,13 @@ function readImageDimensions(file: File) { }); } -function fitCanvasImageSize(width: number, height: number) { +function originalCanvasImageSize(width: number, height: number) { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - return { width: 420, height: 420 }; - } - const maxSide = 520; - const minSide = 120; - let scale = Math.min(1, maxSide / Math.max(width, height)); - if (Math.max(width, height) * scale < minSide) { - scale = minSide / Math.max(width, height); + return { width: 512, height: 512 }; } return { - width: Math.round(width * scale), - height: Math.round(height * scale) + width: Math.round(width), + height: Math.round(height) }; } diff --git a/server/API.md b/server/API.md index afd5086..b0ee78c 100644 --- a/server/API.md +++ b/server/API.md @@ -177,6 +177,17 @@ Returns all project summaries, sorted by `updatedAt` descending. 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` ```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": "" }`. + +`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=&project_id=` + +Returns Lovart-style thread history items grouped by `thread_meta`. + +`GET /api/canda/thread/status?thread_id=&project_id=` + +Returns `{ "code": 0, "msg": "success", "data": { "status": "done" } }` or the current project thread status when `project_id` is present. + ## Save Canvas +`POST /api/canva/project/saveProject` + `PATCH /api/projects/:id/canvas` ```json { - "viewport": { "x": 420, "y": 260, "k": 0.72 }, - "nodes": [], - "connections": [] + "canvas": "SHAKKERDATA://", + "projectCoverList": ["https://cdn.example.com/canvas/image.png"], + "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. diff --git a/server/README.md b/server/README.md index 5c6fc56..f645c8a 100644 --- a/server/README.md +++ b/server/README.md @@ -134,6 +134,10 @@ curl -X PATCH http://localhost:8888/api/projects//title \ -H "Content-Type: application/json" \ -d '{"title":"夏季活动主视觉"}' +curl -X POST http://localhost:8888/api/canva/project/saveProject \ + -H "Content-Type: application/json" \ + -d '{"canvas":"SHAKKERDATA://","projectCoverList":[],"picCount":0,"projectName":"Untitled","projectId":"","version":"1783472149000","sessionId":"browser-session-id","canvasV2Gray":true}' + curl -X POST http://localhost:8888/api/assets/upload-url \ -H "Content-Type: application/json" \ -d '{"fileName":"ref.png","contentType":"image/png"}' diff --git a/server/img_infinite_canvas.api b/server/img_infinite_canvas.api index cf867ef..06f3a28 100644 --- a/server/img_infinite_canvas.api +++ b/server/img_infinite_canvas.api @@ -320,6 +320,11 @@ type ProjectIdRequest { Id string `path:"id"` } +type QueryProjectRequest { + ProjectId string `json:"projectId"` + Cid string `json:"cid,optional"` +} + type CreateProjectRequest { Title string `json:"title,optional"` Prompt string `json:"prompt,optional"` @@ -338,6 +343,53 @@ type ProjectResponse { 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 { Id string `json:"id"` Deleted bool `json:"deleted"` @@ -356,6 +408,105 @@ type ChatHistoryResponse { 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 { Id string `path:"id"` Prompt string `json:"prompt"` @@ -638,13 +789,47 @@ type MergeLayersRequest { } type SaveCanvasRequest { - Id string `path:"id"` - Version string `json:"version,optional"` - SnapshotId string `json:"snapshotId,optional"` - Canvas string `json:"canvas,optional"` - Viewport CanvasViewport `json:"viewport"` - Nodes []CanvasNode `json:"nodes"` - Connections []CanvasConnection `json:"connections"` + Id string `path:"id"` + Version string `json:"version,optional"` + SnapshotId string `json:"snapshotId,optional"` + Canvas string `json:"canvas,optional"` + Viewport CanvasViewport `json:"viewport,optional"` + Nodes []CanvasNode `json:"nodes,optional"` + 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 { @@ -732,6 +917,24 @@ service img_infinite_canvas-api { @handler createProjectAsync 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 delete /projects/batch-delete (DeleteProjectsRequest) returns (DeleteProjectsResponse) @@ -802,7 +1005,7 @@ service img_infinite_canvas-api { post /projects/:id/layers/merge (MergeLayersRequest) returns (ProjectResponse) @handler saveCanvas - patch /projects/:id/canvas (SaveCanvasRequest) returns (ProjectResponse) + patch /projects/:id/canvas (SaveCanvasRequest) returns (SaveProjectResponse) @handler createAssetUpload post /assets/upload-url (CreateAssetUploadRequest) returns (CreateAssetUploadResponse) diff --git a/server/internal/application/asset_cleanup_test.go b/server/internal/application/asset_cleanup_test.go index 9a86fc6..79f85cb 100644 --- a/server/internal/application/asset_cleanup_test.go +++ b/server/internal/application/asset_cleanup_test.go @@ -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) { ctx := context.Background() repo := memory.NewProjectRepository() diff --git a/server/internal/application/design_service.go b/server/internal/application/design_service.go index 0aa77ef..8dd93b9 100644 --- a/server/internal/application/design_service.go +++ b/server/internal/application/design_service.go @@ -1054,7 +1054,7 @@ func (s *DesignService) DeleteAsset(ctx context.Context, publicURL string) error 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)) if err != nil { return design.Project{}, err @@ -1064,19 +1064,24 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo project = normalizedProject } if strings.TrimSpace(canvas) != "" { - if snapshot, err := design.DecodeCanvasSnapshot(canvas); err == nil { - if len(nodes) == 0 { - nodes = snapshot.Nodes - } - if viewport.K == 0 { - viewport = snapshot.Viewport - } - if snapshotID == "" { - snapshotID = snapshot.SnapshotID - } - if clientVersion == "" { - clientVersion = snapshot.Version - } + snapshot, err := design.DecodeCanvasSnapshot(canvas) + if err != nil { + return design.Project{}, fmt.Errorf("%w: invalid canvas snapshot", design.ErrInvalidInput) + } + if len(nodes) == 0 { + nodes = snapshot.Nodes + } + if len(connections) == 0 { + connections = snapshot.Connections + } + if viewport.K == 0 { + viewport = snapshot.Viewport + } + if snapshotID == "" { + snapshotID = snapshot.SnapshotID + } + if clientVersion == "" { + clientVersion = snapshot.Version } } nodes, err = s.persistNodeImageAssets(ctx, project.ID, nodes) @@ -1090,7 +1095,7 @@ func (s *DesignService) SaveCanvas(ctx context.Context, projectID string, viewpo deletedAssetURLs := deletedCanvasAssetURLs(project.Nodes, nodes, project.Messages) project.Viewport = viewport project.Nodes = nodes - project.Connections = nil + project.Connections = append([]design.Connection(nil), connections...) project.UpdatedAt = now if strings.TrimSpace(snapshotID) != "" && strings.TrimSpace(snapshotID) != project.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) { - artifactsByID := generatedImageArtifactsByID(project.Messages) - if len(artifactsByID) == 0 { + artifacts := generatedImageArtifacts(project.Messages) + if len(artifacts) == 0 { return project, false } + artifactsByID := generatedImageArtifactsByID(project.Messages) + usedArtifacts := make(map[string]bool) next := make([]design.Node, 0, len(project.Nodes)) changed := false for _, node := range project.Nodes { if artifact, ok := artifactsByID[node.ID]; ok && node.Status == "generating" { 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 } 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 } -func generatedImageArtifactsByID(messages []design.Message) map[string]design.GeneratorTaskArtifact { - artifactsByID := make(map[string]design.GeneratorTaskArtifact) +func generatedImageArtifacts(messages []design.Message) []design.GeneratorTaskArtifact { + artifacts := make([]design.GeneratorTaskArtifact, 0) for _, message := range messages { if !isToolArtifactsMessage(message) || !strings.EqualFold(strings.TrimSpace(message.Status), "success") { continue } for _, artifact := range parseGeneratorArtifacts(message.Content) { - id := strings.TrimSpace(artifact.Metadata.ArtifactID) - if id == "" || strings.TrimSpace(artifact.Content) == "" { + if strings.TrimSpace(artifact.Content) == "" { 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 } } 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 { node.Type = design.NodeTypeImage if title := strings.TrimSpace(artifact.Metadata.NodeName); title != "" { diff --git a/server/internal/application/image_generator_target_test.go b/server/internal/application/image_generator_target_test.go index 57fcfdd..539a801 100644 --- a/server/internal/application/image_generator_target_test.go +++ b/server/internal/application/image_generator_target_test.go @@ -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) { existing := []design.Node{ {ID: "generated", Type: design.NodeTypeImage, Title: "Generated", Content: "http://localhost:19000/canvas-assets/generated.png", Status: "success"}, diff --git a/server/internal/handler/agent_thread_list_handler.go b/server/internal/handler/agent_thread_list_handler.go new file mode 100644 index 0000000..f3db695 --- /dev/null +++ b/server/internal/handler/agent_thread_list_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/canda_chat_history_handler.go b/server/internal/handler/canda_chat_history_handler.go new file mode 100644 index 0000000..2d7d9cb --- /dev/null +++ b/server/internal/handler/canda_chat_history_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/canda_thread_status_handler.go b/server/internal/handler/canda_thread_status_handler.go new file mode 100644 index 0000000..c75df98 --- /dev/null +++ b/server/internal/handler/canda_thread_status_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/query_agent_last_thread_handler.go b/server/internal/handler/query_agent_last_thread_handler.go new file mode 100644 index 0000000..8f12351 --- /dev/null +++ b/server/internal/handler/query_agent_last_thread_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/query_project_handler.go b/server/internal/handler/query_project_handler.go new file mode 100644 index 0000000..3810f58 --- /dev/null +++ b/server/internal/handler/query_project_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/routes.go b/server/internal/handler/routes.go index 14b4457..22c2840 100644 --- a/server/internal/handler/routes.go +++ b/server/internal/handler/routes.go @@ -94,6 +94,36 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/auth/options", 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, Path: "/context/vision/recognize_object", diff --git a/server/internal/handler/save_project_handler.go b/server/internal/handler/save_project_handler.go new file mode 100644 index 0000000..6d15456 --- /dev/null +++ b/server/internal/handler/save_project_handler.go @@ -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) + } + } +} diff --git a/server/internal/logic/agent_thread_list_logic.go b/server/internal/logic/agent_thread_list_logic.go new file mode 100644 index 0000000..a086660 --- /dev/null +++ b/server/internal/logic/agent_thread_list_logic.go @@ -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 +} diff --git a/server/internal/logic/canda_chat_history_logic.go b/server/internal/logic/canda_chat_history_logic.go new file mode 100644 index 0000000..f3d6971 --- /dev/null +++ b/server/internal/logic/canda_chat_history_logic.go @@ -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 +} diff --git a/server/internal/logic/canda_thread_status_logic.go b/server/internal/logic/canda_thread_status_logic.go new file mode 100644 index 0000000..5dd259b --- /dev/null +++ b/server/internal/logic/canda_thread_status_logic.go @@ -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 +} diff --git a/server/internal/logic/lovart_agent_mapper.go b/server/internal/logic/lovart_agent_mapper.go new file mode 100644 index 0000000..f4324f4 --- /dev/null +++ b/server/internal/logic/lovart_agent_mapper.go @@ -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) +} diff --git a/server/internal/logic/query_agent_last_thread_logic.go b/server/internal/logic/query_agent_last_thread_logic.go new file mode 100644 index 0000000..0c32e3e --- /dev/null +++ b/server/internal/logic/query_agent_last_thread_logic.go @@ -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 +} diff --git a/server/internal/logic/query_project_logic.go b/server/internal/logic/query_project_logic.go new file mode 100644 index 0000000..f28726b --- /dev/null +++ b/server/internal/logic/query_project_logic.go @@ -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") +} diff --git a/server/internal/logic/save_canvas_logic.go b/server/internal/logic/save_canvas_logic.go index 1279ec5..500f794 100644 --- a/server/internal/logic/save_canvas_logic.go +++ b/server/internal/logic/save_canvas_logic.go @@ -6,6 +6,7 @@ package logic import ( "context" + "img_infinite_canvas/internal/domain/design" "img_infinite_canvas/internal/svc" "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( l.ctx, req.Id, @@ -41,5 +42,18 @@ func (l *SaveCanvasLogic) SaveCanvas(req *types.SaveCanvasRequest) (resp *types. 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, + }, + } } diff --git a/server/internal/logic/save_canvas_logic_test.go b/server/internal/logic/save_canvas_logic_test.go new file mode 100644 index 0000000..36b3d1e --- /dev/null +++ b/server/internal/logic/save_canvas_logic_test.go @@ -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) + } +} diff --git a/server/internal/logic/save_project_logic.go b/server/internal/logic/save_project_logic.go new file mode 100644 index 0000000..8a28251 --- /dev/null +++ b/server/internal/logic/save_project_logic.go @@ -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 +} diff --git a/server/internal/types/types.go b/server/internal/types/types.go index 3ff2bfd..d5368de 100644 --- a/server/internal/types/types.go +++ b/server/internal/types/types.go @@ -75,6 +75,13 @@ type AgentPreferToolCategories struct { 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 { ProjectId string `json:"projectId"` Threads []AgentThreadSummary `json:"threads"` @@ -170,6 +177,55 @@ type AuthUser struct { 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 { Id string `json:"id"` FromNodeId string `json:"fromNodeId"` @@ -471,6 +527,38 @@ type InspirationItem struct { 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 { Id string `path:"id"` NodeIds []string `json:"nodeIds"` @@ -613,6 +701,69 @@ type ProjectSummary struct { 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 { Id string `json:"id"` Label string `json:"label"` @@ -637,13 +788,47 @@ type RecognizeObjectResponse struct { } type SaveCanvasRequest struct { - Id string `path:"id"` - Version string `json:"version,optional"` - SnapshotId string `json:"snapshotId,optional"` - Canvas string `json:"canvas,optional"` - Viewport CanvasViewport `json:"viewport"` - Nodes []CanvasNode `json:"nodes"` - Connections []CanvasConnection `json:"connections"` + Id string `path:"id"` + Version string `json:"version,optional"` + SnapshotId string `json:"snapshotId,optional"` + Canvas string `json:"canvas,optional"` + Viewport CanvasViewport `json:"viewport,optional"` + Nodes []CanvasNode `json:"nodes,optional"` + 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 {