diff --git a/frontend/src/infrastructure/designGateway.ts b/frontend/src/infrastructure/designGateway.ts index a5753c9..ee764b6 100644 --- a/frontend/src/infrastructure/designGateway.ts +++ b/frontend/src/infrastructure/designGateway.ts @@ -1,1037 +1,47 @@ -import type { - AgentChatPayload, - AgentThreadListResponse, - AgentMessage, - AgentThreadResponse, - CanvasConnection, - CanvasNode, - CanvasViewport, - Dashboard, - DeleteProjectResponse, - DeleteProjectsResponse, - ExtractNodeTextResponse, - GeneratorTaskResponse, - GeneratorTaskSubmitResponse, - GenerateResult, - MergeLayersRequest, - NodeActionRequest, - NormalizedBox, - Project, - RecognizeObjectResponse, - ResolveShareResponse, - SharePermission, - ShareSettings, - ShareVisitor, - TextExtractionSource, - ProjectListResponse -} from "@/domain/design"; -import { authHeaders } from "@/infrastructure/authGateway"; -import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages"; - -type ImageGenerationOptions = { - imageModel?: string; - imageSize?: string; - imageQuality?: string; - imageCount?: number; - enableWebSearch?: boolean; - allowEmptyPrompt?: boolean; -}; - -type ProjectEventHandlers = { - onProject?: (project: Project) => void; - onThinking?: (message: AgentMessage) => void; - onTextExtraction?: (result: ExtractNodeTextResponse) => void; - onDone?: (project: Project) => void; - onTimeout?: (project: Project) => void; - onError?: (error: Error) => void; -}; - -type ProjectEventsOptions = { - threadId?: string; - 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"; -const shareVisitorStorageKey = "img-infinite-canvas.share.visitor"; - -function isVersionConflictError(error: unknown) { - return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409; -} - -async function request(path: string, init?: RequestInit): Promise { - const headers = new Headers(init?.headers); - headers.set("Content-Type", "application/json"); - Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value)); - const shareId = activeShareId(); - if (shareId) headers.set("X-Share-Id", shareId); - - let response: Response; - try { - response = await fetch(path, { - ...init, - headers, - credentials: "omit" - }); - } catch (err) { - throw createNetworkFailure("design", err); - } - - if (!response.ok) { - const body = await response.json().catch(() => ({ error: response.statusText })); - throw createApiFailure("design", response, body); - } - - return response.json() as Promise; -} +import { agentOperations } from "./designGateway/agents"; +import { assetOperations } from "./designGateway/assets"; +import { canvasOperations } from "./designGateway/canvas"; +import { eventOperations } from "./designGateway/events"; +import { generationOperations } from "./designGateway/generation"; +import { projectOperations } from "./designGateway/projects"; +import { sharingOperations } from "./designGateway/sharing"; export const designGateway = { - dashboard() { - return request("/api/dashboard"); - }, - - listProjects(limit = 20, offset = 0) { - const params = new URLSearchParams({ - limit: String(limit), - offset: String(offset) - }); - return request(`/api/projects?${params.toString()}`); - }, - - createProject(prompt: string, title?: string, image?: ImageGenerationOptions) { - return request<{ project: Project }>("/api/projects", { - method: "POST", - body: JSON.stringify({ prompt, title, ...image }) - }); - }, - - createProjectAsync(prompt: string, title?: string, image?: ImageGenerationOptions) { - return request<{ project: Project }>("/api/projects/async", { - method: "POST", - body: JSON.stringify({ prompt, title, ...image }) - }); - }, - - async getProject(id: string) { - if (activeShareId()) return getProjectDocument(id); - 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) { - return request<{ project: Project }>(`/api/projects/${id}/title`, { - method: "PATCH", - body: JSON.stringify({ title }) - }); - }, - - deleteProject(id: string) { - return request(`/api/projects/${id}`, { - method: "DELETE" - }); - }, - - deleteProjects(ids: string[]) { - return request("/api/projects/batch-delete", { - method: "DELETE", - body: JSON.stringify({ ids }) - }); - }, - - deleteAsset(publicUrl: string) { - return request<{ deleted: boolean }>("/api/assets", { - method: "DELETE", - body: JSON.stringify({ publicUrl }) - }); - }, - - async getProjectHistory(id: string, threadId?: string) { - if (activeShareId()) return getLegacyProjectHistory(id, threadId); - 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); - } - }, - - async listAgentThreads(id: string) { - if (activeShareId()) { - return request(`/api/projects/${id}/agent/threads`); - } - 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) { - return request(`/api/projects/${projectId}/generate`, { - method: "POST", - body: JSON.stringify({ prompt, mode, ...image }) - }); - }, - - generateAsync(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) { - return request<{ project: Project }>(`/api/projects/${projectId}/generate/async`, { - method: "POST", - body: JSON.stringify({ prompt, mode, ...image }) - }); - }, - - agentChat(projectId: string, payload: AgentChatPayload) { - return request(`/api/projects/${projectId}/agent/chat`, { - method: "POST", - body: JSON.stringify(payload) - }); - }, - - stopAgentTask(projectId: string, threadId?: string) { - return request<{ project: Project }>(`/api/projects/${projectId}/agent/stop`, { - method: "POST", - body: JSON.stringify({ threadId }) - }); - }, - - agentSocket(projectId: string, payload: AgentChatPayload, handlers: ProjectEventHandlers) { - let closed = false; - let closeEvents: (() => void) | null = null; - - void designGateway - .agentChat(projectId, payload) - .then((response) => { - if (closed) return; - handlers.onProject?.(response.project); - closeEvents = designGateway.projectEvents(response.project.id, handlers, { - threadId: response.threadId || payload.threadId, - eventsUrl: response.eventsUrl - }); - }) - .catch((error: Error) => { - if (!closed) { - closeEvents?.(); - handlers.onError?.(error); - } - }); - - return () => { - closed = true; - closeEvents?.(); - }; - }, - - agentSocketEvents(projectId: string, threadId: string | undefined, handlers: ProjectEventHandlers) { - return designGateway.projectEvents(projectId, handlers, { threadId }); - }, - - runNodeActionAsync(projectId: string, nodeId: string, action: NodeActionRequest) { - return request<{ project: Project }>(`/api/projects/${projectId}/nodes/${nodeId}/actions/async`, { - method: "POST", - body: JSON.stringify(action) - }); - }, - - getGeneratorTask(projectId: string, taskId: string) { - const params = new URLSearchParams({ task_id: taskId, project_id: projectId }); - return request(`/api/generator/tasks?${params.toString()}`); - }, - - submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record) { - return request("/api/generator/tasks", { - method: "POST", - headers: { "X-Share-Project-Id": projectId }, - body: JSON.stringify({ - cid: createClientId(), - project_id: projectId, - generator_name: generatorName, - input_args: inputArgs - }) - }); - }, - - recognizeObject(imageUrl: string, boundingBox: NormalizedBox, lang = "zh") { - return request("/api/context/vision/recognize_object", { - method: "POST", - body: JSON.stringify({ image_url: imageUrl, bounding_box: boundingBox, lang }) - }); - }, - - generateMockupModel(projectId: string, nodeId: string) { - return request(`/api/projects/${projectId}/nodes/${nodeId}/mockup-model`, { - method: "POST", - body: JSON.stringify({}) - }); - }, - - extractNodeText(projectId: string, nodeId: string, prompt = "") { - return request(`/api/projects/${projectId}/nodes/${nodeId}/text-extraction`, { - method: "POST", - body: JSON.stringify({ prompt }) - }); - }, - - extractNodeTextAsync(projectId: string, nodeId: string, source: TextExtractionSource = "edit-text") { - const params = new URLSearchParams({ eitdortxt: source }); - return request(`/api/projects/${projectId}/nodes/${nodeId}/text-extraction/async?${params.toString()}`, { - method: "POST" - }); - }, - - mergeLayers(projectId: string, payload: MergeLayersRequest) { - return request<{ project: Project }>(`/api/projects/${projectId}/layers/merge`, { - method: "POST", - body: JSON.stringify(payload) - }); - }, - - resolveShare(shareId: string) { - return request(`/api/shares/${encodeURIComponent(shareId)}`, { - headers: { "X-Share-Visitor": getShareVisitorId() } - }); - }, - - getShareSettings(projectId: string) { - return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing`); - }, - - updateShareLink(projectId: string, permission: Exclude) { - return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/link`, { - method: "PUT", - body: JSON.stringify({ permission }) - }); - }, - - inviteShareMember(projectId: string, identifier: string, permission: Exclude) { - return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members`, { - method: "POST", - body: JSON.stringify({ identifier, permission }) - }); - }, - - updateShareMember(projectId: string, memberId: string, permission: Exclude) { - return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members/${memberId}`, { - method: "PATCH", - body: JSON.stringify({ permission }) - }); - }, - - deleteShareMember(projectId: string, memberId: string) { - return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members/${memberId}`, { - method: "DELETE" - }); - }, - - listShareVisitors(projectId: string) { - return request<{ visitors: ShareVisitor[] }>(`/api/projects/${projectId}/sharing/visitors`); - }, - - revokeShareAccess(projectId: string) { - return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/access`, { - method: "DELETE" - }); - }, - - projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) { - const controller = new AbortController(); - let settled = false; - const readProject = (data: string) => { - const payload = JSON.parse(data) as { project: Project }; - return payload.project; - }; - const close = () => { - settled = true; - controller.abort(); - }; - const onEvent = (event: string, data: string) => { - if (event === "project") { - handlers.onProject?.(readProject(data)); - return; - } - if (event === "thinking") { - handlers.onThinking?.(JSON.parse(data) as AgentMessage); - return; - } - if (event === "text_extraction") { - handlers.onTextExtraction?.(JSON.parse(data) as ExtractNodeTextResponse); - return; - } - if (event === "done") { - settled = true; - handlers.onDone?.(readProject(data)); - controller.abort(); - return; - } - if (event === "timeout") { - settled = true; - handlers.onTimeout?.(readProject(data)); - controller.abort(); - return; - } - if (event === "stream_error") { - settled = true; - const payload = data ? (JSON.parse(data) as { error?: string }) : {}; - handlers.onError?.(new Error(payload.error ?? "Event stream failed")); - controller.abort(); - } - }; - - void streamProjectEvents(projectEventsUrl(projectId, options), controller.signal, onEvent).catch((error: Error) => { - if (settled || controller.signal.aborted) return; - settled = true; - handlers.onError?.(error); - }); - - return close; - }, - - saveCanvas( - projectId: string, - viewport: CanvasViewport, - nodes: CanvasNode[], - connections: CanvasConnection[], - snapshot?: CanvasSaveProject - ) { - 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(); - } catch (error) { - if (!isVersionConflictError(error) || attempt === 2) throw error; - const { project } = await designGateway.getProject(projectId); - return { project }; - } - } - return save(); - })(); - }, - - async uploadImage(file: File) { - const upload = await request<{ - driver: string; - uploadUrl: string; - publicUrl: string; - headers: Record; - }>("/api/assets/upload-url", { - method: "POST", - body: JSON.stringify({ - fileName: file.name, - contentType: file.type || "application/octet-stream", - size: file.size - }) - }); - - if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) { - await fetch(upload.uploadUrl, { - method: "PUT", - credentials: "omit", - headers: upload.headers, - body: file - }); - } - return upload.publicUrl; - } + dashboard: projectOperations.dashboard, + listProjects: projectOperations.listProjects, + createProject: projectOperations.createProject, + createProjectAsync: projectOperations.createProjectAsync, + getProject: projectOperations.getProject, + updateProjectTitle: projectOperations.updateProjectTitle, + deleteProject: projectOperations.deleteProject, + deleteProjects: projectOperations.deleteProjects, + deleteAsset: assetOperations.deleteAsset, + getProjectHistory: agentOperations.getProjectHistory, + listAgentThreads: agentOperations.listAgentThreads, + queryAgentLastThread: agentOperations.queryAgentLastThread, + generate: generationOperations.generate, + generateAsync: generationOperations.generateAsync, + agentChat: agentOperations.agentChat, + stopAgentTask: agentOperations.stopAgentTask, + agentSocket: agentOperations.agentSocket, + agentSocketEvents: agentOperations.agentSocketEvents, + runNodeActionAsync: generationOperations.runNodeActionAsync, + getGeneratorTask: generationOperations.getGeneratorTask, + submitGeneratorTask: generationOperations.submitGeneratorTask, + recognizeObject: generationOperations.recognizeObject, + generateMockupModel: generationOperations.generateMockupModel, + extractNodeText: generationOperations.extractNodeText, + extractNodeTextAsync: generationOperations.extractNodeTextAsync, + mergeLayers: generationOperations.mergeLayers, + resolveShare: sharingOperations.resolveShare, + getShareSettings: sharingOperations.getShareSettings, + updateShareLink: sharingOperations.updateShareLink, + inviteShareMember: sharingOperations.inviteShareMember, + updateShareMember: sharingOperations.updateShareMember, + deleteShareMember: sharingOperations.deleteShareMember, + listShareVisitors: sharingOperations.listShareVisitors, + revokeShareAccess: sharingOperations.revokeShareAccess, + projectEvents: eventOperations.projectEvents, + saveCanvas: canvasOperations.saveCanvas, + uploadImage: assetOperations.uploadImage }; - -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) { - if (activeShareId()) { - return request(`/api/projects/${projectId}/canvas`, { - method: "PATCH", - body: JSON.stringify(payload) - }); - } - 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 activeShareId() { - if (typeof window === "undefined") return ""; - const hashPath = window.location.hash.replace(/^#/, ""); - const path = hashPath || window.location.pathname; - const [pathOnly] = path.split(/[?#]/); - const match = pathOnly.replace(/\/+$/, "").match(/^\/share\/([^/]+)$/); - return match ? decodeURIComponent(match[1]) : ""; -} - -function getShareVisitorId() { - if (typeof window === "undefined") return ""; - const stored = window.localStorage.getItem(shareVisitorStorageKey)?.trim(); - if (stored) return stored; - const bytes = new Uint8Array(18); - window.crypto.getRandomValues(bytes); - const visitorId = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); - window.localStorage.setItem(shareVisitorStorageKey, visitorId); - return visitorId; -} - -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(); - if (options.threadId) params.set("threadId", options.threadId); - const query = params.toString(); - return `/api/projects/${projectId}/events${query ? `?${query}` : ""}`; -} - -async function streamProjectEvents(url: string, signal: AbortSignal, onEvent: (event: string, data: string) => void) { - const headers = new Headers(); - Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value)); - const response = await fetch(url, { - headers, - signal, - cache: "no-store", - credentials: "omit" - }); - if (!response.ok) { - const payload = await response.json().catch(() => ({ error: response.statusText })); - throw createApiFailure("design", response, payload); - } - if (!response.body) { - throw new Error("Event stream failed"); - } - - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - let buffer = ""; - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buffer += value; - const parts = buffer.split(/\r?\n\r?\n/); - buffer = parts.pop() ?? ""; - parts.forEach((part) => emitServerSentEvent(part, onEvent)); - } - if (buffer.trim()) { - emitServerSentEvent(buffer, onEvent); - } - } finally { - reader.releaseLock(); - } -} - -function emitServerSentEvent(raw: string, onEvent: (event: string, data: string) => void) { - let event = "message"; - const data: string[] = []; - raw.split(/\r?\n/).forEach((line) => { - if (line.startsWith("event:")) { - event = line.slice("event:".length).trim(); - return; - } - if (line.startsWith("data:")) { - data.push(line.slice("data:".length).trimStart()); - } - }); - if (data.length > 0) { - onEvent(event, data.join("\n")); - } -} diff --git a/frontend/src/infrastructure/designGateway/agents.ts b/frontend/src/infrastructure/designGateway/agents.ts new file mode 100644 index 0000000..76457f3 --- /dev/null +++ b/frontend/src/infrastructure/designGateway/agents.ts @@ -0,0 +1,164 @@ +import type { AgentChatPayload, AgentMessage, AgentThreadListResponse, AgentThreadResponse } from "@/domain/design"; + +import { createQueryProjectCid } from "./clientIds"; +import { projectEvents } from "./events"; +import { request } from "./request"; +import type { + CandaChatHistoryItem, + CandaChatHistoryResponse, + LovartAgentThreadListResponse, + ProjectHistoryResponse, + ProjectResponse, + QueryAgentLastThreadResponse +} from "./responses"; +import { activeShareId } from "./shareContext"; +import type { ProjectEventHandlers } from "./types"; + +async function getProjectHistory(id: string, threadId?: string) { + if (activeShareId()) return getLegacyProjectHistory(id, threadId); + try { + const resolvedThreadId = threadId || (await 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); + } +} + +async function listAgentThreads(id: string) { + if (activeShareId()) { + return request(`/api/projects/${id}/agent/threads`); + } + 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 function queryAgentLastThread(id: string) { + const response = await request("/api/canva/agent/queryAgentLastThread", { + method: "POST", + body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() }) + }); + return response.data; +} + +function agentChat(projectId: string, payload: AgentChatPayload) { + return request(`/api/projects/${projectId}/agent/chat`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +function stopAgentTask(projectId: string, threadId?: string) { + return request(`/api/projects/${projectId}/agent/stop`, { + method: "POST", + body: JSON.stringify({ threadId }) + }); +} + +function agentSocket(projectId: string, payload: AgentChatPayload, handlers: ProjectEventHandlers) { + let closed = false; + let closeEvents: (() => void) | null = null; + + void agentChat(projectId, payload) + .then((response) => { + if (closed) return; + handlers.onProject?.(response.project); + closeEvents = projectEvents(response.project.id, handlers, { + threadId: response.threadId || payload.threadId, + eventsUrl: response.eventsUrl + }); + }) + .catch((error: Error) => { + if (!closed) { + closeEvents?.(); + handlers.onError?.(error); + } + }); + + return () => { + closed = true; + closeEvents?.(); + }; +} + +function agentSocketEvents(projectId: string, threadId: string | undefined, handlers: ProjectEventHandlers) { + return projectEvents(projectId, handlers, { threadId }); +} + +async function getLegacyProjectHistory(id: string, threadId?: string) { + const response = await request(`/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 + }; +} + +function messageFromCandaHistoryItem(item: CandaChatHistoryItem, 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 ""; +} + +function firstNonEmptyString(...values: unknown[]) { + for (const value of values) { + if (typeof value === "string" && value.length > 0) return value; + } + return ""; +} + +export const agentOperations = { + getProjectHistory, + listAgentThreads, + queryAgentLastThread, + agentChat, + stopAgentTask, + agentSocket, + agentSocketEvents +}; diff --git a/frontend/src/infrastructure/designGateway/assets.ts b/frontend/src/infrastructure/designGateway/assets.ts new file mode 100644 index 0000000..0afdee7 --- /dev/null +++ b/frontend/src/infrastructure/designGateway/assets.ts @@ -0,0 +1,35 @@ +import { request } from "./request"; +import type { AssetUploadResponse, DeleteAssetResponse } from "./responses"; + +function deleteAsset(publicUrl: string) { + return request("/api/assets", { + method: "DELETE", + body: JSON.stringify({ publicUrl }) + }); +} + +async function uploadImage(file: File) { + const upload = await request("/api/assets/upload-url", { + method: "POST", + body: JSON.stringify({ + fileName: file.name, + contentType: file.type || "application/octet-stream", + size: file.size + }) + }); + + if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) { + await fetch(upload.uploadUrl, { + method: "PUT", + credentials: "omit", + headers: upload.headers, + body: file + }); + } + return upload.publicUrl; +} + +export const assetOperations = { + deleteAsset, + uploadImage +}; diff --git a/frontend/src/infrastructure/designGateway/canvas.ts b/frontend/src/infrastructure/designGateway/canvas.ts new file mode 100644 index 0000000..e33e5d5 --- /dev/null +++ b/frontend/src/infrastructure/designGateway/canvas.ts @@ -0,0 +1,100 @@ +import type { CanvasConnection, CanvasNode, CanvasViewport } from "@/domain/design"; +import { USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages"; + +import { + createCanvasSnapshotDraft, + encodeCanvasSnapshot, + getCanvasSaveSessionId, + mergeSavedCanvasProject +} from "./canvasSnapshot"; +import { getProject } from "./projects"; +import { request } from "./request"; +import type { SaveProjectResponse } from "./responses"; +import { activeShareId } from "./shareContext"; +import type { CanvasSaveProject } from "./types"; + +function isVersionConflictError(error: unknown) { + return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409; +} + +function saveCanvas( + projectId: string, + viewport: CanvasViewport, + nodes: CanvasNode[], + connections: CanvasConnection[], + snapshot?: CanvasSaveProject +) { + 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(); + } catch (error) { + if (!isVersionConflictError(error) || attempt === 2) throw error; + const { project } = await getProject(projectId); + return { project }; + } + } + return save(); + })(); +} + +async function saveProjectCanvas(projectId: string, payload: object) { + if (activeShareId()) { + return request(`/api/projects/${projectId}/canvas`, { + method: "PATCH", + body: JSON.stringify(payload) + }); + } + 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) + }); + } +} + +export const canvasOperations = { + saveCanvas +}; diff --git a/frontend/src/infrastructure/designGateway/canvasSnapshot.ts b/frontend/src/infrastructure/designGateway/canvasSnapshot.ts new file mode 100644 index 0000000..761395c --- /dev/null +++ b/frontend/src/infrastructure/designGateway/canvasSnapshot.ts @@ -0,0 +1,271 @@ +import type { CanvasConnection, CanvasNode, CanvasViewport, Project } from "@/domain/design"; + +import { createClientId } from "./clientIds"; +import type { QueryProjectCanvasSnapshot, QueryProjectResponse, SaveProjectResponse } from "./responses"; +import type { CanvasSaveProject, CanvasSnapshotDraft, CanvasSnapshotInput } from "./types"; + +const canvasDataPrefix = "SHAKKERDATA://"; +const canvasSaveSessionStorageKey = "img-infinite-canvas.canvas.saveSessionId"; + +export 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; +} + +export async function encodeCanvasSnapshot({ + projectId, + version, + snapshotId, + updatedAt, + viewport, + nodes, + connections +}: CanvasSnapshotInput) { + 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)}`; +} + +export function createCanvasSnapshotDraft(nodes: CanvasNode[]): CanvasSnapshotDraft { + return { + snapshotId: createClientId(), + updatedAt: new Date().toISOString(), + projectCoverList: projectCoverListFromNodes(nodes) + }; +} + +export 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(); + } +} + +export 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 ?? [] + }; +} + +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 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 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; +} + +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 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:"); +} diff --git a/frontend/src/infrastructure/designGateway/clientIds.ts b/frontend/src/infrastructure/designGateway/clientIds.ts new file mode 100644 index 0000000..194e1e2 --- /dev/null +++ b/frontend/src/infrastructure/designGateway/clientIds.ts @@ -0,0 +1,7 @@ +export function createClientId() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function createQueryProjectCid() { + return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/frontend/src/infrastructure/designGateway/events.ts b/frontend/src/infrastructure/designGateway/events.ts new file mode 100644 index 0000000..592000d --- /dev/null +++ b/frontend/src/infrastructure/designGateway/events.ts @@ -0,0 +1,123 @@ +import type { AgentMessage, ExtractNodeTextResponse, Project } from "@/domain/design"; +import { authHeaders } from "@/infrastructure/authGateway"; +import { createApiFailure } from "@/infrastructure/userMessages"; + +import type { ProjectEventHandlers, ProjectEventsOptions } from "./types"; + +export function projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) { + const controller = new AbortController(); + let settled = false; + const readProject = (data: string) => { + const payload = JSON.parse(data) as { project: Project }; + return payload.project; + }; + const close = () => { + settled = true; + controller.abort(); + }; + const onEvent = (event: string, data: string) => { + if (event === "project") { + handlers.onProject?.(readProject(data)); + return; + } + if (event === "thinking") { + handlers.onThinking?.(JSON.parse(data) as AgentMessage); + return; + } + if (event === "text_extraction") { + handlers.onTextExtraction?.(JSON.parse(data) as ExtractNodeTextResponse); + return; + } + if (event === "done") { + settled = true; + handlers.onDone?.(readProject(data)); + controller.abort(); + return; + } + if (event === "timeout") { + settled = true; + handlers.onTimeout?.(readProject(data)); + controller.abort(); + return; + } + if (event === "stream_error") { + settled = true; + const payload = data ? (JSON.parse(data) as { error?: string }) : {}; + handlers.onError?.(new Error(payload.error ?? "Event stream failed")); + controller.abort(); + } + }; + + void streamProjectEvents(projectEventsUrl(projectId, options), controller.signal, onEvent).catch((error: Error) => { + if (settled || controller.signal.aborted) return; + settled = true; + handlers.onError?.(error); + }); + + return close; +} + +function projectEventsUrl(projectId: string, options: ProjectEventsOptions) { + if (options.eventsUrl) return options.eventsUrl; + const params = new URLSearchParams(); + if (options.threadId) params.set("threadId", options.threadId); + const query = params.toString(); + return `/api/projects/${projectId}/events${query ? `?${query}` : ""}`; +} + +async function streamProjectEvents(url: string, signal: AbortSignal, onEvent: (event: string, data: string) => void) { + const headers = new Headers(); + Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value)); + const response = await fetch(url, { + headers, + signal, + cache: "no-store", + credentials: "omit" + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({ error: response.statusText })); + throw createApiFailure("design", response, payload); + } + if (!response.body) { + throw new Error("Event stream failed"); + } + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += value; + const parts = buffer.split(/\r?\n\r?\n/); + buffer = parts.pop() ?? ""; + parts.forEach((part) => emitServerSentEvent(part, onEvent)); + } + if (buffer.trim()) { + emitServerSentEvent(buffer, onEvent); + } + } finally { + reader.releaseLock(); + } +} + +function emitServerSentEvent(raw: string, onEvent: (event: string, data: string) => void) { + let event = "message"; + const data: string[] = []; + raw.split(/\r?\n/).forEach((line) => { + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim(); + return; + } + if (line.startsWith("data:")) { + data.push(line.slice("data:".length).trimStart()); + } + }); + if (data.length > 0) { + onEvent(event, data.join("\n")); + } +} + +export const eventOperations = { + projectEvents +}; diff --git a/frontend/src/infrastructure/designGateway/generation.ts b/frontend/src/infrastructure/designGateway/generation.ts new file mode 100644 index 0000000..0749d0d --- /dev/null +++ b/frontend/src/infrastructure/designGateway/generation.ts @@ -0,0 +1,104 @@ +import type { + AgentThreadResponse, + ExtractNodeTextResponse, + GeneratorTaskResponse, + GeneratorTaskSubmitResponse, + GenerateResult, + MergeLayersRequest, + NodeActionRequest, + NormalizedBox, + RecognizeObjectResponse, + TextExtractionSource +} from "@/domain/design"; + +import { createClientId } from "./clientIds"; +import { request } from "./request"; +import type { ProjectResponse } from "./responses"; +import type { ImageGenerationOptions } from "./types"; + +function generate(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) { + return request(`/api/projects/${projectId}/generate`, { + method: "POST", + body: JSON.stringify({ prompt, mode, ...image }) + }); +} + +function generateAsync(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) { + return request(`/api/projects/${projectId}/generate/async`, { + method: "POST", + body: JSON.stringify({ prompt, mode, ...image }) + }); +} + +function runNodeActionAsync(projectId: string, nodeId: string, action: NodeActionRequest) { + return request(`/api/projects/${projectId}/nodes/${nodeId}/actions/async`, { + method: "POST", + body: JSON.stringify(action) + }); +} + +function getGeneratorTask(projectId: string, taskId: string) { + const params = new URLSearchParams({ task_id: taskId, project_id: projectId }); + return request(`/api/generator/tasks?${params.toString()}`); +} + +function submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record) { + return request("/api/generator/tasks", { + method: "POST", + headers: { "X-Share-Project-Id": projectId }, + body: JSON.stringify({ + cid: createClientId(), + project_id: projectId, + generator_name: generatorName, + input_args: inputArgs + }) + }); +} + +function recognizeObject(imageUrl: string, boundingBox: NormalizedBox, lang = "zh") { + return request("/api/context/vision/recognize_object", { + method: "POST", + body: JSON.stringify({ image_url: imageUrl, bounding_box: boundingBox, lang }) + }); +} + +function generateMockupModel(projectId: string, nodeId: string) { + return request(`/api/projects/${projectId}/nodes/${nodeId}/mockup-model`, { + method: "POST", + body: JSON.stringify({}) + }); +} + +function extractNodeText(projectId: string, nodeId: string, prompt = "") { + return request(`/api/projects/${projectId}/nodes/${nodeId}/text-extraction`, { + method: "POST", + body: JSON.stringify({ prompt }) + }); +} + +function extractNodeTextAsync(projectId: string, nodeId: string, source: TextExtractionSource = "edit-text") { + const params = new URLSearchParams({ eitdortxt: source }); + return request(`/api/projects/${projectId}/nodes/${nodeId}/text-extraction/async?${params.toString()}`, { + method: "POST" + }); +} + +function mergeLayers(projectId: string, payload: MergeLayersRequest) { + return request(`/api/projects/${projectId}/layers/merge`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export const generationOperations = { + generate, + generateAsync, + runNodeActionAsync, + getGeneratorTask, + submitGeneratorTask, + recognizeObject, + generateMockupModel, + extractNodeText, + extractNodeTextAsync, + mergeLayers +}; diff --git a/frontend/src/infrastructure/designGateway/projects.ts b/frontend/src/infrastructure/designGateway/projects.ts new file mode 100644 index 0000000..76cd24c --- /dev/null +++ b/frontend/src/infrastructure/designGateway/projects.ts @@ -0,0 +1,84 @@ +import type { Dashboard, DeleteProjectResponse, DeleteProjectsResponse, ProjectListResponse } from "@/domain/design"; + +import { projectFromQueryProjectResponse } from "./canvasSnapshot"; +import { createQueryProjectCid } from "./clientIds"; +import { request } from "./request"; +import type { ProjectResponse, QueryProjectResponse } from "./responses"; +import { activeShareId } from "./shareContext"; +import type { ImageGenerationOptions } from "./types"; + +function dashboard() { + return request("/api/dashboard"); +} + +function listProjects(limit = 20, offset = 0) { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset) + }); + return request(`/api/projects?${params.toString()}`); +} + +function createProject(prompt: string, title?: string, image?: ImageGenerationOptions) { + return request("/api/projects", { + method: "POST", + body: JSON.stringify({ prompt, title, ...image }) + }); +} + +function createProjectAsync(prompt: string, title?: string, image?: ImageGenerationOptions) { + return request("/api/projects/async", { + method: "POST", + body: JSON.stringify({ prompt, title, ...image }) + }); +} + +export async function getProject(id: string) { + if (activeShareId()) return getProjectDocument(id); + 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); +} + +function updateProjectTitle(id: string, title: string) { + return request(`/api/projects/${id}/title`, { + method: "PATCH", + body: JSON.stringify({ title }) + }); +} + +function deleteProject(id: string) { + return request(`/api/projects/${id}`, { + method: "DELETE" + }); +} + +function deleteProjects(ids: string[]) { + return request("/api/projects/batch-delete", { + method: "DELETE", + body: JSON.stringify({ ids }) + }); +} + +function getProjectDocument(id: string) { + return request(`/api/projects/${id}`); +} + +export const projectOperations = { + dashboard, + listProjects, + createProject, + createProjectAsync, + getProject, + updateProjectTitle, + deleteProject, + deleteProjects +}; diff --git a/frontend/src/infrastructure/designGateway/request.ts b/frontend/src/infrastructure/designGateway/request.ts new file mode 100644 index 0000000..0e7d17d --- /dev/null +++ b/frontend/src/infrastructure/designGateway/request.ts @@ -0,0 +1,30 @@ +import { authHeaders } from "@/infrastructure/authGateway"; +import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages"; + +import { activeShareId } from "./shareContext"; + +export async function request(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("Content-Type", "application/json"); + Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value)); + const shareId = activeShareId(); + if (shareId) headers.set("X-Share-Id", shareId); + + let response: Response; + try { + response = await fetch(path, { + ...init, + headers, + credentials: "omit" + }); + } catch (err) { + throw createNetworkFailure("design", err); + } + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: response.statusText })); + throw createApiFailure("design", response, body); + } + + return response.json() as Promise; +} diff --git a/frontend/src/infrastructure/designGateway/responses.ts b/frontend/src/infrastructure/designGateway/responses.ts new file mode 100644 index 0000000..24b4d9c --- /dev/null +++ b/frontend/src/infrastructure/designGateway/responses.ts @@ -0,0 +1,122 @@ +import type { AgentMessage, CanvasConnection, CanvasNode, CanvasViewport, Project, ShareSettings, ShareVisitor } from "@/domain/design"; + +export type ProjectResponse = { + project: Project; +}; + +export type ProjectHistoryResponse = { + projectId: string; + messages: AgentMessage[]; +}; + +export type DeleteAssetResponse = { + deleted: boolean; +}; + +export type AssetUploadResponse = { + driver: string; + uploadUrl: string; + publicUrl: string; + headers: Record; +}; + +export type ShareSettingsResponse = { + settings: ShareSettings; +}; + +export type ShareVisitorsResponse = { + visitors: ShareVisitor[]; +}; + +export type SaveProjectResponse = { + code: number; + msg: string | null; + data: { + projectId: string; + newCreated: boolean; + validProjectId: boolean; + version: string; + }; +}; + +export 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; + }; +}; + +export type QueryProjectCanvasSnapshot = { + projectId?: string; + version?: string; + snapshotId?: string; + viewport?: CanvasViewport; + nodes?: Array | null>; + connections?: Array | null>; + updatedAt?: string; +}; + +export type QueryAgentLastThreadResponse = { + code: number; + msg: string | null; + data: string; +}; + +export 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; + }; +}; + +export type CandaChatHistoryItem = { + 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; + }; +}; + +export type CandaChatHistoryResponse = { + code: number; + message: string; + data: CandaChatHistoryItem[]; +}; diff --git a/frontend/src/infrastructure/designGateway/shareContext.ts b/frontend/src/infrastructure/designGateway/shareContext.ts new file mode 100644 index 0000000..6fe0726 --- /dev/null +++ b/frontend/src/infrastructure/designGateway/shareContext.ts @@ -0,0 +1,21 @@ +const shareVisitorStorageKey = "img-infinite-canvas.share.visitor"; + +export function activeShareId() { + if (typeof window === "undefined") return ""; + const hashPath = window.location.hash.replace(/^#/, ""); + const path = hashPath || window.location.pathname; + const [pathOnly] = path.split(/[?#]/); + const match = pathOnly.replace(/\/+$/, "").match(/^\/share\/([^/]+)$/); + return match ? decodeURIComponent(match[1]) : ""; +} + +export function getShareVisitorId() { + if (typeof window === "undefined") return ""; + const stored = window.localStorage.getItem(shareVisitorStorageKey)?.trim(); + if (stored) return stored; + const bytes = new Uint8Array(18); + window.crypto.getRandomValues(bytes); + const visitorId = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); + window.localStorage.setItem(shareVisitorStorageKey, visitorId); + return visitorId; +} diff --git a/frontend/src/infrastructure/designGateway/sharing.ts b/frontend/src/infrastructure/designGateway/sharing.ts new file mode 100644 index 0000000..302485d --- /dev/null +++ b/frontend/src/infrastructure/designGateway/sharing.ts @@ -0,0 +1,63 @@ +import type { ResolveShareResponse, SharePermission } from "@/domain/design"; + +import { request } from "./request"; +import type { ShareSettingsResponse, ShareVisitorsResponse } from "./responses"; +import { getShareVisitorId } from "./shareContext"; + +function resolveShare(shareId: string) { + return request(`/api/shares/${encodeURIComponent(shareId)}`, { + headers: { "X-Share-Visitor": getShareVisitorId() } + }); +} + +function getShareSettings(projectId: string) { + return request(`/api/projects/${projectId}/sharing`); +} + +function updateShareLink(projectId: string, permission: Exclude) { + return request(`/api/projects/${projectId}/sharing/link`, { + method: "PUT", + body: JSON.stringify({ permission }) + }); +} + +function inviteShareMember(projectId: string, identifier: string, permission: Exclude) { + return request(`/api/projects/${projectId}/sharing/members`, { + method: "POST", + body: JSON.stringify({ identifier, permission }) + }); +} + +function updateShareMember(projectId: string, memberId: string, permission: Exclude) { + return request(`/api/projects/${projectId}/sharing/members/${memberId}`, { + method: "PATCH", + body: JSON.stringify({ permission }) + }); +} + +function deleteShareMember(projectId: string, memberId: string) { + return request(`/api/projects/${projectId}/sharing/members/${memberId}`, { + method: "DELETE" + }); +} + +function listShareVisitors(projectId: string) { + return request(`/api/projects/${projectId}/sharing/visitors`); +} + +function revokeShareAccess(projectId: string) { + return request(`/api/projects/${projectId}/sharing/access`, { + method: "DELETE" + }); +} + +export const sharingOperations = { + resolveShare, + getShareSettings, + updateShareLink, + inviteShareMember, + updateShareMember, + deleteShareMember, + listShareVisitors, + revokeShareAccess +}; diff --git a/frontend/src/infrastructure/designGateway/types.ts b/frontend/src/infrastructure/designGateway/types.ts new file mode 100644 index 0000000..d637ba0 --- /dev/null +++ b/frontend/src/infrastructure/designGateway/types.ts @@ -0,0 +1,42 @@ +import type { AgentMessage, CanvasConnection, CanvasNode, CanvasViewport, ExtractNodeTextResponse, Project } from "@/domain/design"; + +export type ImageGenerationOptions = { + imageModel?: string; + imageSize?: string; + imageQuality?: string; + imageCount?: number; + enableWebSearch?: boolean; + allowEmptyPrompt?: boolean; +}; + +export type ProjectEventHandlers = { + onProject?: (project: Project) => void; + onThinking?: (message: AgentMessage) => void; + onTextExtraction?: (result: ExtractNodeTextResponse) => void; + onDone?: (project: Project) => void; + onTimeout?: (project: Project) => void; + onError?: (error: Error) => void; +}; + +export type ProjectEventsOptions = { + threadId?: string; + eventsUrl?: string; +}; + +export type CanvasSaveProject = Pick & Partial; + +export type CanvasSnapshotDraft = { + snapshotId: string; + updatedAt: string; + projectCoverList: string[]; +}; + +export type CanvasSnapshotInput = { + projectId: string; + version: string; + snapshotId: string; + updatedAt: string; + viewport: CanvasViewport; + nodes: CanvasNode[]; + connections: CanvasConnection[]; +}; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index a67a734..de89584 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/sharepopover/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/infrastructure/designgateway/agents.ts","./src/infrastructure/designgateway/assets.ts","./src/infrastructure/designgateway/canvas.ts","./src/infrastructure/designgateway/canvassnapshot.ts","./src/infrastructure/designgateway/clientids.ts","./src/infrastructure/designgateway/events.ts","./src/infrastructure/designgateway/generation.ts","./src/infrastructure/designgateway/projects.ts","./src/infrastructure/designgateway/request.ts","./src/infrastructure/designgateway/responses.ts","./src/infrastructure/designgateway/sharecontext.ts","./src/infrastructure/designgateway/sharing.ts","./src/infrastructure/designgateway/types.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/sharepopover/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"} \ No newline at end of file