1038 lines
34 KiB
TypeScript
1038 lines
34 KiB
TypeScript
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<Project, "version" | "snapshotId" | "canvas"> & Partial<Project>;
|
|
|
|
type SaveProjectResponse = {
|
|
code: number;
|
|
msg: string | null;
|
|
data: {
|
|
projectId: string;
|
|
newCreated: boolean;
|
|
validProjectId: boolean;
|
|
version: string;
|
|
};
|
|
};
|
|
|
|
type QueryProjectResponse = {
|
|
code: number;
|
|
msg: string | null;
|
|
data: {
|
|
assetMeta: {
|
|
assetId: string;
|
|
projectId: string;
|
|
name: string;
|
|
extra?: {
|
|
projectCoverList?: string[];
|
|
};
|
|
updateTime: string;
|
|
};
|
|
userId: string | number;
|
|
projectName: string;
|
|
projectId: string | null;
|
|
canvas: string;
|
|
validProjectId: boolean;
|
|
projectType: number;
|
|
version: string;
|
|
snapshotId: string | null;
|
|
permission: string;
|
|
};
|
|
};
|
|
|
|
type CanvasSnapshotDraft = {
|
|
snapshotId: string;
|
|
updatedAt: string;
|
|
projectCoverList: string[];
|
|
};
|
|
|
|
type QueryProjectCanvasSnapshot = {
|
|
projectId?: string;
|
|
version?: string;
|
|
snapshotId?: string;
|
|
viewport?: CanvasViewport;
|
|
nodes?: Array<Partial<CanvasNode> | null>;
|
|
connections?: Array<Partial<CanvasConnection> | null>;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
type QueryAgentLastThreadResponse = {
|
|
code: number;
|
|
msg: string | null;
|
|
data: string;
|
|
};
|
|
|
|
type LovartAgentThreadListResponse = {
|
|
code: number;
|
|
msg: string | null;
|
|
data: {
|
|
page: number;
|
|
total: number;
|
|
pageSize: number;
|
|
data: Array<{
|
|
agentThreadId: string;
|
|
threadIdType: number;
|
|
text: string;
|
|
coverImageUrl: string | null;
|
|
updateTimestamp: number;
|
|
}>;
|
|
hasMore: boolean;
|
|
};
|
|
};
|
|
|
|
type CandaChatHistoryResponse = {
|
|
code: number;
|
|
message: string;
|
|
data: Array<{
|
|
id: string;
|
|
timestamp: string;
|
|
type: string;
|
|
action_id: string;
|
|
step_id: string;
|
|
text?: string;
|
|
content?: string;
|
|
status?: string;
|
|
tool_call_id?: string;
|
|
name?: string;
|
|
thread_meta?: {
|
|
session_id?: string;
|
|
project_id?: string;
|
|
};
|
|
}>;
|
|
};
|
|
|
|
const canvasDataPrefix = "SHAKKERDATA://";
|
|
const canvasSaveSessionStorageKey = "img-infinite-canvas.canvas.saveSessionId";
|
|
const shareVisitorStorageKey = "img-infinite-canvas.share.visitor";
|
|
|
|
function isVersionConflictError(error: unknown) {
|
|
return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409;
|
|
}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<T>;
|
|
}
|
|
|
|
export const designGateway = {
|
|
dashboard() {
|
|
return request<Dashboard>("/api/dashboard");
|
|
},
|
|
|
|
listProjects(limit = 20, offset = 0) {
|
|
const params = new URLSearchParams({
|
|
limit: String(limit),
|
|
offset: String(offset)
|
|
});
|
|
return request<ProjectListResponse>(`/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<QueryProjectResponse>("/api/canva/project/queryProject", {
|
|
method: "POST",
|
|
body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() })
|
|
});
|
|
const project = await projectFromQueryProjectResponse(response, id);
|
|
if (project) return { project };
|
|
} catch {
|
|
// Keep existing workspaces open if the Lovart-compatible document route is unavailable during development.
|
|
}
|
|
return getProjectDocument(id);
|
|
},
|
|
|
|
updateProjectTitle(id: string, title: string) {
|
|
return request<{ project: Project }>(`/api/projects/${id}/title`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ title })
|
|
});
|
|
},
|
|
|
|
deleteProject(id: string) {
|
|
return request<DeleteProjectResponse>(`/api/projects/${id}`, {
|
|
method: "DELETE"
|
|
});
|
|
},
|
|
|
|
deleteProjects(ids: string[]) {
|
|
return request<DeleteProjectsResponse>("/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<CandaChatHistoryResponse>(`/api/canda/chat-history?${params.toString()}`);
|
|
return {
|
|
projectId: id,
|
|
threadId: resolvedThreadId,
|
|
messages: response.data.map((item) => messageFromCandaHistoryItem(item, resolvedThreadId))
|
|
};
|
|
} catch {
|
|
return getLegacyProjectHistory(id, threadId);
|
|
}
|
|
},
|
|
|
|
async listAgentThreads(id: string) {
|
|
if (activeShareId()) {
|
|
return request<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
|
|
}
|
|
try {
|
|
const response = await request<LovartAgentThreadListResponse>("/api/canva/agent/agentThreadList", {
|
|
method: "POST",
|
|
body: JSON.stringify({ projectId: id, page: 1, pageSize: 100, cid: createQueryProjectCid() })
|
|
});
|
|
return {
|
|
projectId: id,
|
|
threads: response.data.data.map((thread) => ({
|
|
agentThreadId: thread.agentThreadId,
|
|
coverImageUrl: thread.coverImageUrl ?? "",
|
|
text: thread.text,
|
|
threadIdType: thread.threadIdType,
|
|
updateTimestamp: thread.updateTimestamp
|
|
}))
|
|
} satisfies AgentThreadListResponse;
|
|
} catch {
|
|
return request<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
|
|
}
|
|
},
|
|
|
|
async queryAgentLastThread(id: string) {
|
|
const response = await request<QueryAgentLastThreadResponse>("/api/canva/agent/queryAgentLastThread", {
|
|
method: "POST",
|
|
body: JSON.stringify({ projectId: id, cid: createQueryProjectCid() })
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
generate(projectId: string, prompt: string, mode = "design", image?: ImageGenerationOptions) {
|
|
return request<GenerateResult>(`/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<AgentThreadResponse>(`/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<GeneratorTaskResponse>(`/api/generator/tasks?${params.toString()}`);
|
|
},
|
|
|
|
submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record<string, unknown>) {
|
|
return request<GeneratorTaskSubmitResponse>("/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<RecognizeObjectResponse>("/api/context/vision/recognize_object", {
|
|
method: "POST",
|
|
body: JSON.stringify({ image_url: imageUrl, bounding_box: boundingBox, lang })
|
|
});
|
|
},
|
|
|
|
generateMockupModel(projectId: string, nodeId: string) {
|
|
return request<AgentThreadResponse>(`/api/projects/${projectId}/nodes/${nodeId}/mockup-model`, {
|
|
method: "POST",
|
|
body: JSON.stringify({})
|
|
});
|
|
},
|
|
|
|
extractNodeText(projectId: string, nodeId: string, prompt = "") {
|
|
return request<ExtractNodeTextResponse>(`/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<AgentThreadResponse>(`/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<ResolveShareResponse>(`/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<SharePermission, "owner">) {
|
|
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/link`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ permission })
|
|
});
|
|
},
|
|
|
|
inviteShareMember(projectId: string, identifier: string, permission: Exclude<SharePermission, "private" | "owner">) {
|
|
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ identifier, permission })
|
|
});
|
|
},
|
|
|
|
updateShareMember(projectId: string, memberId: string, permission: Exclude<SharePermission, "private" | "owner">) {
|
|
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<string, string>;
|
|
}>("/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<SaveProjectResponse>(`/api/projects/${projectId}/canvas`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
try {
|
|
return await request<SaveProjectResponse>("/api/canva/project/saveProject", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
} catch (error) {
|
|
if (isVersionConflictError(error)) throw error;
|
|
return request<SaveProjectResponse>(`/api/projects/${projectId}/canvas`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
}
|
|
|
|
function 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<CanvasViewport> | null | undefined): CanvasViewport {
|
|
const k = numberValue(fieldValue(viewport, "k", "K"), 1);
|
|
return {
|
|
x: numberValue(fieldValue(viewport, "x", "X"), 0),
|
|
y: numberValue(fieldValue(viewport, "y", "Y"), 0),
|
|
k: k > 0 ? k : 1
|
|
};
|
|
}
|
|
|
|
function normalizeCanvasNodes(nodes: QueryProjectCanvasSnapshot["nodes"]) {
|
|
const seenIds = new Set<string>();
|
|
return (nodes ?? []).map((node, index) => normalizeCanvasNode(node, index, seenIds));
|
|
}
|
|
|
|
function normalizeCanvasNode(node: Partial<CanvasNode> | null | undefined, index: number, seenIds: Set<string>): CanvasNode {
|
|
const source = node ?? {};
|
|
let id = stringValue(fieldValue(source, "id", "ID"));
|
|
if (!id || seenIds.has(id)) {
|
|
id = `node-${index}-${createClientId()}`;
|
|
}
|
|
seenIds.add(id);
|
|
|
|
return {
|
|
...source,
|
|
id,
|
|
type: canvasNodeType(fieldValue(source, "type", "Type")),
|
|
title: stringValue(fieldValue(source, "title", "Title")),
|
|
x: numberValue(fieldValue(source, "x", "X"), 0),
|
|
y: numberValue(fieldValue(source, "y", "Y"), 0),
|
|
width: numberValue(fieldValue(source, "width", "Width"), 320),
|
|
height: numberValue(fieldValue(source, "height", "Height"), 240),
|
|
content: stringValue(fieldValue(source, "content", "Content")),
|
|
tone: stringValue(fieldValue(source, "tone", "Tone")),
|
|
status: stringValue(fieldValue(source, "status", "Status")) || "success",
|
|
parentId: stringValue(fieldValue(source, "parentId", "ParentID")) || undefined,
|
|
layerRole: stringValue(fieldValue(source, "layerRole", "LayerRole")) || undefined,
|
|
fontSize: optionalNumberValue(fieldValue(source, "fontSize", "FontSize")),
|
|
fontFamily: stringValue(fieldValue(source, "fontFamily", "FontFamily")) || undefined,
|
|
fontWeight: stringValue(fieldValue(source, "fontWeight", "FontWeight")) || undefined,
|
|
color: stringValue(fieldValue(source, "color", "Color")) || undefined,
|
|
textAlign: canvasTextAlign(fieldValue(source, "textAlign", "TextAlign")),
|
|
lineHeight: optionalNumberValue(fieldValue(source, "lineHeight", "LineHeight")),
|
|
letterSpacing: optionalNumberValue(fieldValue(source, "letterSpacing", "LetterSpacing")),
|
|
textDecoration: stringValue(fieldValue(source, "textDecoration", "TextDecoration")) || undefined,
|
|
textTransform: stringValue(fieldValue(source, "textTransform", "TextTransform")) || undefined,
|
|
opacity: optionalNumberValue(fieldValue(source, "opacity", "Opacity")),
|
|
fillColor: stringValue(fieldValue(source, "fillColor", "FillColor")) || undefined,
|
|
strokeColor: stringValue(fieldValue(source, "strokeColor", "StrokeColor")) || undefined,
|
|
strokeWidth: optionalNumberValue(fieldValue(source, "strokeWidth", "StrokeWidth")),
|
|
strokeStyle: stringValue(fieldValue(source, "strokeStyle", "StrokeStyle")) || undefined,
|
|
flipX: Boolean(fieldValue(source, "flipX", "FlipX")),
|
|
flipY: Boolean(fieldValue(source, "flipY", "FlipY")),
|
|
rotation: optionalNumberValue(fieldValue(source, "rotation", "Rotation")),
|
|
imageAdjustments: stringValue(fieldValue(source, "imageAdjustments", "ImageAdjustments")) || undefined,
|
|
mockupSurface: (fieldValue(source, "mockupSurface", "MockupSurface") as CanvasNode["mockupSurface"]) || undefined,
|
|
mockupModel: (fieldValue(source, "mockupModel", "MockupModel") as CanvasNode["mockupModel"]) || undefined,
|
|
mockupSourceContent: stringValue(fieldValue(source, "mockupSourceContent", "MockupSourceContent")) || undefined
|
|
};
|
|
}
|
|
|
|
function normalizeCanvasConnections(connections: QueryProjectCanvasSnapshot["connections"], nodes: CanvasNode[]) {
|
|
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
const seenIds = new Set<string>();
|
|
return (connections ?? []).flatMap((connection, index) => {
|
|
const source = connection ?? {};
|
|
const fromNodeId = stringValue(fieldValue(source, "fromNodeId", "FromNodeID"));
|
|
const toNodeId = stringValue(fieldValue(source, "toNodeId", "ToNodeID"));
|
|
if (!fromNodeId || !toNodeId || !nodeIds.has(fromNodeId) || !nodeIds.has(toNodeId)) return [];
|
|
let id = stringValue(fieldValue(source, "id", "ID"));
|
|
if (!id || seenIds.has(id)) {
|
|
id = `connection-${index}-${fromNodeId}-${toNodeId}`;
|
|
}
|
|
seenIds.add(id);
|
|
return [{ id, fromNodeId, toNodeId }];
|
|
});
|
|
}
|
|
|
|
function canvasNodeType(value: unknown): CanvasNode["type"] {
|
|
return value === "brief" || value === "reference" || value === "image" || value === "text" || value === "frame" ? value : "image";
|
|
}
|
|
|
|
function canvasTextAlign(value: unknown): CanvasNode["textAlign"] | undefined {
|
|
return value === "left" || value === "center" || value === "right" ? value : undefined;
|
|
}
|
|
|
|
function fieldValue(source: unknown, ...keys: string[]) {
|
|
if (!source || typeof source !== "object") return undefined;
|
|
const record = source as Record<string, unknown>;
|
|
for (const key of keys) {
|
|
if (record[key] !== undefined) return record[key];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function firstNonEmptyString(...values: unknown[]) {
|
|
for (const value of values) {
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function stringValue(value: unknown) {
|
|
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
}
|
|
|
|
function numberValue(value: unknown, fallback: number) {
|
|
const next = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
return Number.isFinite(next) ? next : fallback;
|
|
}
|
|
|
|
function optionalNumberValue(value: unknown) {
|
|
const next = numberValue(value, Number.NaN);
|
|
return Number.isFinite(next) ? next : undefined;
|
|
}
|
|
|
|
async function encodeCanvasSnapshot({
|
|
projectId,
|
|
version,
|
|
snapshotId,
|
|
updatedAt,
|
|
viewport,
|
|
nodes,
|
|
connections
|
|
}: {
|
|
projectId: string;
|
|
version: string;
|
|
snapshotId: string;
|
|
updatedAt: string;
|
|
viewport: CanvasViewport;
|
|
nodes: CanvasNode[];
|
|
connections: CanvasConnection[];
|
|
}) {
|
|
if (typeof CompressionStream === "undefined" || typeof Blob === "undefined" || typeof Response === "undefined" || typeof btoa === "undefined") {
|
|
return "";
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
projectId,
|
|
version,
|
|
snapshotId,
|
|
viewport,
|
|
nodes,
|
|
connections,
|
|
updatedAt
|
|
});
|
|
const stream = new Blob([payload], { type: "application/json" }).stream().pipeThrough(new CompressionStream("gzip"));
|
|
const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
return `${canvasDataPrefix}${bytesToBase64(compressed)}`;
|
|
}
|
|
|
|
async function decodeCanvasSnapshot(canvas: string): Promise<QueryProjectCanvasSnapshot | null> {
|
|
if (
|
|
!canvas.startsWith(canvasDataPrefix) ||
|
|
typeof DecompressionStream === "undefined" ||
|
|
typeof Blob === "undefined" ||
|
|
typeof Response === "undefined" ||
|
|
typeof atob === "undefined"
|
|
) {
|
|
return null;
|
|
}
|
|
const compressed = base64ToBytes(canvas.slice(canvasDataPrefix.length));
|
|
const stream = new Blob([compressed], { type: "application/gzip" }).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
return (await new Response(stream).json()) as QueryProjectCanvasSnapshot;
|
|
}
|
|
|
|
function bytesToBase64(bytes: Uint8Array) {
|
|
const chunkSize = 0x8000;
|
|
let binary = "";
|
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToBytes(value: string) {
|
|
const binary = atob(value);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function createCanvasSnapshotDraft(nodes: CanvasNode[]): CanvasSnapshotDraft {
|
|
return {
|
|
snapshotId: createClientId(),
|
|
updatedAt: new Date().toISOString(),
|
|
projectCoverList: projectCoverListFromNodes(nodes)
|
|
};
|
|
}
|
|
|
|
function projectCoverListFromNodes(nodes: CanvasNode[]) {
|
|
return nodes
|
|
.filter((node) => (node.type === "image" || node.type === "frame") && node.status === "success" && isCanvasCoverUrl(node.content))
|
|
.map((node) => node.content.trim())
|
|
.slice(0, 12);
|
|
}
|
|
|
|
function isCanvasCoverUrl(value: string) {
|
|
const url = value.trim();
|
|
return Boolean(url) && !url.startsWith("data:") && !url.startsWith("blob:");
|
|
}
|
|
|
|
function getCanvasSaveSessionId() {
|
|
if (typeof window === "undefined") return createClientId();
|
|
try {
|
|
const current = window.sessionStorage.getItem(canvasSaveSessionStorageKey);
|
|
if (current) return current;
|
|
const next = createClientId();
|
|
window.sessionStorage.setItem(canvasSaveSessionStorageKey, next);
|
|
return next;
|
|
} catch {
|
|
return createClientId();
|
|
}
|
|
}
|
|
|
|
function createClientId() {
|
|
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
function createQueryProjectCid() {
|
|
return `${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
function mergeSavedCanvasProject(
|
|
projectId: string,
|
|
viewport: CanvasViewport,
|
|
nodes: CanvasNode[],
|
|
connections: CanvasConnection[],
|
|
current: CanvasSaveProject | undefined,
|
|
draft: CanvasSnapshotDraft,
|
|
canvas: string,
|
|
response: SaveProjectResponse
|
|
): Project {
|
|
return {
|
|
id: current?.id ?? response.data.projectId ?? projectId,
|
|
userId: current?.userId,
|
|
title: current?.title || "Untitled",
|
|
brief: current?.brief ?? "",
|
|
status: current?.status || "ready",
|
|
thumbnail: current?.thumbnail || draft.projectCoverList[0] || "",
|
|
updatedAt: draft.updatedAt,
|
|
version: response.data.version || current?.version || "",
|
|
snapshotId: draft.snapshotId,
|
|
canvas: canvas || current?.canvas || "",
|
|
lastThreadId: current?.lastThreadId ?? "",
|
|
viewport,
|
|
nodes,
|
|
connections,
|
|
messages: current?.messages ?? []
|
|
};
|
|
}
|
|
|
|
function projectEventsUrl(projectId: string, options: ProjectEventsOptions) {
|
|
if (options.eventsUrl) return options.eventsUrl;
|
|
const params = new URLSearchParams();
|
|
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"));
|
|
}
|
|
}
|