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; } 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; } }; 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")); } }