Files
moteva/frontend/src/infrastructure/designGateway.ts
T

349 lines
11 KiB
TypeScript
Raw Normal View History

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 = 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()}`);
},
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}` : ""}`;
}