From 2551c6477f845ac4f1ae38356cf6c46ae825a204 Mon Sep 17 00:00:00 2001 From: shensz2017 Date: Mon, 12 Jan 2026 15:51:47 +0800 Subject: [PATCH] Create AI Image Studio monorepo skeleton --- .gitignore | 8 ++ apps/api/package.json | 30 ++++ apps/api/src/index.ts | 135 +++++++++++++++++ apps/api/src/jobs/service.ts | 93 ++++++++++++ apps/api/src/jobs/store.ts | 27 ++++ apps/api/src/jobs/types.ts | 18 +++ apps/api/src/openapi.ts | 13 ++ apps/api/src/providers/mock.ts | 18 +++ apps/api/tests/job-store.test.ts | 37 +++++ apps/api/tsconfig.json | 14 ++ apps/web/app/layout.tsx | 17 +++ apps/web/app/page.tsx | 240 +++++++++++++++++++++++++++++++ apps/web/next-env.d.ts | 4 + apps/web/next.config.mjs | 6 + apps/web/package.json | 21 +++ apps/web/styles/globals.css | 204 ++++++++++++++++++++++++++ apps/web/tsconfig.json | 17 +++ apps/worker/package.json | 19 +++ apps/worker/src/index.ts | 24 ++++ apps/worker/tsconfig.json | 13 ++ docs/INSTALL.md | 37 +++++ package.json | 15 ++ 22 files changed, 1010 insertions(+) create mode 100644 .gitignore create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/jobs/service.ts create mode 100644 apps/api/src/jobs/store.ts create mode 100644 apps/api/src/jobs/types.ts create mode 100644 apps/api/src/openapi.ts create mode 100644 apps/api/src/providers/mock.ts create mode 100644 apps/api/tests/job-store.test.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/styles/globals.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 docs/INSTALL.md create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfffbf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +.dist +.DS_Store +.env +.env.local +*.log +coverage diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..a9fcc87 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,30 @@ +{ + "name": "ai-image-studio-api", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "uuid": "^9.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.12.12", + "@types/swagger-ui-express": "^4.1.6", + "@types/uuid": "^9.0.8", + "tsx": "^4.11.2", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..d88d252 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,135 @@ +import express from "express"; +import cors from "cors"; +import swaggerUi from "swagger-ui-express"; +import { z } from "zod"; +import { JobService } from "./jobs/service"; +import { JobStore } from "./jobs/store"; +import { JobType } from "./jobs/types"; +import { openapiSpec } from "./openapi"; + +const app = express(); +const port = process.env.PORT ? Number(process.env.PORT) : 4000; + +app.use(cors()); +app.use(express.json({ limit: "2mb" })); + +const store = new JobStore(); +const service = new JobService(store); + +/** + * @openapi + * /health: + * get: + * summary: Health check + * responses: + * 200: + * description: OK + */ +app.get("/health", (_req, res) => { + res.json({ status: "ok" }); +}); + +/** + * @openapi + * /jobs: + * post: + * summary: Create a new generation or edit job + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * enum: [txt2img, img2img, inpaint, outpaint, upscale, bgremove] + * input: + * type: object + * responses: + * 201: + * description: Job created + */ +app.post("/jobs", (req, res) => { + const schema = z.object({ + type: z.enum(["txt2img", "img2img", "inpaint", "outpaint", "upscale", "bgremove"]), + input: z.record(z.unknown()).default({}) + }); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request", details: parsed.error.flatten() }); + } + + const job = service.createJob(parsed.data.type as JobType, parsed.data.input); + return res.status(201).json(job); +}); + +/** + * @openapi + * /jobs: + * get: + * summary: List jobs + * responses: + * 200: + * description: Job list + */ +app.get("/jobs", (_req, res) => { + res.json(service.listJobs()); +}); + +/** + * @openapi + * /jobs/{id}: + * get: + * summary: Get job status + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Job details + * 404: + * description: Not found + */ +app.get("/jobs/:id", (req, res) => { + const job = service.getJob(req.params.id); + if (!job) { + return res.status(404).json({ error: "Job not found" }); + } + return res.json(job); +}); + +/** + * @openapi + * /jobs/{id}/cancel: + * post: + * summary: Cancel a job + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Job canceled + * 404: + * description: Not found + */ +app.post("/jobs/:id/cancel", (req, res) => { + const job = service.cancelJob(req.params.id); + if (!job) { + return res.status(404).json({ error: "Job not found" }); + } + return res.json(job); +}); + +app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiSpec)); + +app.listen(port, () => { + console.log(`API listening on http://localhost:${port}`); +}); diff --git a/apps/api/src/jobs/service.ts b/apps/api/src/jobs/service.ts new file mode 100644 index 0000000..5f3fac2 --- /dev/null +++ b/apps/api/src/jobs/service.ts @@ -0,0 +1,93 @@ +import { v4 as uuidv4 } from "uuid"; +import { JobRecord, JobStatus, JobType } from "./types"; +import { JobStore } from "./store"; +import { buildMockResult } from "../providers/mock"; + +const randomDuration = () => 3000 + Math.floor(Math.random() * 4000); + +export class JobService { + private readonly timers = new Map(); + + constructor(private readonly store: JobStore) {} + + createJob(type: JobType, input: Record): JobRecord { + const id = uuidv4(); + const now = new Date().toISOString(); + const job: JobRecord = { + id, + type, + status: "queued", + progress: 0, + input, + createdAt: now + }; + + this.store.create(job); + this.startJob(id); + return job; + } + + cancelJob(id: string): JobRecord | undefined { + const current = this.store.get(id); + if (!current || current.status === "succeeded" || current.status === "failed") { + return current; + } + const timer = this.timers.get(id); + if (timer) { + clearInterval(timer); + this.timers.delete(id); + } + return this.store.update(id, (job) => ({ + ...job, + status: "canceled", + finishedAt: new Date().toISOString() + })); + } + + getJob(id: string): JobRecord | undefined { + return this.store.get(id); + } + + listJobs(): JobRecord[] { + return this.store.list(); + } + + private startJob(id: string): void { + const duration = randomDuration(); + const interval = 500; + const steps = Math.ceil(duration / interval); + let tick = 0; + + this.store.update(id, (job) => ({ + ...job, + status: "running", + startedAt: new Date().toISOString() + })); + + const timer = setInterval(() => { + tick += 1; + const progress = Math.min(100, Math.round((tick / steps) * 100)); + const status: JobStatus = progress >= 100 ? "succeeded" : "running"; + + const next = this.store.update(id, (job) => { + if (job.status === "canceled") { + return job; + } + return { + ...job, + status, + progress, + output: progress >= 100 ? buildMockResult(job.type, job.id) : job.output, + finishedAt: progress >= 100 ? new Date().toISOString() : job.finishedAt + }; + }); + + if (!next || next.status === "canceled" || status === "succeeded") { + clearInterval(timer); + this.timers.delete(id); + } + }, interval); + + this.timers.set(id, timer); + } +} diff --git a/apps/api/src/jobs/store.ts b/apps/api/src/jobs/store.ts new file mode 100644 index 0000000..ac6727c --- /dev/null +++ b/apps/api/src/jobs/store.ts @@ -0,0 +1,27 @@ +import { JobRecord } from "./types"; + +export class JobStore { + private readonly jobs = new Map(); + + create(job: JobRecord): void { + this.jobs.set(job.id, job); + } + + update(id: string, updater: (current: JobRecord) => JobRecord): JobRecord | undefined { + const current = this.jobs.get(id); + if (!current) { + return undefined; + } + const next = updater(current); + this.jobs.set(id, next); + return next; + } + + get(id: string): JobRecord | undefined { + return this.jobs.get(id); + } + + list(): JobRecord[] { + return Array.from(this.jobs.values()); + } +} diff --git a/apps/api/src/jobs/types.ts b/apps/api/src/jobs/types.ts new file mode 100644 index 0000000..e686f6f --- /dev/null +++ b/apps/api/src/jobs/types.ts @@ -0,0 +1,18 @@ +export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "canceled"; + +export type JobType = "txt2img" | "img2img" | "inpaint" | "outpaint" | "upscale" | "bgremove"; + +export interface JobRecord { + id: string; + type: JobType; + status: JobStatus; + progress: number; + input: Record; + output?: { + images: Array<{ url: string; content?: string }>; + }; + error?: string; + createdAt: string; + startedAt?: string; + finishedAt?: string; +} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000..88d74cc --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,13 @@ +import swaggerJsdoc from "swagger-jsdoc"; + +export const openapiSpec = swaggerJsdoc({ + definition: { + openapi: "3.0.0", + info: { + title: "AI Image Studio API", + version: "0.1.0", + description: "Minimal API for AI Image Studio MVP." + } + }, + apis: ["./src/index.ts"] +}); diff --git a/apps/api/src/providers/mock.ts b/apps/api/src/providers/mock.ts new file mode 100644 index 0000000..7d9a305 --- /dev/null +++ b/apps/api/src/providers/mock.ts @@ -0,0 +1,18 @@ +import { JobType } from "../jobs/types"; + +export interface MockGenerationResult { + images: Array<{ url: string; content?: string }>; +} + +export const buildMockResult = (jobType: JobType, seed: string): MockGenerationResult => { + const label = `${jobType.toUpperCase()} preview`; + const size = jobType === "upscale" ? "1024" : "768"; + return { + images: [ + { + url: `https://picsum.photos/seed/${encodeURIComponent(seed)}/${size}/${size}`, + content: label + } + ] + }; +}; diff --git a/apps/api/tests/job-store.test.ts b/apps/api/tests/job-store.test.ts new file mode 100644 index 0000000..e29cf16 --- /dev/null +++ b/apps/api/tests/job-store.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { JobStore } from "../src/jobs/store"; +import { JobRecord } from "../src/jobs/types"; + +const createJob = (id: string): JobRecord => ({ + id, + type: "txt2img", + status: "queued", + progress: 0, + input: {}, + createdAt: new Date().toISOString() +}); + +describe("JobStore", () => { + it("creates and retrieves jobs", () => { + const store = new JobStore(); + const job = createJob("job-1"); + + store.create(job); + + expect(store.get("job-1")).toEqual(job); + }); + + it("updates jobs", () => { + const store = new JobStore(); + const job = createJob("job-2"); + + store.create(job); + + const updated = store.update("job-2", (current) => ({ + ...current, + progress: 42 + })); + + expect(updated?.progress).toBe(42); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..f15167c --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..c8b50e9 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,17 @@ +import "../styles/globals.css"; +import type { ReactNode } from "react"; + +export const metadata = { + title: "AI Image Studio", + description: "AI Image Studio MVP" +}; + +const RootLayout = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +export default RootLayout; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..e445ab9 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; + +type JobStatus = "queued" | "running" | "succeeded" | "failed" | "canceled"; + +type JobRecord = { + id: string; + type: string; + status: JobStatus; + progress: number; + input: Record; + output?: { + images: Array<{ url: string; content?: string }>; + }; +}; + +const stylePresets = ["Studio", "Cinematic", "Analog", "Soft Light"]; +const aspectRatios = ["1:1", "16:9", "9:16", "4:3", "3:2"]; + +export default function HomePage() { + const [prompt, setPrompt] = useState("一间未来感工作室,氛围光线,极简风格"); + const [negativePrompt, setNegativePrompt] = useState("low quality, blurry"); + const [style, setStyle] = useState(stylePresets[0]); + const [ratio, setRatio] = useState(aspectRatios[0]); + const [seed, setSeed] = useState(""); + const [steps, setSteps] = useState(28); + const [cfg, setCfg] = useState(6.5); + const [count, setCount] = useState(2); + const [currentJob, setCurrentJob] = useState(null); + const [history, setHistory] = useState([]); + const [error, setError] = useState(null); + + const payload = useMemo( + () => ({ + prompt, + negative_prompt: negativePrompt, + style, + aspect_ratio: ratio, + seed: seed || "random", + steps, + cfg, + n: count + }), + [prompt, negativePrompt, style, ratio, seed, steps, cfg, count] + ); + + const submitJob = async () => { + setError(null); + const response = await fetch(`${API_BASE}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "txt2img", input: payload }) + }); + + if (!response.ok) { + setError("任务创建失败,请检查 API 服务是否启动。"); + return; + } + + const job = (await response.json()) as JobRecord; + setCurrentJob(job); + setHistory((prev) => [job, ...prev]); + }; + + useEffect(() => { + if (!currentJob) { + return; + } + + const timer = setInterval(async () => { + const response = await fetch(`${API_BASE}/jobs/${currentJob.id}`); + if (!response.ok) { + setError("无法获取任务状态。"); + return; + } + const job = (await response.json()) as JobRecord; + setCurrentJob(job); + setHistory((prev) => { + const exists = prev.find((item) => item.id === job.id); + if (exists) { + return prev.map((item) => (item.id === job.id ? job : item)); + } + return [job, ...prev]; + }); + }, 1000); + + return () => clearInterval(timer); + }, [currentJob]); + + const previews = currentJob?.output?.images ?? []; + + return ( +
+
+
AI IMAGE STUDIO
+
Project: Neon Workspace · Queue: {currentJob?.status ?? "idle"}
+ +
+ + + +
+
+ {previews.length === 0 ? ( +
+
Canvas Preview
+
生成任务完成后将在此处加载结果图。
+
+ ) : ( +
+ {previews.map((item) => ( +
+ {item.content +
+ ))} +
+ )} +
+
+ +