Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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}` : ""}`;
|
||||
}
|
||||
Reference in New Issue
Block a user