351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
|
|
import type {
|
||
|
|
AgentChatPayload,
|
||
|
|
AgentThreadListResponse,
|
||
|
|
AgentMessage,
|
||
|
|
AgentThreadResponse,
|
||
|
|
CanvasConnection,
|
||
|
|
CanvasNode,
|
||
|
|
CanvasViewport,
|
||
|
|
Dashboard,
|
||
|
|
DeleteProjectResponse,
|
||
|
|
DeleteProjectsResponse,
|
||
|
|
ExtractNodeTextResponse,
|
||
|
|
GeneratorTaskResponse,
|
||
|
|
GenerateResult,
|
||
|
|
MergeLayersRequest,
|
||
|
|
NodeActionRequest,
|
||
|
|
NormalizedBox,
|
||
|
|
Project,
|
||
|
|
RecognizeObjectResponse,
|
||
|
|
TextExtractionSource,
|
||
|
|
ProjectListResponse
|
||
|
|
} from "@/domain/design";
|
||
|
|
import { authHeaders } from "@/infrastructure/authGateway";
|
||
|
|
import { createApiFailure, createNetworkFailure, USER_MESSAGE_KEYS, httpStatusOf, isUserMessageKey } from "@/infrastructure/userMessages";
|
||
|
|
import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding";
|
||
|
|
|
||
|
|
type ImageGenerationOptions = {
|
||
|
|
imageModel?: string;
|
||
|
|
imageSize?: string;
|
||
|
|
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;
|
||
|
|
};
|
||
|
|
|
||
|
|
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));
|
||
|
|
|
||
|
|
let response: Response;
|
||
|
|
try {
|
||
|
|
response = await fetch(path, {
|
||
|
|
...init,
|
||
|
|
headers
|
||
|
|
});
|
||
|
|
} 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 })
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
getProject(id: string) {
|
||
|
|
return request<{ project: Project }>(`/api/projects/${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 })
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
getProjectHistory(id: string) {
|
||
|
|
return request<Pick<Project, "messages"> & { projectId: string }>(`/api/projects/${id}/history`);
|
||
|
|
},
|
||
|
|
|
||
|
|
listAgentThreads(id: string) {
|
||
|
|
return request<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
|
||
|
|
},
|
||
|
|
|
||
|
|
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 = payload.threadId ? designGateway.projectEvents(projectId, handlers, { threadId: payload.threadId }) : null;
|
||
|
|
|
||
|
|
void designGateway
|
||
|
|
.agentChat(projectId, payload)
|
||
|
|
.then((response) => {
|
||
|
|
if (closed) return;
|
||
|
|
handlers.onProject?.(response.project);
|
||
|
|
if (!closeEvents) {
|
||
|
|
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()}`);
|
||
|
|
},
|
||
|
|
|
||
|
|
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)
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) {
|
||
|
|
const source = new EventSource(projectEventsUrl(projectId, options));
|
||
|
|
let settled = false;
|
||
|
|
const readProject = (event: MessageEvent) => {
|
||
|
|
const payload = JSON.parse(event.data) as { project: Project };
|
||
|
|
return payload.project;
|
||
|
|
};
|
||
|
|
|
||
|
|
source.addEventListener("project", (event) => handlers.onProject?.(readProject(event as MessageEvent)));
|
||
|
|
source.addEventListener("thinking", (event) => handlers.onThinking?.(JSON.parse((event as MessageEvent).data) as AgentMessage));
|
||
|
|
source.addEventListener("text_extraction", (event) => handlers.onTextExtraction?.(JSON.parse((event as MessageEvent).data) as ExtractNodeTextResponse));
|
||
|
|
source.addEventListener("done", (event) => {
|
||
|
|
settled = true;
|
||
|
|
handlers.onDone?.(readProject(event as MessageEvent));
|
||
|
|
source.close();
|
||
|
|
});
|
||
|
|
source.addEventListener("timeout", (event) => {
|
||
|
|
settled = true;
|
||
|
|
handlers.onTimeout?.(readProject(event as MessageEvent));
|
||
|
|
source.close();
|
||
|
|
});
|
||
|
|
source.addEventListener("stream_error", (event) => {
|
||
|
|
settled = true;
|
||
|
|
if ((event as MessageEvent).data) {
|
||
|
|
const payload = JSON.parse((event as MessageEvent).data) as { error?: string };
|
||
|
|
handlers.onError?.(new Error(payload.error ?? "Event stream failed"));
|
||
|
|
} else {
|
||
|
|
handlers.onError?.(new Error("Event stream failed"));
|
||
|
|
}
|
||
|
|
source.close();
|
||
|
|
});
|
||
|
|
source.onerror = () => {
|
||
|
|
if (settled || source.readyState === EventSource.CLOSED) return;
|
||
|
|
handlers.onError?.(new Error("Event stream failed"));
|
||
|
|
source.close();
|
||
|
|
};
|
||
|
|
|
||
|
|
return () => source.close();
|
||
|
|
},
|
||
|
|
|
||
|
|
saveCanvas(
|
||
|
|
projectId: string,
|
||
|
|
viewport: CanvasViewport,
|
||
|
|
nodes: CanvasNode[],
|
||
|
|
connections: CanvasConnection[],
|
||
|
|
snapshot?: Pick<Project, "version" | "snapshotId" | "canvas">
|
||
|
|
) {
|
||
|
|
const payload = { viewport, nodes, connections, ...snapshot };
|
||
|
|
const save = () =>
|
||
|
|
request<{ project: Project }>(`/api/projects/${projectId}/canvas`, {
|
||
|
|
method: "PATCH",
|
||
|
|
body: JSON.stringify(payload)
|
||
|
|
});
|
||
|
|
|
||
|
|
return (async () => {
|
||
|
|
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 uploadFile = await encodeUploadImage(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: uploadFile.name,
|
||
|
|
contentType: uploadFile.type || file.type || "application/octet-stream",
|
||
|
|
size: uploadFile.size
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
|
||
|
|
await fetch(upload.uploadUrl, {
|
||
|
|
method: "PUT",
|
||
|
|
headers: upload.headers,
|
||
|
|
body: uploadFile
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return upload.publicUrl;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
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}` : ""}`;
|
||
|
|
}
|