feat: complete TASK-WP2-01 project foundations

This commit is contained in:
suyx
2026-07-28 20:12:12 +08:00
parent 59e566885b
commit 4d38530361
19 changed files with 3696 additions and 12 deletions
+250
View File
@@ -29,6 +29,20 @@ import {
LogoutResponseSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
GenerationProjectItemSchema,
ProjectDetailResponseSchema,
ProjectIdSchema,
ProjectImageItemSchema,
ProjectListQuerySchema,
ProjectListResponseSchema,
ProjectParamsSchema,
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
@@ -48,6 +62,10 @@ import {
type AccountProfileUpdateRequest,
type LoginCompleteRequest,
type LoginSendRequest,
type FailedEmptyTrashRequest,
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -70,6 +88,8 @@ import {
import { EventHub } from "./event-hub.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import { ProjectError } from "./project-errors.js";
import type { ProjectService } from "./projects.js";
import {
RegistrationError,
registrationFieldError,
@@ -96,6 +116,7 @@ export interface CreateAppOptions {
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
projects?: ProjectService;
registration?: RegistrationService;
}
@@ -163,6 +184,64 @@ function registrationValidationFailure(reply: FastifyReply, correlationId: strin
);
}
function projectFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (!(error instanceof ProjectError)) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
const mapping = {
generation_state_invalid: 400,
project_active_limit: 409,
project_history_limit: 409,
project_name_invalid: 400,
project_not_found: 404,
project_ratio_fixed: 409,
project_retry_not_allowed: 409,
} as const;
return reply.code(mapping[error.code]).send(null);
}
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
function projectSummaryResponse(project: ProjectSummaryView) {
return {
current_image_id: project.currentImageId,
deleted_at: project.deletedAt,
name: project.name,
project_id: project.projectId,
purge_at: project.purgeAt,
ratio: project.ratio,
state_version: project.stateVersion,
status: project.status,
successful_image_count: project.successfulImageCount,
updated_at: project.updatedAt,
};
}
function projectDetailResponse(project: ProjectDetailView) {
return {
...projectSummaryResponse(project),
created_at: project.createdAt,
draft_prompt: project.draftPrompt,
generations: project.generations.map((generation) => ({
created_at: generation.createdAt,
error_category: generation.errorCategory,
generation_id: generation.generationId,
prompt: generation.prompt,
ratio: generation.ratio,
status: generation.status,
updated_at: generation.updatedAt,
})),
images: project.images.map((image) => ({
created_at: image.createdAt,
generation_id: image.generationId,
image_id: image.imageId,
})),
pixel_height: project.pixelHeight,
pixel_width: project.pixelWidth,
};
}
function isSupportGateRequest(method: string, path: string) {
if (method === "POST" && path === "/api/v1/support/check") return true;
if (method !== "GET" && method !== "HEAD") return false;
@@ -261,6 +340,20 @@ export async function createApp(options: CreateAppOptions = {}) {
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
SseEventSchema,
ProjectIdSchema,
ProjectRatioSchema,
ProjectViewStatusSchema,
ProjectSummarySchema,
ProjectListQuerySchema,
ProjectListResponseSchema,
ProjectParamsSchema,
GenerationProjectItemSchema,
ProjectImageItemSchema,
ProjectDetailResponseSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
]) {
app.addSchema(schema);
}
@@ -920,6 +1013,163 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/projects",
{
attachValidation: true,
schema: {
operationId: "listProjects",
querystring: Type.Ref(ProjectListQuerySchema),
response: {
200: Type.Ref(ProjectListResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
const query = request.query as ProjectListQuery;
const status = query.status ?? "active";
return {
active_count: options.projects.activeProjectCount(session.userId),
active_limit: 20 as const,
projects: options.projects.listProjects(session.userId, status).slice(0, 20).map(projectSummaryResponse),
};
},
);
app.get(
"/api/v1/projects/:projectId",
{
attachValidation: true,
schema: {
operationId: "getProject",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectDetailResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
return projectDetailResponse(options.projects.getProject(session.userId, (request.params as ProjectParams).projectId));
} catch (error) {
return projectFailure(reply, request.id, error);
}
},
);
app.patch(
"/api/v1/projects/:projectId",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectRenameRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "renameProject",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectRenameResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const renamed = options.projects.renameProject(
owner.userId,
(request.params as ProjectParams).projectId,
(request.body as ProjectRenameRequest).name,
);
return { name: renamed.name, state_version: renamed.stateVersion, status: "renamed" as const };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/projects/failed-empty/trash",
{
attachValidation: true,
schema: {
body: Type.Ref(FailedEmptyTrashRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "trashFailedEmptyProjects",
response: {
200: Type.Ref(FailedEmptyTrashResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const result = options.projects.trashFailedEmpty(owner.userId, (request.body as FailedEmptyTrashRequest).project_ids);
return { ignored_project_ids: result.ignoredProjectIds, trashed_project_ids: result.trashedProjectIds };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/support/check",
{
+9 -1
View File
@@ -7,6 +7,7 @@ import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js";
import { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js";
import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
@@ -15,6 +16,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
let projects: ProjectService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -23,17 +25,21 @@ if (credentialChannelEnabled) {
.update(`Dada/P0A/${purpose}/v1`, "utf8")
.digest();
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
const databasePath = join(dataRoot, "db", "dada.sqlite3");
registration = new RegistrationService({
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
challengePepper: derivePepper("challenge-pepper"),
currentPrivacyNoticeVersion: registrationNotice.version,
databasePath: join(dataRoot, "db", "dada.sqlite3"),
databasePath,
invitePepper: derivePepper("invite-pepper"),
resend: new MockResendAdapter(),
sessionPepper: derivePepper("session-pepper"),
});
projects = new ProjectService({ databasePath });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
projects?.close();
projects = undefined;
registration?.close();
registration = undefined;
throw error;
@@ -45,6 +51,7 @@ if (credentialChannelEnabled) {
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp({
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(projects ? { projects } : {}),
...(registration ? { registration } : {}),
});
@@ -60,6 +67,7 @@ if (controlPipeIndex >= 0) {
let storage: ManagedStorage | undefined;
const control = attachApiSupervisorControl(controlPipe, async () => {
await app.close();
projects?.close();
registration?.close();
storage?.close();
});
+17
View File
@@ -0,0 +1,17 @@
export type ProjectErrorCode =
| "generation_state_invalid"
| "project_active_limit"
| "project_history_limit"
| "project_name_invalid"
| "project_not_found"
| "project_ratio_fixed"
| "project_retry_not_allowed";
export class ProjectError extends Error {
readonly code: ProjectErrorCode;
constructor(code: ProjectErrorCode) {
super(code);
this.code = code;
}
}
+463
View File
@@ -0,0 +1,463 @@
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
import { ProjectError } from "./project-errors.js";
export { ProjectError } from "./project-errors.js";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
export type ProjectRatio = typeof projectRatios[number];
export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
"3:4": { height: 1440, width: 1080 },
"1:1": { height: 1080, width: 1080 },
"4:3": { height: 1080, width: 1440 },
"9:16": { height: 1920, width: 1080 },
};
const projectLimit = 20;
const historyLimit = 10;
const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000;
const generationErrorCategories = new Set([
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
"unknown_retryable", "unknown_non_retryable",
]);
interface ProjectRow {
created_at: number;
current_image_id: string | null;
deleted_at: number | null;
draft_prompt: string;
name: string;
owner_id: string;
pixel_height: number;
pixel_width: number;
project_id: string;
purge_at: number | null;
ratio: ProjectRatio;
state_version: number;
status: "active" | "trashed" | "purged";
updated_at: number;
}
interface GenerationRow {
created_at: number;
error_category: string | null;
generation_id: string;
prompt: string;
project_id: string;
ratio: ProjectRatio;
status: GenerationStatus;
updated_at: number;
}
interface ImageRow {
created_at: number;
generation_id: string;
image_id: string;
}
function isProjectRatio(value: string): value is ProjectRatio {
return projectRatios.includes(value as ProjectRatio);
}
function normalizePrompt(value: string) {
const normalized = value.trim().replace(/\s+/gu, " ");
if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid");
return normalized;
}
function takeGraphemes(value: string, count: number) {
const Segmenter = Intl.Segmenter;
if (Segmenter) {
return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)]
.slice(0, count)
.map((entry) => entry.segment)
.join("");
}
return Array.from(value).slice(0, count).join("");
}
function localDate(timestamp: number) {
const date = new Date(timestamp);
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
.map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0"))
.join("-");
}
function defaultProjectName(prompt: string, timestamp: number) {
const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作";
return `${summary} ${localDate(timestamp)}`;
}
function normalizeProjectName(value: string) {
const normalized = value.trim().replace(/\s+/gu, " ");
if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid");
return normalized;
}
function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
export class ProjectService {
readonly database: BetterSqlite3.Database;
private readonly clock: () => number;
constructor(input: { clock?: () => number; databasePath: string }) {
this.clock = input.clock ?? Date.now;
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON");
this.database.pragma("synchronous = FULL");
this.database.pragma("busy_timeout = 5000");
this.migrate();
}
close() {
this.database.close();
}
createProjectForGeneration(input: {
ownerId: string;
prompt: string;
ratio: ProjectRatio;
status: Exclude<GenerationStatus, "succeeded">;
}) {
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
const prompt = normalizePrompt(input.prompt);
const projectId = randomUUID();
const generationId = randomUUID();
const now = this.clock();
const pixels = ratioPixels[input.ratio];
const transaction = this.database.transaction(() => {
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
.get(input.ownerId) as { count: number };
if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
this.database.prepare(`
INSERT INTO projects (
project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
`).run(
projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
pixels.width, pixels.height, now, now,
);
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
});
transaction.immediate();
return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) };
}
continueProjectGeneration(input: {
ownerId: string;
projectId: string;
prompt: string;
ratio: ProjectRatio;
status: Exclude<GenerationStatus, "succeeded">;
}) {
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
const generationId = randomUUID();
const prompt = normalizePrompt(input.prompt);
const now = this.clock();
const transaction = this.database.transaction(() => {
const project = this.readOwnedProject(input.ownerId, input.projectId);
if (project.status !== "active") throw new ProjectError("project_not_found");
if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed");
if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit");
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now);
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?")
.run(prompt, now, input.projectId);
});
transaction.immediate();
return this.readGeneration(generationId);
}
retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) {
const project = this.getProject(input.ownerId, input.projectId);
const latest = project.generations.at(-1);
if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) {
throw new ProjectError("project_retry_not_allowed");
}
return this.continueProjectGeneration({
ownerId: input.ownerId,
projectId: input.projectId,
prompt: input.prompt,
ratio: project.ratio,
status: "queued",
});
}
recordSuccessfulImage(input: { generationId: string; imageId: string }) {
const now = this.clock();
const transaction = this.database.transaction(() => {
const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?")
.get(input.generationId) as GenerationRow | undefined;
if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid");
if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit");
this.database.prepare(`
UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
`).run(now, input.generationId);
this.database.prepare(`
INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)
`).run(input.imageId, generation.project_id, input.generationId, now);
this.database.prepare(`
UPDATE projects
SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
WHERE project_id = ?
`).run(input.imageId, now, generation.project_id);
});
transaction.immediate();
}
markGenerationFailed(generationId: string, errorCategory: string) {
if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid");
const status = errorCategory === "safety_rejected" ? "rejected" : "failed";
const changed = this.database.prepare(`
UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ?
WHERE generation_id = ? AND status IN ('queued', 'running')
`).run(status, errorCategory, this.clock(), generationId);
if (changed.changes !== 1) throw new ProjectError("generation_state_invalid");
}
renameProject(ownerId: string, projectId: string, name: string) {
const normalized = normalizeProjectName(name);
const now = this.clock();
const changed = this.database.prepare(`
UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
WHERE owner_id = ? AND project_id = ? AND status = 'active'
`).run(normalized, now, ownerId, projectId);
if (changed.changes !== 1) throw new ProjectError("project_not_found");
return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
}
trashFailedEmpty(ownerId: string, projectIds: string[]) {
const uniqueIds = [...new Set(projectIds)];
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
const trashedProjectIds: string[] = [];
const ignoredProjectIds: string[] = [];
const now = this.clock();
const transaction = this.database.transaction(() => {
for (const projectId of uniqueIds) {
const row = this.database.prepare(`
SELECT p.project_id,
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active'
`).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined;
if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) {
ignoredProjectIds.push(projectId);
continue;
}
this.database.prepare(`
UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
WHERE project_id = ?
`).run(now, now + trashRetentionMilliseconds, now, projectId);
trashedProjectIds.push(projectId);
}
});
transaction.immediate();
return { ignoredProjectIds, trashedProjectIds };
}
listProjects(ownerId: string, status: "active" | "trashed") {
const rows = this.database.prepare(`
SELECT p.*,
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
FROM projects p
WHERE p.owner_id = ? AND p.status = ?
ORDER BY p.updated_at DESC, p.project_id DESC
`).all(ownerId, status) as Array<ProjectRow & { image_count: number; latest_status: string | null }>;
return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status));
}
getProject(ownerId: string, projectId: string) {
const row = this.readOwnedProject(ownerId, projectId);
if (row.status === "purged") throw new ProjectError("project_not_found");
const generations = this.database.prepare(`
SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid
`).all(projectId) as GenerationRow[];
const images = this.database.prepare(`
SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid
`).all(projectId) as ImageRow[];
const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
return {
...summary,
createdAt: iso(row.created_at),
draftPrompt: row.draft_prompt,
generations: generations.map((generation) => this.generationView(generation)),
images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })),
pixelHeight: row.pixel_height,
pixelWidth: row.pixel_width,
};
}
activeProjectCount(ownerId: string) {
const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
.get(ownerId) as { count: number };
return row.count;
}
private insertGeneration(input: {
generationId: string;
ownerId: string;
projectId: string;
prompt: string;
ratio: ProjectRatio;
status: Exclude<GenerationStatus, "succeeded">;
}, now: number) {
const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null;
this.database.prepare(`
INSERT INTO generation_jobs (
generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now);
}
private readGeneration(generationId: string) {
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined;
if (!row) throw new ProjectError("generation_state_invalid");
return this.generationView(row);
}
private generationView(row: GenerationRow) {
return {
createdAt: iso(row.created_at),
errorCategory: row.error_category,
generationId: row.generation_id,
projectId: row.project_id,
prompt: row.prompt,
ratio: row.ratio,
status: row.status,
updatedAt: iso(row.updated_at),
};
}
private readOwnedProject(ownerId: string, projectId: string) {
const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?")
.get(ownerId, projectId) as ProjectRow | undefined;
if (!row) throw new ProjectError("project_not_found");
return row;
}
private successfulImageCount(projectId: string) {
const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
.get(projectId) as { count: number };
return row.count;
}
private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) {
const status: ProjectViewStatus = row.status === "trashed"
? "trashed"
: imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus)
? "failed_empty"
: "active";
return {
currentImageId: row.current_image_id,
deletedAt: row.deleted_at === null ? null : iso(row.deleted_at),
name: row.name,
projectId: row.project_id,
purgeAt: row.purge_at === null ? null : iso(row.purge_at),
ratio: row.ratio,
stateVersion: row.state_version,
status,
successfulImageCount: imageCount,
updatedAt: iso(row.updated_at),
};
}
private migrate() {
this.database.exec(`
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000),
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
pixel_width INTEGER NOT NULL,
pixel_height INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')),
state_version INTEGER NOT NULL CHECK (state_version >= 1),
current_image_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
purge_at INTEGER,
UNIQUE(project_id, ratio),
CHECK (
(ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR
(ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR
(ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR
(ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920)
),
CHECK (
(status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR
(status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR
(status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC);
DROP TRIGGER IF EXISTS projects_active_insert_limit;
CREATE TRIGGER projects_active_insert_limit
BEFORE INSERT ON projects
WHEN NEW.status = 'active' AND (
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
) >= ${projectLimit}
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
DROP TRIGGER IF EXISTS projects_active_restore_limit;
CREATE TRIGGER projects_active_restore_limit
BEFORE UPDATE OF status ON projects
WHEN OLD.status <> 'active' AND NEW.status = 'active' AND (
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
) >= ${projectLimit}
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
CREATE TABLE IF NOT EXISTS generation_jobs (
generation_id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
project_id TEXT NOT NULL,
prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000),
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')),
error_category TEXT CHECK (error_category IS NULL OR error_category IN (
'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
'unknown_retryable', 'unknown_non_retryable'
)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(generation_id, project_id),
FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE,
CHECK (
(status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR
(status IN ('failed', 'rejected') AND error_category IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id);
CREATE TABLE IF NOT EXISTS project_images (
image_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
generation_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id);
DROP TRIGGER IF EXISTS projects_ratio_immutable;
CREATE TRIGGER projects_ratio_immutable
BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects
WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height
BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END;
DROP TRIGGER IF EXISTS project_images_history_limit;
CREATE TRIGGER project_images_history_limit
BEFORE INSERT ON project_images
WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
`);
}
}
+9
View File
@@ -985,6 +985,11 @@ export class RegistrationService {
});
}
authorizeUserMutation(input: { csrfToken: string; sessionToken: string }) {
const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, this.options.clock());
return { userId: session.user_id };
}
logoutUser(input: { csrfToken: string; sessionToken: string }) {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
@@ -1196,6 +1201,10 @@ export class RegistrationService {
this.database.prepare("DELETE FROM privacy_consents WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM credit_accounts WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM user_profiles WHERE user_id = ?").run(session.user_id);
const projectsTable = this.database.prepare(`
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects'
`).get();
if (projectsTable) this.database.prepare("DELETE FROM projects WHERE owner_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email);
this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?")
+33 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, UserSessionResponse, LogoutResponse, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -138,6 +138,13 @@ export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string
return `${options.baseUrl ?? ""}/api/v1/events`;
}
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectDetailResponse>;
}
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -145,6 +152,13 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
return response.json() as Promise<UserSessionResponse>;
}
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectListResponse>;
}
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
@@ -152,6 +166,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { body: JSON.stringify(body), method: "PATCH", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectRenameResponse>;
}
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
@@ -186,6 +209,15 @@ export async function sendRegistrationCode(body: RegistrationSendRequest, option
return response.json() as Promise<RegistrationSendResponse>;
}
export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, options: ClientOptions = {}): Promise<FailedEmptyTrashResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/failed-empty/trash`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<FailedEmptyTrashResponse>;
}
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+87
View File
@@ -187,8 +187,27 @@ export type ErrorEnvelope = {
};
};
export type FailedEmptyTrashRequest = {
"project_ids": Array<ProjectId>;
};
export type FailedEmptyTrashResponse = {
"ignored_project_ids": Array<ProjectId>;
"trashed_project_ids": Array<ProjectId>;
};
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
export type GenerationProjectItem = {
"created_at": string;
"error_category": GenerationErrorCategory | null;
"generation_id": string;
"prompt": string;
"ratio": ProjectRatio;
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
"updated_at": string;
};
export type LoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
@@ -231,6 +250,74 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number;
};
export type ProjectDetailResponse = {
"created_at": string;
"current_image_id": ProjectId | null;
"deleted_at": string | null;
"draft_prompt": string;
"generations": Array<GenerationProjectItem>;
"images": Array<ProjectImageItem>;
"name": string;
"pixel_height": number;
"pixel_width": number;
"project_id": ProjectId;
"purge_at": string | null;
"ratio": ProjectRatio;
"state_version": number;
"status": ProjectViewStatus;
"successful_image_count": number;
"updated_at": string;
};
export type ProjectId = string;
export type ProjectImageItem = {
"created_at": string;
"generation_id": string;
"image_id": ProjectId;
};
export type ProjectListQuery = {
"status"?: "active" | "trashed";
};
export type ProjectListResponse = {
"active_count": number;
"active_limit": 20;
"projects": Array<ProjectSummary>;
};
export type ProjectParams = {
"projectId": ProjectId;
};
export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16";
export type ProjectRenameRequest = {
"name": string;
};
export type ProjectRenameResponse = {
"name": string;
"state_version": number;
"status": "renamed";
};
export type ProjectSummary = {
"current_image_id": ProjectId | null;
"deleted_at": string | null;
"name": string;
"project_id": ProjectId;
"purge_at": string | null;
"ratio": ProjectRatio;
"state_version": number;
"status": ProjectViewStatus;
"successful_image_count": number;
"updated_at": string;
};
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type RegistrationCompleteHeaders = {
"idempotency-key": string;
};
+13 -6
View File
@@ -5,6 +5,7 @@ import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
import { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js";
import { AccountSettingsPage } from "./account-settings.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
const root = document.getElementById("root");
@@ -20,11 +21,14 @@ let authRevision = 0;
function renderAuthenticationEntry() {
authRevision += 1;
const authenticationPage = window.location.pathname === "/app/settings"
? <AccountSettingsPage key={authRevision} />
: window.location.pathname.startsWith("/admin")
? <AdminAuthPage key={authRevision} />
: <UserAuthPage key={authRevision} />;
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
let authenticationPage;
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render(
<StrictMode>
{authenticationPage}
@@ -33,5 +37,8 @@ function renderAuthenticationEntry() {
}
// Any authenticated surface can dispatch this after a revoked/invalid session response.
window.addEventListener("dada:session-invalid", renderAuthenticationEntry);
window.addEventListener("dada:session-invalid", () => {
if (window.location.pathname.startsWith("/app")) window.history.replaceState(null, "", "/");
renderAuthenticationEntry();
});
renderAuthenticationEntry();
+995
View File
@@ -0,0 +1,995 @@
.product-page,
.product-loading {
min-height: 100vh;
color: #111111;
background: #f6f6f4;
}
.product-page {
display: flex;
flex-direction: column;
}
.product-loading {
display: grid;
place-content: center;
gap: 16px;
text-align: center;
}
.product-loading button,
.product-loading a {
min-height: 44px;
padding: 11px 18px;
border: 1px solid #111111;
color: #111111;
background: #ffffff;
text-decoration: none;
}
.product-header {
display: flex;
min-height: 68px;
align-items: stretch;
padding: 0 max(3vw, 32px);
border-bottom: 1px solid #9c9c96;
background: #ffffff;
}
.product-brand {
display: flex;
align-items: center;
margin-right: 24px;
color: #111111;
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 25px;
font-weight: 900;
text-decoration: none;
}
.product-header nav {
display: flex;
align-items: stretch;
gap: 4px;
}
.product-header nav a {
display: flex;
min-width: 72px;
align-items: center;
justify-content: center;
padding: 0 14px;
color: #111111;
font-weight: 700;
text-decoration: none;
}
.product-header nav a[aria-current="page"] {
background: #f2f500;
}
.workspace-art {
position: relative;
display: grid;
min-height: 238px;
grid-template-columns: minmax(330px, 0.7fr) minmax(500px, 1.7fr);
gap: 40px;
align-items: center;
overflow: hidden;
padding: 30px max(3vw, 32px);
border-bottom: 1px solid #9c9c96;
background: #eeeee9;
}
.workspace-art > div:first-child {
z-index: 2;
}
.workspace-art strong {
display: block;
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 90px;
font-weight: 900;
line-height: 0.9;
}
.workspace-art h1 {
margin: 20px 0 0;
font-size: 25px;
}
.workspace-art-system {
position: relative;
display: grid;
min-height: 160px;
align-content: center;
justify-items: end;
padding: 32px 14%;
border-bottom: 1px solid #65655f;
font-family: Consolas, monospace;
font-size: 12px;
}
.workspace-art-system::before {
position: absolute;
top: 20px;
left: 4%;
width: 50%;
height: 105px;
background: #b9b9b4;
content: "";
}
.workspace-art-system::after {
position: absolute;
top: 62px;
right: 8%;
width: 58%;
height: 70px;
background: #f2f500;
content: "";
}
.workspace-art-system i {
position: absolute;
z-index: 1;
top: 0;
right: 33%;
width: 1px;
height: 100%;
background: #4f4f4a;
}
.workspace-art-system b {
z-index: 2;
min-width: 190px;
}
.workspace-main {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
max-width: 1540px;
margin: 0 auto;
padding: 28px max(3vw, 32px) 64px;
}
.generation-area {
min-width: 0;
}
.workspace-compact-heading,
.recent-projects header,
.project-current > header,
.project-history > header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.workspace-compact-heading {
margin-bottom: 18px;
}
.workspace-compact-heading p,
.projects-title p,
.project-detail-header p {
margin: 0 0 4px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.workspace-compact-heading h1,
.projects-title h1,
.project-detail-header h1 {
margin: 0;
}
.prompt-label {
display: block;
margin-bottom: 10px;
font-size: 17px;
font-weight: 800;
}
.generation-area textarea {
width: 100%;
min-height: 124px;
resize: vertical;
padding: 16px;
border: 1px solid #6f6f69;
border-radius: 0;
background: #ffffff;
line-height: 1.6;
}
.generation-options {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(360px, 0.8fr);
gap: 10px;
margin-top: 12px;
}
.model-status,
.ratio-control,
.reference-input {
border: 1px solid #8f8f89;
background: #ffffff;
}
.model-status {
display: grid;
gap: 6px;
align-content: center;
min-height: 76px;
padding: 12px 14px;
}
.model-status span,
.ratio-control legend,
.reference-input small {
color: #65655f;
font-size: 12px;
}
.ratio-control {
min-width: 0;
margin: 0;
padding: 8px 12px 12px;
}
.ratio-control legend {
padding: 0 4px;
}
.ratio-control > div {
display: grid;
grid-template-columns: repeat(4, minmax(58px, 1fr));
gap: 6px;
}
.ratio-control label {
min-width: 0;
}
.ratio-control input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.ratio-control span {
display: grid;
min-height: 38px;
place-items: center;
border: 1px solid #85857f;
font-family: Consolas, monospace;
font-weight: 700;
}
.ratio-control input:checked + span {
border-color: #111111;
background: #f2f500;
}
.ratio-control input:focus-visible + span {
outline: 2px solid #1769aa;
outline-offset: 2px;
}
.reference-input {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 20px;
align-items: center;
min-height: 76px;
margin-top: 12px;
padding: 13px 16px;
cursor: pointer;
}
.reference-input > span {
font-weight: 800;
}
.reference-input small {
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.reference-input input {
grid-column: 1 / -1;
width: 100%;
}
.generation-submit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 16px;
}
.generation-submit span {
color: #5b5b55;
font-size: 13px;
}
.generation-submit button,
.failed-draft-bar button {
min-width: 220px;
min-height: 48px;
border: 1px solid #111111;
background: #f2f500;
font-weight: 800;
}
.generation-submit button:disabled,
.failed-draft-bar button:disabled {
cursor: not-allowed;
background: #deded9;
color: #777770;
}
.current-task {
grid-column: 2;
grid-row: 1 / span 2;
min-height: 430px;
padding: 22px;
border: 1px solid #75756f;
background: #ffffff;
}
.current-task h2 {
margin: 0;
font-size: 18px;
}
.current-task-empty {
display: grid;
min-height: 330px;
place-content: center;
gap: 12px;
justify-items: center;
text-align: center;
}
.current-task-empty i {
width: 34px;
height: 34px;
border: 3px dashed #777770;
border-radius: 50%;
}
.current-task-empty span {
color: #6a6a64;
font-size: 13px;
}
.recent-projects {
grid-column: 1;
margin-top: 28px;
}
.recent-projects h2 {
margin: 0;
font-size: 18px;
}
.recent-projects a {
color: #1769aa;
font-weight: 700;
}
.projects-page,
.project-detail-page {
width: min(1340px, calc(100% - 64px));
margin: 0 auto;
padding: 44px 0 80px;
}
.projects-title {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #a4a49e;
}
.projects-title h1 {
font-size: 40px;
}
.projects-title > strong {
font-family: Consolas, monospace;
font-size: 15px;
}
.projects-tabs {
display: flex;
gap: 0;
margin-top: 22px;
}
.projects-tabs button {
min-width: 132px;
min-height: 44px;
border: 1px solid #868680;
background: #ffffff;
font-weight: 700;
}
.projects-tabs button + button {
border-left: 0;
}
.projects-tabs button[aria-selected="true"] {
background: #111111;
color: #ffffff;
}
.projects-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 170px 170px;
gap: 10px;
margin: 14px 0 24px;
}
.projects-controls input,
.projects-controls select {
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid #8a8a84;
border-radius: 0;
background: #ffffff;
}
.project-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.project-grid.compact {
margin-top: 14px;
}
.project-card {
position: relative;
min-width: 0;
border: 1px solid #7b7b75;
background: #ffffff;
}
.project-select {
position: absolute;
z-index: 2;
top: 12px;
left: 12px;
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid #111111;
background: #ffffff;
}
.project-select input {
width: 18px;
height: 18px;
}
.project-placeholder {
display: grid;
height: 154px;
grid-template-columns: repeat(4, 1fr);
overflow: hidden;
border-bottom: 1px solid #a5a59f;
background: #d8d8d3;
}
.project-placeholder span {
display: grid;
place-items: end center;
padding-bottom: 14px;
color: #b2b2ad;
font-family: Arial Black, sans-serif;
font-size: 54px;
}
.project-placeholder span:nth-child(2) {
background: #c4c4bf;
}
.project-placeholder span:nth-child(3) {
background: #ecece8;
}
.project-card[data-status="failed_empty"] .project-placeholder {
background: #e7e7e2;
filter: grayscale(1);
}
.project-card[data-status="active"] .project-placeholder span:nth-child(3) {
background: #f2f500;
}
.project-card-body {
display: grid;
min-height: 170px;
grid-template-rows: minmax(48px, auto) auto auto 1fr;
gap: 8px;
padding: 16px;
}
.project-card-body > div {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.project-card h3 {
display: -webkit-box;
overflow: hidden;
margin: 0;
font-size: 17px;
line-height: 1.35;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.project-card-body > div span {
flex: 0 0 auto;
padding: 3px 6px;
background: #e9e9e5;
font-family: Consolas, monospace;
font-size: 10px;
}
.project-card-body p,
.project-card-body time {
margin: 0;
color: #676761;
font-size: 12px;
}
.project-card-body > a {
align-self: end;
justify-self: start;
color: #111111;
font-weight: 800;
}
.projects-stale,
.projects-notice {
padding: 12px 14px;
border-left: 5px solid #d14a3b;
background: #fff1ef;
font-weight: 700;
}
.projects-notice {
border-color: #287b45;
background: #edf8f0;
}
.projects-empty {
display: grid;
min-height: 360px;
place-content: center;
justify-items: center;
text-align: center;
}
.projects-empty a,
.projects-empty button {
min-height: 44px;
padding: 11px 18px;
border: 1px solid #111111;
color: #111111;
background: #f2f500;
font-weight: 700;
text-decoration: none;
}
.failed-draft-bar {
position: sticky;
z-index: 5;
bottom: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 24px;
padding: 14px 18px;
border: 1px solid #111111;
background: #ffffff;
box-shadow: 6px 6px 0 #111111;
}
.project-detail-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding-bottom: 22px;
border-bottom: 1px solid #9f9f99;
}
.project-detail-header > a {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 1px solid #111111;
color: #111111;
font-size: 20px;
text-decoration: none;
}
.project-detail-header h1 {
overflow-wrap: anywhere;
font-size: 34px;
}
.project-detail-header > span {
padding: 9px 12px;
border: 1px solid #8e8e88;
font-family: Consolas, monospace;
font-weight: 700;
}
.project-identity {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 28px;
align-items: center;
padding: 22px 0;
border-bottom: 1px solid #c2c2bc;
}
.project-identity h2,
.project-identity p {
margin: 0;
}
.project-identity p {
margin-top: 4px;
color: #6b6b65;
font-family: Consolas, monospace;
font-size: 11px;
}
.project-identity form {
display: grid;
grid-template-columns: minmax(0, 1fr) 130px;
gap: 8px;
}
.project-identity input,
.project-identity button {
min-width: 0;
min-height: 44px;
border: 1px solid #85857f;
border-radius: 0;
}
.project-identity input {
padding: 9px 12px;
background: #ffffff;
}
.project-identity button {
background: #f2f500;
font-weight: 800;
}
.project-identity form span {
grid-column: 1 / -1;
font-size: 12px;
}
.project-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 28px;
padding-top: 28px;
}
.project-current > header,
.project-history > header {
min-height: 42px;
margin-bottom: 12px;
}
.project-current h2,
.project-history h2 {
margin: 0;
font-size: 19px;
}
.project-current > .project-placeholder {
height: auto;
min-height: 480px;
border: 1px solid #73736d;
}
.project-current > .project-placeholder span {
font-size: 80px;
}
.project-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.project-actions a,
.project-actions button,
.project-retry {
display: grid;
min-height: 44px;
place-items: center;
border: 1px solid #111111;
color: #111111;
background: #ffffff;
font-weight: 700;
text-align: center;
text-decoration: none;
}
.project-actions > :first-child {
background: #f2f500;
}
.project-actions button:disabled {
border-color: #b2b2ac;
color: #777770;
background: #dfdfda;
cursor: not-allowed;
}
.project-blocker {
margin: 10px 0 0;
color: #a52e24;
font-weight: 700;
}
.project-retry {
width: 180px;
margin-top: 12px;
background: #f2f500;
}
.project-history ol {
display: grid;
max-height: 650px;
gap: 10px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
}
.project-history li {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: 12px;
padding: 8px;
border: 1px solid #a4a49e;
background: #ffffff;
}
.project-history li[data-current="true"] {
border: 3px solid #111111;
padding: 6px;
}
.project-history li .project-placeholder {
height: 88px;
border: 0;
}
.project-history li .project-placeholder span {
padding-bottom: 7px;
font-size: 18px;
}
.project-history li > div:last-child {
display: grid;
align-content: center;
gap: 8px;
}
.project-history time {
color: #6b6b65;
font-size: 11px;
}
.history-empty {
display: grid;
min-height: 320px;
place-content: center;
padding: 28px;
border: 1px dashed #94948e;
text-align: center;
}
.history-empty p {
max-width: 320px;
overflow-wrap: anywhere;
color: #65655f;
}
.local-only-footer {
display: grid;
width: 100%;
min-height: 48px;
margin-top: auto;
place-items: center;
padding: 8px 20px;
color: #ffffff;
background: #111111;
font-size: 13px;
font-weight: 700;
text-align: center;
}
@media (max-width: 920px) {
.workspace-art {
grid-template-columns: 1fr;
}
.workspace-art-system {
display: none;
}
.workspace-main {
grid-template-columns: 1fr;
}
.current-task {
grid-column: 1;
grid-row: auto;
min-height: 260px;
}
.current-task-empty {
min-height: 190px;
}
.recent-projects {
grid-column: 1;
}
.generation-options,
.project-detail-grid {
grid-template-columns: 1fr;
}
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.product-header {
display: grid;
padding: 0 16px;
}
.product-brand {
min-height: 54px;
margin: 0;
}
.product-header nav {
min-width: 0;
overflow-x: auto;
border-top: 1px solid #d0d0ca;
}
.product-header nav a {
min-width: 68px;
min-height: 48px;
padding: 0 10px;
}
.workspace-art {
min-height: 190px;
padding: 28px 20px;
}
.workspace-art strong {
font-size: 66px;
}
.workspace-art h1 {
font-size: 23px;
}
.workspace-main,
.projects-page,
.project-detail-page {
width: 100%;
padding-right: 16px;
padding-left: 16px;
}
.generation-options,
.generation-submit,
.reference-input,
.projects-title,
.failed-draft-bar,
.project-identity {
grid-template-columns: 1fr;
}
.generation-submit,
.projects-title,
.failed-draft-bar {
align-items: stretch;
flex-direction: column;
}
.generation-submit button,
.failed-draft-bar button {
width: 100%;
min-width: 0;
}
.reference-input small {
text-align: left;
}
.projects-title {
display: grid;
}
.projects-controls {
grid-template-columns: 1fr;
}
.project-grid {
grid-template-columns: 1fr;
}
.project-detail-header {
grid-template-columns: 44px minmax(0, 1fr);
}
.project-detail-header > span {
grid-column: 2;
justify-self: start;
}
.project-identity {
display: grid;
gap: 14px;
}
.project-identity form {
grid-template-columns: 1fr;
}
.project-actions {
grid-template-columns: 1fr;
}
.project-current > .project-placeholder {
min-height: 360px;
}
.local-only-footer {
font-size: 11px;
}
}
+448
View File
@@ -0,0 +1,448 @@
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
import "./project-pages.css";
type Ratio = "3:4" | "1:1" | "4:3" | "9:16";
type ProjectStatus = "active" | "failed_empty" | "trashed";
interface SessionPayload {
credits: { available_balance: number; reserved_balance: number };
csrf_token: string;
user: { creator_name: string };
}
interface ProjectSummary {
current_image_id: string | null;
deleted_at?: string | null;
name: string;
project_id: string;
purge_at?: string | null;
ratio: Ratio;
state_version: number;
status: ProjectStatus;
successful_image_count: number;
updated_at: string;
}
interface ProjectListPayload {
active_count: number;
active_limit: 20;
projects: ProjectSummary[];
}
interface ProjectDetailPayload extends ProjectSummary {
created_at: string;
draft_prompt: string;
generations: Array<{
created_at: string;
error_category: string | null;
generation_id: string;
prompt: string;
ratio: Ratio;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updated_at: string;
}>;
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
pixel_height?: number;
pixel_width?: number;
}
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { credentials: "same-origin", ...init });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (!response.ok) throw new Error("request_failed");
return response.json() as Promise<T>;
}
function formatUpdatedAt(value: string) {
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
return (
<header className="product-header">
<a className="product-brand" href="/app">DADA</a>
<nav aria-label="主导航">
<a aria-current={current === "workspace" ? "page" : undefined} href="/app"></a>
<a aria-current={current === "projects" ? "page" : undefined} href="/app/projects"></a>
<a href="/app/credits"></a>
<a href="/app/settings"></a>
</nav>
</header>
);
}
function LocalOnlyFooter() {
return <footer className="local-only-footer"></footer>;
}
function LoadingPage({ label }: { label: string }) {
return <main className="product-loading" aria-live="polite">{label}</main>;
}
function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectStatus }) {
return (
<div className="project-placeholder" data-ratio={ratio} data-status={status} aria-hidden="true">
<span>D</span><span>A</span><span>D</span><span>A</span>
</div>
);
}
export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState<SessionPayload>();
const [projects, setProjects] = useState<ProjectListPayload>();
const [loadingFailed, setLoadingFailed] = useState(false);
const [prompt, setPrompt] = useState("");
const [ratio, setRatio] = useState<Ratio>("3:4");
const [references, setReferences] = useState<string[]>([]);
useEffect(() => {
let active = true;
Promise.all([
readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectListPayload>("/api/v1/projects?status=active"),
]).then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setProjects(nextProjects);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, []);
if (loadingFailed) {
return (
<main className="product-loading">
<p role="alert"></p>
<button onClick={() => window.location.reload()} type="button"></button>
</main>
);
}
if (!session || !projects) return <LoadingPage label="正在读取创作工作台" />;
const empty = projects.projects.length === 0;
return (
<div className="product-page">
<ProductHeader current="workspace" />
{empty ? (
<section className="workspace-art" aria-labelledby="workspace-title">
<div>
<strong>DADA</strong>
<h1 id="workspace-title"></h1>
</div>
<div className="workspace-art-system" aria-hidden="true">
<i /><b>NEW PROJECT</b><b>LOCAL ONLY</b><b>NO CLOUD SYNC</b>
</div>
</section>
) : null}
<main className={`workspace-main ${empty ? "is-empty" : "has-projects"}`}>
<section className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"}>
{!empty ? (
<div className="workspace-compact-heading">
<div><p>NEW PROJECT</p><h1 id="workspace-compact-title"></h1></div>
<span> {session.credits.available_balance} </span>
</div>
) : null}
<label className="prompt-label" htmlFor={promptId}></label>
<textarea
id={promptId}
maxLength={4_000}
onChange={(event) => setPrompt(event.target.value)}
placeholder="输入提示词…"
value={prompt}
/>
<div className="generation-options">
<div className="model-status" aria-live="polite">
<span></span>
<strong></strong>
</div>
<fieldset className="ratio-control">
<legend></legend>
<div>
{(["3:4", "1:1", "4:3", "9:16"] as const).map((value) => (
<label key={value}>
<input checked={ratio === value} name="ratio" onChange={() => setRatio(value)} type="radio" value={value} />
<span>{value}</span>
</label>
))}
</div>
</fieldset>
</div>
<label className="reference-input">
<span></span>
<small>{references.length > 0 ? references.join("、") : "尚未提交,仅保留在当前页面"}</small>
<input
accept="image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setReferences(Array.from(event.target.files ?? []).map((file) => file.name))}
type="file"
/>
</label>
<div className="generation-submit">
<span></span>
<button disabled type="button"></button>
</div>
</section>
<aside className="current-task" aria-labelledby="current-task-title">
<h2 id="current-task-title"></h2>
<div className="current-task-empty"><i /><strong></strong><span></span></div>
</aside>
{!empty ? (
<section className="recent-projects" aria-labelledby="recent-title">
<header><h2 id="recent-title"></h2><a href="/app/projects"></a></header>
<div className="project-grid compact">
{projects.projects.slice(0, 6).map((project) => <ProjectCard key={project.project_id} project={project} />)}
</div>
</section>
) : null}
</main>
<LocalOnlyFooter />
</div>
);
}
function ProjectCard({ project, selectable, selected, onSelect }: {
onSelect?: (selected: boolean) => void;
project: ProjectSummary;
selectable?: boolean;
selected?: boolean;
}) {
return (
<article className="project-card" data-status={project.status}>
{selectable ? (
<label className="project-select">
<input
aria-label={`选择失败草稿:${project.name}`}
checked={selected}
onChange={(event) => onSelect?.(event.target.checked)}
type="checkbox"
/>
</label>
) : null}
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} · {project.ratio}</p>
<time dateTime={project.updated_at}>{formatUpdatedAt(project.updated_at)}</time>
<a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}></a>
</div>
</article>
);
}
export function ProjectsPage() {
const [session, setSession] = useState<SessionPayload>();
const [payload, setPayload] = useState<ProjectListPayload>();
const [status, setStatus] = useState<"active" | "trashed">("active");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<"updated" | "name">("updated");
const [selected, setSelected] = useState<string[]>([]);
const [loadingFailed, setLoadingFailed] = useState(false);
const [notice, setNotice] = useState("");
useEffect(() => {
let active = true;
setLoadingFailed(false);
Promise.all([
session ? Promise.resolve(session) : readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectListPayload>(`/api/v1/projects?status=${status}`),
]).then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setPayload(nextProjects);
setSelected([]);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, [status]);
const visible = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase("zh-CN");
const items = (payload?.projects ?? []).filter((project) => project.name.toLocaleLowerCase("zh-CN").includes(normalized));
return items.toSorted((left, right) => sort === "name"
? left.name.localeCompare(right.name, "zh-CN")
: Date.parse(right.updated_at) - Date.parse(left.updated_at));
}, [payload, query, sort]);
async function batchTrash() {
if (!session || selected.length === 0) return;
try {
const result = await readJson<{ ignored_project_ids: string[]; trashed_project_ids: string[] }>("/api/v1/projects/failed-empty/trash", {
body: JSON.stringify({ project_ids: selected }),
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
setPayload((current) => current ? {
...current,
active_count: Math.max(0, current.active_count - result.trashed_project_ids.length),
projects: current.projects.filter((project) => !result.trashed_project_ids.includes(project.project_id)),
} : current);
setSelected([]);
setNotice("失败草稿已移入回收站");
} catch {
setNotice("操作未完成,请重试");
}
}
if (!payload && !loadingFailed) return <LoadingPage label="正在读取项目" />;
return (
<div className="product-page">
<ProductHeader current="projects" />
<main className="projects-page">
<header className="projects-title">
<div><p>LOCAL PROJECTS</p><h1></h1></div>
<strong>{payload?.active_count ?? 0} / 20 active</strong>
</header>
<div className="projects-tabs" role="tablist" aria-label="项目状态">
<button aria-selected={status === "active"} onClick={() => setStatus("active")} role="tab" type="button"></button>
<button aria-selected={status === "trashed"} onClick={() => setStatus("trashed")} role="tab" type="button"></button>
</div>
<div className="projects-controls">
<input aria-label="搜索项目" onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目" type="search" value={query} />
<select aria-label="状态筛选" value={status} onChange={(event) => setStatus(event.target.value as "active" | "trashed")}>
<option value="active">active</option><option value="trashed">trashed</option>
</select>
<select aria-label="项目排序" value={sort} onChange={(event) => setSort(event.target.value as "updated" | "name")}>
<option value="updated"></option><option value="name"></option>
</select>
</div>
{loadingFailed ? <p className="projects-stale" role="alert">{payload ? "当前显示上次读取的数据。" : ""}</p> : null}
{notice ? <p className="projects-notice" role="status">{notice}</p> : null}
{visible.length === 0 ? (
<section className="projects-empty">
<h2>{query ? "没有符合条件的项目" : status === "active" ? "还没有项目" : "回收站为空"}</h2>
{query ? <button onClick={() => setQuery("")} type="button"></button> : status === "active" ? <a href="/app"></a> : null}
</section>
) : (
<div className="project-grid">
{visible.map((project) => (
<ProjectCard
key={project.project_id}
onSelect={(checked) => setSelected((current) => checked
? [...current, project.project_id]
: current.filter((projectId) => projectId !== project.project_id))}
project={project}
selectable={status === "active" && project.status === "failed_empty"}
selected={selected.includes(project.project_id)}
/>
))}
</div>
)}
{status === "active" && payload?.projects.some((project) => project.status === "failed_empty") ? (
<section className="failed-draft-bar" aria-label="失败草稿快捷清理">
<span> {selected.length} 稿</span>
<button disabled={selected.length === 0} onClick={batchTrash} type="button"></button>
</section>
) : null}
</main>
<LocalOnlyFooter />
</div>
);
}
export function ProjectDetailPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<SessionPayload>();
const [project, setProject] = useState<ProjectDetailPayload>();
const [name, setName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameStatus, setNameStatus] = useState("");
const [loadingFailed, setLoadingFailed] = useState(false);
useEffect(() => {
let active = true;
Promise.all([
readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectDetailPayload>(`/api/v1/projects/${projectId}`),
]).then(([nextSession, nextProject]) => {
if (!active) return;
setSession(nextSession);
setProject(nextProject);
setName(nextProject.name);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, [projectId]);
async function rename(event: FormEvent) {
event.preventDefault();
if (!session || !project || savingName || !name.trim()) return;
setSavingName(true);
setNameStatus("");
try {
const result = await readJson<{ name: string; state_version: number }>(`/api/v1/projects/${projectId}`, {
body: JSON.stringify({ name }), headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token }, method: "PATCH",
});
setProject({ ...project, name: result.name, state_version: result.state_version });
setName(result.name);
setNameStatus("项目名已保存");
} catch {
setNameStatus("项目名保存失败");
} finally {
setSavingName(false);
}
}
if (!project && !loadingFailed) return <LoadingPage label="正在读取项目详情" />;
if (!project) {
return <main className="product-loading"><p role="alert"></p><a href="/app/projects"></a></main>;
}
const atHistoryLimit = project.successful_image_count >= 10;
return (
<div className="product-page">
<ProductHeader current="projects" />
<main className="project-detail-page">
<header className="project-detail-header">
<a href="/app/projects" aria-label="返回项目列表"></a>
<div><p>{project.status === "failed_empty" ? "FAILED EMPTY" : project.status.toUpperCase()}</p><h1>{project.name}</h1></div>
<span> {project.ratio}</span>
</header>
<section className="project-identity" aria-labelledby="rename-title">
<div><h2 id="rename-title"></h2><p>state version {project.state_version}</p></div>
<form onSubmit={rename}>
<input aria-label="项目名称" maxLength={80} onChange={(event) => setName(event.target.value)} value={name} />
<button disabled={savingName || !name.trim() || name.trim() === project.name} type="submit">{savingName ? "保存中" : "保存名称"}</button>
{nameStatus ? <span role="status">{nameStatus}</span> : null}
</form>
</section>
<div className="project-detail-grid">
<section className="project-current" aria-labelledby="current-image-title">
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-actions">
<button disabled={atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
</div>
{atHistoryLimit ? <p className="project-blocker"></p> : null}
{project.status === "failed_empty" ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
</section>
<section className="project-history" aria-labelledby="history-title">
<header><h2 id="history-title"></h2><strong>{project.successful_image_count} / 10 </strong></header>
{project.images.length === 0 ? (
<div className="history-empty"><strong></strong><p>{project.draft_prompt}</p></div>
) : (
<ol>
{project.images.toReversed().map((image, index) => (
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
<ProjectPlaceholder ratio={project.ratio} status="active" />
<div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time></div>
</li>
))}
</ol>
)}
</section>
</div>
</main>
<LocalOnlyFooter />
</div>
);
}
+775
View File
@@ -1317,6 +1317,50 @@
],
"type": "object"
},
"FailedEmptyTrashRequest": {
"additionalProperties": false,
"properties": {
"project_ids": {
"items": {
"$ref": "#/components/schemas/ProjectId"
},
"maxItems": 20,
"minItems": 1,
"type": "array",
"uniqueItems": true
}
},
"required": [
"project_ids"
],
"type": "object"
},
"FailedEmptyTrashResponse": {
"additionalProperties": false,
"properties": {
"ignored_project_ids": {
"items": {
"$ref": "#/components/schemas/ProjectId"
},
"maxItems": 20,
"type": "array",
"uniqueItems": true
},
"trashed_project_ids": {
"items": {
"$ref": "#/components/schemas/ProjectId"
},
"maxItems": 20,
"type": "array",
"uniqueItems": true
}
},
"required": [
"ignored_project_ids",
"trashed_project_ids"
],
"type": "object"
},
"GenerationErrorCategory": {
"anyOf": [
{
@@ -1375,6 +1419,85 @@
}
]
},
"GenerationProjectItem": {
"additionalProperties": false,
"properties": {
"created_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
"error_category": {
"anyOf": [
{
"$ref": "#/components/schemas/GenerationErrorCategory"
},
{
"type": "null"
}
]
},
"generation_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"prompt": {
"maxLength": 4000,
"minLength": 1,
"type": "string"
},
"ratio": {
"$ref": "#/components/schemas/ProjectRatio"
},
"status": {
"anyOf": [
{
"enum": [
"queued"
],
"type": "string"
},
{
"enum": [
"running"
],
"type": "string"
},
{
"enum": [
"succeeded"
],
"type": "string"
},
{
"enum": [
"failed"
],
"type": "string"
},
{
"enum": [
"rejected"
],
"type": "string"
}
]
},
"updated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
"required": [
"created_at",
"error_category",
"generation_id",
"prompt",
"ratio",
"status",
"updated_at"
],
"type": "object"
},
"LoginCompleteRequest": {
"additionalProperties": false,
"properties": {
@@ -1551,6 +1674,375 @@
],
"type": "object"
},
"ProjectDetailResponse": {
"additionalProperties": false,
"properties": {
"created_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
"current_image_id": {
"anyOf": [
{
"$ref": "#/components/schemas/ProjectId"
},
{
"type": "null"
}
]
},
"deleted_at": {
"anyOf": [
{
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
{
"type": "null"
}
]
},
"draft_prompt": {
"maxLength": 4000,
"minLength": 1,
"type": "string"
},
"generations": {
"items": {
"$ref": "#/components/schemas/GenerationProjectItem"
},
"type": "array"
},
"images": {
"items": {
"$ref": "#/components/schemas/ProjectImageItem"
},
"maxItems": 10,
"type": "array"
},
"name": {
"maxLength": 160,
"minLength": 1,
"type": "string"
},
"pixel_height": {
"minimum": 1,
"type": "integer"
},
"pixel_width": {
"minimum": 1,
"type": "integer"
},
"project_id": {
"$ref": "#/components/schemas/ProjectId"
},
"purge_at": {
"anyOf": [
{
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
{
"type": "null"
}
]
},
"ratio": {
"$ref": "#/components/schemas/ProjectRatio"
},
"state_version": {
"minimum": 1,
"type": "integer"
},
"status": {
"$ref": "#/components/schemas/ProjectViewStatus"
},
"successful_image_count": {
"maximum": 10,
"minimum": 0,
"type": "integer"
},
"updated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
"required": [
"current_image_id",
"deleted_at",
"name",
"project_id",
"purge_at",
"ratio",
"state_version",
"status",
"successful_image_count",
"updated_at",
"created_at",
"draft_prompt",
"generations",
"images",
"pixel_height",
"pixel_width"
],
"type": "object"
},
"ProjectId": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"ProjectImageItem": {
"additionalProperties": false,
"properties": {
"created_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
"generation_id": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
"type": "string"
},
"image_id": {
"$ref": "#/components/schemas/ProjectId"
}
},
"required": [
"created_at",
"generation_id",
"image_id"
],
"type": "object"
},
"ProjectListQuery": {
"additionalProperties": false,
"properties": {
"status": {
"anyOf": [
{
"enum": [
"active"
],
"type": "string"
},
{
"enum": [
"trashed"
],
"type": "string"
}
]
}
},
"type": "object"
},
"ProjectListResponse": {
"additionalProperties": false,
"properties": {
"active_count": {
"maximum": 20,
"minimum": 0,
"type": "integer"
},
"active_limit": {
"enum": [
20
],
"type": "number"
},
"projects": {
"items": {
"$ref": "#/components/schemas/ProjectSummary"
},
"maxItems": 20,
"type": "array"
}
},
"required": [
"active_count",
"active_limit",
"projects"
],
"type": "object"
},
"ProjectParams": {
"additionalProperties": false,
"properties": {
"projectId": {
"$ref": "#/components/schemas/ProjectId"
}
},
"required": [
"projectId"
],
"type": "object"
},
"ProjectRatio": {
"anyOf": [
{
"enum": [
"3:4"
],
"type": "string"
},
{
"enum": [
"1:1"
],
"type": "string"
},
{
"enum": [
"4:3"
],
"type": "string"
},
{
"enum": [
"9:16"
],
"type": "string"
}
]
},
"ProjectRenameRequest": {
"additionalProperties": false,
"properties": {
"name": {
"maxLength": 80,
"minLength": 1,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"ProjectRenameResponse": {
"additionalProperties": false,
"properties": {
"name": {
"maxLength": 80,
"minLength": 1,
"type": "string"
},
"state_version": {
"minimum": 2,
"type": "integer"
},
"status": {
"enum": [
"renamed"
],
"type": "string"
}
},
"required": [
"name",
"state_version",
"status"
],
"type": "object"
},
"ProjectSummary": {
"additionalProperties": false,
"properties": {
"current_image_id": {
"anyOf": [
{
"$ref": "#/components/schemas/ProjectId"
},
{
"type": "null"
}
]
},
"deleted_at": {
"anyOf": [
{
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
{
"type": "null"
}
]
},
"name": {
"maxLength": 160,
"minLength": 1,
"type": "string"
},
"project_id": {
"$ref": "#/components/schemas/ProjectId"
},
"purge_at": {
"anyOf": [
{
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
},
{
"type": "null"
}
]
},
"ratio": {
"$ref": "#/components/schemas/ProjectRatio"
},
"state_version": {
"minimum": 1,
"type": "integer"
},
"status": {
"$ref": "#/components/schemas/ProjectViewStatus"
},
"successful_image_count": {
"maximum": 10,
"minimum": 0,
"type": "integer"
},
"updated_at": {
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
"type": "string"
}
},
"required": [
"current_image_id",
"deleted_at",
"name",
"project_id",
"purge_at",
"ratio",
"state_version",
"status",
"successful_image_count",
"updated_at"
],
"type": "object"
},
"ProjectViewStatus": {
"anyOf": [
{
"enum": [
"active"
],
"type": "string"
},
{
"enum": [
"failed_empty"
],
"type": "string"
},
{
"enum": [
"trashed"
],
"type": "string"
}
]
},
"RegistrationCompleteHeaders": {
"additionalProperties": true,
"properties": {
@@ -3929,6 +4421,289 @@
]
}
},
"/api/v1/projects": {
"get": {
"operationId": "listProjects",
"parameters": [
{
"in": "query",
"name": "status",
"required": false,
"schema": {
"anyOf": [
{
"enum": [
"active"
],
"type": "string"
},
{
"enum": [
"trashed"
],
"type": "string"
}
]
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectListResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Projects"
]
}
},
"/api/v1/projects/{projectId}": {
"get": {
"operationId": "getProject",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"$ref": "#/components/schemas/ProjectId"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectDetailResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"404": {
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Projects"
]
},
"patch": {
"operationId": "renameProject",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"$ref": "#/components/schemas/ProjectId"
}
},
{
"in": "header",
"name": "x-csrf-token",
"required": true,
"schema": {
"maxLength": 64,
"minLength": 43,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectRenameRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectRenameResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"404": {
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Projects"
]
}
},
"/api/v1/projects/failed-empty/trash": {
"post": {
"operationId": "trashFailedEmptyProjects",
"parameters": [
{
"in": "header",
"name": "x-csrf-token",
"required": true,
"schema": {
"maxLength": 64,
"minLength": 43,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FailedEmptyTrashRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FailedEmptyTrashResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Projects"
]
}
},
"/api/v1/support/check": {
"post": {
"operationId": "checkBrowserSupport",
+4 -2
View File
@@ -14,7 +14,7 @@
"test:integration": "vitest run tests/integration",
"test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts --config playwright.config.ts",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
@@ -51,7 +51,9 @@
"test:wp1-05": "node scripts/run-wp1-05-validation.mjs",
"test:wp1-05:red": "node scripts/run-wp1-05-validation.mjs --phase red",
"test:wp1-06": "node scripts/run-wp1-06-validation.mjs",
"test:wp1-06:red": "node scripts/run-wp1-06-validation.mjs --phase red"
"test:wp1-06:red": "node scripts/run-wp1-06-validation.mjs --phase red",
"test:wp2-01": "node scripts/run-wp2-01-validation.mjs",
"test:wp2-01:red": "node scripts/run-wp2-01-validation.mjs --phase red"
},
"devDependencies": {
"@playwright/test": "1.62.0",
+1
View File
@@ -3,4 +3,5 @@ export * from "./api.js";
export * from "./auth.js";
export * from "./bootstrap.js";
export * from "./events.js";
export * from "./projects.js";
export * from "./registration-notice.js";
+110
View File
@@ -0,0 +1,110 @@
import { Type, type Static } from "@sinclair/typebox";
import { GenerationErrorCategorySchema } from "./api.js";
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
export const ProjectIdSchema = Type.String({ pattern: uuidPattern, $id: "ProjectId" });
export const ProjectRatioSchema = Type.Union(
[Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")],
{ $id: "ProjectRatio" },
);
export const ProjectViewStatusSchema = Type.Union(
[Type.Literal("active"), Type.Literal("failed_empty"), Type.Literal("trashed")],
{ $id: "ProjectViewStatus" },
);
export const ProjectSummarySchema = Type.Object(
{
current_image_id: Type.Union([Type.Ref(ProjectIdSchema), Type.Null()]),
deleted_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
name: Type.String({ maxLength: 160, minLength: 1 }),
project_id: Type.Ref(ProjectIdSchema),
purge_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
ratio: Type.Ref(ProjectRatioSchema),
state_version: Type.Integer({ minimum: 1 }),
status: Type.Ref(ProjectViewStatusSchema),
successful_image_count: Type.Integer({ maximum: 10, minimum: 0 }),
updated_at: Type.String({ pattern: isoTimestampPattern }),
},
{ additionalProperties: false, $id: "ProjectSummary" },
);
export const ProjectListQuerySchema = Type.Object(
{ status: Type.Optional(Type.Union([Type.Literal("active"), Type.Literal("trashed")])) },
{ additionalProperties: false, $id: "ProjectListQuery" },
);
export const ProjectListResponseSchema = Type.Object(
{
active_count: Type.Integer({ maximum: 20, minimum: 0 }),
active_limit: Type.Literal(20),
projects: Type.Array(Type.Ref(ProjectSummarySchema), { maxItems: 20 }),
},
{ additionalProperties: false, $id: "ProjectListResponse" },
);
export const ProjectParamsSchema = Type.Object(
{ projectId: Type.Ref(ProjectIdSchema) },
{ additionalProperties: false, $id: "ProjectParams" },
);
export const GenerationProjectItemSchema = Type.Object(
{
created_at: Type.String({ pattern: isoTimestampPattern }),
error_category: Type.Union([Type.Ref(GenerationErrorCategorySchema), Type.Null()]),
generation_id: Type.String({ pattern: uuidPattern }),
prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
ratio: Type.Ref(ProjectRatioSchema),
status: Type.Union([
Type.Literal("queued"), Type.Literal("running"), Type.Literal("succeeded"),
Type.Literal("failed"), Type.Literal("rejected"),
]),
updated_at: Type.String({ pattern: isoTimestampPattern }),
},
{ additionalProperties: false, $id: "GenerationProjectItem" },
);
export const ProjectImageItemSchema = Type.Object(
{
created_at: Type.String({ pattern: isoTimestampPattern }),
generation_id: Type.String({ pattern: uuidPattern }),
image_id: Type.Ref(ProjectIdSchema),
},
{ additionalProperties: false, $id: "ProjectImageItem" },
);
export const ProjectDetailResponseSchema = Type.Object(
{
...ProjectSummarySchema.properties,
created_at: Type.String({ pattern: isoTimestampPattern }),
draft_prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
generations: Type.Array(Type.Ref(GenerationProjectItemSchema)),
images: Type.Array(Type.Ref(ProjectImageItemSchema), { maxItems: 10 }),
pixel_height: Type.Integer({ minimum: 1 }),
pixel_width: Type.Integer({ minimum: 1 }),
},
{ additionalProperties: false, $id: "ProjectDetailResponse" },
);
export const ProjectRenameRequestSchema = Type.Object(
{ name: Type.String({ maxLength: 80, minLength: 1 }) },
{ additionalProperties: false, $id: "ProjectRenameRequest" },
);
export const ProjectRenameResponseSchema = Type.Object(
{
name: Type.String({ maxLength: 80, minLength: 1 }),
state_version: Type.Integer({ minimum: 2 }),
status: Type.Literal("renamed"),
},
{ additionalProperties: false, $id: "ProjectRenameResponse" },
);
export const FailedEmptyTrashRequestSchema = Type.Object(
{ project_ids: Type.Array(Type.Ref(ProjectIdSchema), { maxItems: 20, minItems: 1, uniqueItems: true }) },
{ additionalProperties: false, $id: "FailedEmptyTrashRequest" },
);
export const FailedEmptyTrashResponseSchema = Type.Object(
{
ignored_project_ids: Type.Array(Type.Ref(ProjectIdSchema), { maxItems: 20, uniqueItems: true }),
trashed_project_ids: Type.Array(Type.Ref(ProjectIdSchema), { maxItems: 20, uniqueItems: true }),
},
{ additionalProperties: false, $id: "FailedEmptyTrashResponse" },
);
export type ProjectListQuery = Static<typeof ProjectListQuerySchema>;
export type ProjectParams = Static<typeof ProjectParamsSchema>;
export type ProjectRenameRequest = Static<typeof ProjectRenameRequestSchema>;
export type FailedEmptyTrashRequest = Static<typeof FailedEmptyTrashRequestSchema>;
+111
View File
@@ -0,0 +1,111 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp2-01-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const casesRoot = resolve(runDirectory, "cases");
const playwrightDirectory = resolve(runDirectory, "playwright");
const caseIds = [
"TDD-WP2-PROJ-001-new-versus-continue",
"TDD-WP2-PROJ-005-failed-draft-retry",
];
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
for (const caseId of caseIds) mkdirSync(resolve(casesRoot, caseId), { recursive: true });
const commandsToRun = phase === "red"
? [
["project-integration", ["exec", "vitest", "run", "tests/integration/wp2-01-projects.test.ts"]],
["project-api", ["exec", "vitest", "run", "tests/api/wp2-01-projects.test.ts"]],
["project-e2e", ["exec", "playwright", "test", "tests/e2e/projects-workspace.spec.ts", "--config", "playwright.config.ts"]],
]
: [
["integration", ["test:integration"]],
["api", ["test:api"]],
["e2e", ["test:e2e"]],
["tdd-trace", ["validate:tdd-trace"]],
];
const environment = {
...process.env,
DADA_EVIDENCE_DIR_PROJECTS: casesRoot,
DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory,
};
const commandResults = [];
for (const [name, args] of commandsToRun) {
const command = `pnpm ${args.join(" ")}`;
const started_at = new Date().toISOString();
const execution = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
if (execution.stdout) process.stdout.write(execution.stdout);
if (execution.stderr) process.stderr.write(execution.stderr);
commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
}
function find(root, name) {
if (!existsSync(root)) return [];
return readdirSync(root).flatMap((entry) => {
const child = resolve(root, entry);
return statSync(child).isDirectory() ? find(child, name) : entry === name ? [child] : [];
});
}
if (phase === "green") {
const traces = find(playwrightDirectory, "trace.zip");
const projectTrace = traces.find((path) => path.includes("inventing-a-model"))
?? traces.find((path) => path.includes("project-detail"));
const draftTrace = traces.find((path) => path.includes("project-list"));
if (projectTrace) copyFileSync(projectTrace, resolve(casesRoot, caseIds[0], "trace.zip"));
if (draftTrace) copyFileSync(draftTrace, resolve(casesRoot, caseIds[1], "trace.zip"));
}
for (const caseId of caseIds) {
writeFileSync(resolve(casesRoot, caseId, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
}
const expectedEvidence = phase === "red" ? ["red-observation.json"] : ["response.json", "db-diff.json", "trace.zip"];
const commandState = phase === "red"
? commandResults.every((result) => result.exit_code !== 0)
: commandResults.every((result) => result.exit_code === 0);
if (phase === "red") {
for (const caseId of caseIds) {
writeFileSync(resolve(casesRoot, caseId, "red-observation.json"), `${JSON.stringify({
expected_failure: caseId.includes("PROJ-001")
? "new and continued generation project semantics plus fixed history rules are absent"
: "failed-empty retry and batch eligibility semantics are absent",
status: commandState ? "red_confirmed" : "failed",
}, null, 2)}\n`);
}
}
const manifest = {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
};
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
const results = caseIds.map((testId) => {
const directory = resolve(casesRoot, testId);
const evidence_refs = expectedEvidence;
const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directory, file)));
const status = commandState && missing_evidence.length === 0 ? (phase === "red" ? "red_confirmed" : "passed") : "failed";
const result = {
acceptance_criteria: testId.includes("PROJ-001") ? ["AC-03", "AC-06", "AC-34"] : ["AC-04", "AC-43"],
automation: ["automated"], commit, evidence_refs, layer: ["DB", "API", "E2E"], manifest,
missing_evidence, phase,
requirements: testId.includes("PROJ-001") ? ["GEN-04", "GEN-11", "PROJECT-01", "PROJECT-02", "PROJECT-03"] : ["GEN-09", "PROJECT-01"],
run_id: runId, status, task_id: "TASK-WP2-01", test_id: testId, work_package: "WP-2",
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
};
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
return result;
});
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
const passed = results.every((result) => result.status === targetStatus);
const summary = {
cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })),
phase, run_id: runId, status: passed ? targetStatus : "failed",
};
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
console.log(JSON.stringify(summary, null, 2));
if (!passed) process.exit(1);
+119
View File
@@ -0,0 +1,119 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { ProjectService } from "../../apps/api/src/projects.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const now = Date.parse("2026-07-28T08:00:00.000Z");
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const roots: string[] = [];
const registrations: RegistrationService[] = [];
const projects: ProjectService[] = [];
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-01-api-"));
roots.push(root);
const databasePath = join(root, "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x21), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
invitePepper: Buffer.alloc(32, 0x22), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x23),
});
registrations.push(registration);
const projectService = new ProjectService({ clock: () => now, databasePath });
projects.push(projectService);
const ownerId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
) VALUES (?, 'projects@example.invalid', 'user', 'active', 1, ?, ?)
`).run(ownerId, randomUUID(), now);
registration.database.prepare(`
INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Project User', '@project_user')
`).run(ownerId);
registration.database.prepare(`
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)
`).run(ownerId, now);
const session = registration.issueAuthenticatedSession(ownerId, "user");
return { ownerId, projectService, registration, session };
}
afterEach(() => {
for (const project of projects.splice(0)) project.close();
for (const registration of registrations.splice(0)) registration.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TASK-WP2-01 project API", () => {
it("returns owner-scoped summaries/detail and applies rename plus failed-empty batch trash", async () => {
const fixture = harness();
const failed = fixture.projectService.createProjectForGeneration({
ownerId: fixture.ownerId, prompt: "API 失败草稿", ratio: "3:4", status: "failed",
});
const succeeded = fixture.projectService.createProjectForGeneration({
ownerId: fixture.ownerId, prompt: "API 成功项目", ratio: "1:1", status: "running",
});
fixture.projectService.recordSuccessfulImage({ generationId: succeeded.generation.generationId, imageId: randomUUID() });
const app = await createApp({
browserGate: false, networkBoundary: { allowTestPort: true }, projects: fixture.projectService, registration: fixture.registration,
});
const cookie = `dada_session=${fixture.session.sessionToken}`;
const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
const csrf = session.json().csrf_token;
const list = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/projects?status=active" });
expect(list.statusCode).toBe(200);
expect(list.json()).toMatchObject({ active_count: 2, active_limit: 20 });
expect(list.json().projects).toEqual(expect.arrayContaining([
expect.objectContaining({ project_id: failed.project.projectId, status: "failed_empty", successful_image_count: 0 }),
expect.objectContaining({ project_id: succeeded.project.projectId, status: "active", successful_image_count: 1 }),
]));
expect(list.json().projects[0]).not.toHaveProperty("draft_prompt");
expect(list.json().projects[0]).not.toHaveProperty("generations");
const detail = await app.inject({
headers: { ...headers, cookie }, method: "GET", url: `/api/v1/projects/${succeeded.project.projectId}`,
});
expect(detail.statusCode).toBe(200);
expect(detail.json()).toMatchObject({ project_id: succeeded.project.projectId, ratio: "1:1", successful_image_count: 1 });
const rename = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "PATCH", payload: { name: "API 新名称" },
url: `/api/v1/projects/${succeeded.project.projectId}`,
});
expect(rename.statusCode).toBe(200);
expect(rename.json()).toMatchObject({ name: "API 新名称", status: "renamed" });
const batch = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST",
payload: { project_ids: [failed.project.projectId, succeeded.project.projectId] },
url: "/api/v1/projects/failed-empty/trash",
});
expect(batch.statusCode).toBe(200);
expect(batch.json()).toEqual({ ignored_project_ids: [succeeded.project.projectId], trashed_project_ids: [failed.project.projectId] });
expect(fixture.projectService.getProject(fixture.ownerId, succeeded.project.projectId).status).toBe("active");
await app.close();
});
it("does not reveal another owner's project", async () => {
const fixture = harness();
const hidden = fixture.projectService.createProjectForGeneration({
ownerId: randomUUID(), prompt: "另一个用户", ratio: "4:3", status: "failed",
});
const app = await createApp({
browserGate: false, networkBoundary: { allowTestPort: true }, projects: fixture.projectService, registration: fixture.registration,
});
const response = await app.inject({
headers: { ...headers, cookie: `dada_session=${fixture.session.sessionToken}` }, method: "GET",
url: `/api/v1/projects/${hidden.project.projectId}`,
});
expect(response.statusCode).toBe(404);
await app.close();
});
});
+123
View File
@@ -0,0 +1,123 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
let vite: ViteDevServer;
let webUrl: string;
test.beforeAll(async () => {
vite = await createServer({
configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"),
server: { host: "127.0.0.1", port: 0 },
});
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
const session = {
audience: "user", authenticated: true,
credits: { available_balance: 10, reserved_balance: 0 },
csrf_token: "csrf-projects-fixture-000000000000000000000000000000000000000",
expires_at: "2026-08-28T08:00:00.000Z",
user: { creator_name: "Project User", role: "user", social_id: "@project_user", status: "active", user_id: "00000000-0000-4000-8000-000000000201" },
};
function routeSession(page: Page) {
return page.route("**/api/v1/auth/session", (route) => route.fulfill({
body: JSON.stringify(session), contentType: "application/json", status: 200,
}));
}
async function captureEvidence(page: Page, caseId: string, name: string) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
if (!root) return;
const directory = resolve(root, caseId, "screenshots");
mkdirSync(directory, { recursive: true });
await page.screenshot({ fullPage: true, path: resolve(directory, name) });
}
test("TDD-WP2-PROJ-001 renders the empty WbDYT workspace without inventing a model", async ({ page }) => {
await routeSession(page);
await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({
body: JSON.stringify({ active_count: 0, active_limit: 20, projects: [] }), contentType: "application/json", status: 200,
}));
await page.goto(`${webUrl}/app`);
await expect(page.getByRole("heading", { name: "开始一张新作品" })).toBeVisible();
await expect(page.getByText("NEW PROJECT", { exact: true })).toBeVisible();
await expect(page.getByLabel("描述你想生成的画面")).toBeVisible();
await expect(page.getByRole("radio", { name: "3:4" })).toBeChecked();
for (const ratio of ["1:1", "4:3", "9:16"]) await expect(page.getByRole("radio", { name: ratio })).toBeVisible();
await expect(page.getByText("当前没有可用于新任务的模型")).toBeVisible();
await expect(page.getByRole("button", { name: "生成一张图片" })).toBeDisabled();
await expect(page.getByText("没有进行中的任务")).toBeVisible();
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
await expect(page.getByText("浏览示例模板")).toHaveCount(0);
await captureEvidence(page, "TDD-WP2-PROJ-001-new-versus-continue", "workspace-empty.png");
});
test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async ({ page }) => {
await page.setViewportSize({ height: 760, width: 375 });
await routeSession(page);
const failedId = "00000000-0000-4000-8000-000000000211";
const successId = "00000000-0000-4000-8000-000000000212";
await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({
body: JSON.stringify({
active_count: 2, active_limit: 20,
projects: [
{ current_image_id: null, name: "失败草稿", project_id: failedId, ratio: "3:4", state_version: 1, status: "failed_empty", successful_image_count: 0, updated_at: "2026-07-28T08:00:00.000Z" },
{ current_image_id: "00000000-0000-4000-8000-000000000213", name: "城市工作室", project_id: successId, ratio: "3:4", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-07-28T08:02:00.000Z" },
],
}), contentType: "application/json", status: 200,
}));
let batchPayload: unknown;
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
batchPayload = route.request().postDataJSON();
await route.fulfill({
body: JSON.stringify({ ignored_project_ids: [], trashed_project_ids: [failedId] }), contentType: "application/json", status: 200,
});
});
await page.goto(`${webUrl}/app/projects`);
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
await expect(page.getByText("2 / 20 active")).toBeVisible();
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
await page.getByLabel("选择失败草稿:失败草稿").check();
await page.getByRole("button", { name: "批量移入回收站" }).click();
expect(batchPayload).toEqual({ project_ids: [failedId] });
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
});
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
await routeSession(page);
const projectId = "00000000-0000-4000-8000-000000000221";
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
body: JSON.stringify({
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
image_id: `00000000-0000-4000-8000-${String(323 + index).padStart(12, "0")}`,
})),
name: "城市工作室", project_id: projectId, ratio: "3:4", state_version: 4,
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
}), contentType: "application/json", status: 200,
}));
await page.goto(`${webUrl}/app/projects/${projectId}`);
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
await expect(page.getByText("固定比例 3:4")).toBeVisible();
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(0);
await captureEvidence(page, "TDD-WP2-PROJ-001-new-versus-continue", "project-detail-limit.png");
});
@@ -7,6 +7,7 @@ import { Readable } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
import { ProjectService } from "../../apps/api/src/projects.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
@@ -15,6 +16,7 @@ const fixedNow = Date.parse("2026-07-28T10:00:00.000Z");
const roots: string[] = [];
const services: RegistrationService[] = [];
const storages: ManagedStorage[] = [];
const projectServices: ProjectService[] = [];
function writeEvidence(environmentName: string, file: string, value: unknown) {
const directory = process.env[environmentName];
@@ -53,10 +55,13 @@ async function createRegisteredHarness(email = "delete-me@example.invalid") {
registrationId: sent.registrationId,
socialId: "@delete_me",
});
return { completed, databasePath, email, resend, root, service, storage };
const projects = new ProjectService({ clock: () => fixedNow, databasePath });
projectServices.push(projects);
return { completed, databasePath, email, projects, resend, root, service, storage };
}
afterEach(() => {
for (const projects of projectServices.splice(0)) projects.close();
for (const service of services.splice(0)) service.close();
for (const storage of storages.splice(0)) storage.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
@@ -68,6 +73,7 @@ describe("TDD-WP1-DEL-001-account-delete", () => {
const userId = harness.completed.user.userId;
const secondSession = harness.service.issueAuthenticatedSession(userId, "user");
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
harness.projects.createProjectForGeneration({ ownerId: userId, prompt: "注销项目", ratio: "3:4", status: "failed" });
const files = await Promise.all([
harness.storage.commitStream({
content: Readable.from(Buffer.from("reference")), expectedMimeType: "application/octet-stream",
@@ -105,9 +111,10 @@ describe("TDD-WP1-DEL-001-account-delete", () => {
credits: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts WHERE user_id = ?").get(userId).count,
ledger: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE user_id = ?").get(userId).count,
profiles: harness.service.database.prepare("SELECT COUNT(*) AS count FROM user_profiles WHERE user_id = ?").get(userId).count,
projects: harness.service.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ?").get(userId).count,
sessions: harness.service.database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ?").get(userId).count,
};
expect(databaseState).toEqual({ account: 0, consents: 0, credits: 0, ledger: 0, profiles: 0, sessions: 0 });
expect(databaseState).toEqual({ account: 0, consents: 0, credits: 0, ledger: 0, profiles: 0, projects: 0, sessions: 0 });
expect(files.every((file) => harness.storage.resolveManagedFile(file.file_id) === undefined)).toBe(true);
expect(harness.storage.inspectCounts().pending_cleanup).toBe(3);
expect(harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE normalized_email = ?").get(harness.email).count).toBe(0);
+120
View File
@@ -0,0 +1,120 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ProjectError, ProjectService } from "../../apps/api/src/projects.js";
const baseNow = Date.parse("2026-07-28T08:00:00.000Z");
const roots: string[] = [];
const services: ProjectService[] = [];
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp2-01-projects-"));
roots.push(root);
const service = new ProjectService({ clock: () => baseNow, databasePath: join(root, "dada.sqlite3") });
services.push(service);
return service;
}
function writeEvidence(caseId: string, file: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
if (!root) return;
const directory = resolve(root, caseId);
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP2-PROJ-001-new-versus-continue", () => {
it("creates only on explicit new work, fixes ratio, and isolates the history limit", () => {
const service = harness();
const ownerId = randomUUID();
for (let index = 0; index < 18; index += 1) {
service.createProjectForGeneration({ ownerId, prompt: `fixture ${index}`, ratio: "1:1", status: "failed" });
}
const first = service.createProjectForGeneration({
ownerId,
prompt: " 夜晚城市 中一间明亮的工作室,还有安静的雨声 ",
ratio: "3:4",
status: "failed",
});
const second = service.createProjectForGeneration({ ownerId, prompt: "第二张作品", ratio: "9:16", status: "failed" });
expect(first.project.projectId).not.toBe(second.project.projectId);
expect(first.project.name).toBe("夜晚城市 中一间明亮的工作室,还有安静的雨声 2026-07-28");
expect(service.listProjects(ownerId, "active")).toHaveLength(20);
expect(() => service.createProjectForGeneration({ ownerId, prompt: "第 21 个", ratio: "1:1", status: "failed" }))
.toThrowError(expect.objectContaining<ProjectError>({ code: "project_active_limit" }));
const beforeContinue = service.listProjects(ownerId, "active").length;
const continued = service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: "同项目继续", ratio: "3:4", status: "running",
});
expect(continued.projectId).toBe(first.project.projectId);
expect(service.listProjects(ownerId, "active")).toHaveLength(beforeContinue);
expect(() => service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: "不能改比例", ratio: "1:1", status: "failed",
})).toThrowError(expect.objectContaining<ProjectError>({ code: "project_ratio_fixed" }));
for (let index = 0; index < 10; index += 1) {
const generation = index === 0
? continued
: service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: `历史 ${index}`, ratio: "3:4", status: "running",
});
service.recordSuccessfulImage({ generationId: generation.generationId, imageId: randomUUID() });
}
expect(service.getProject(ownerId, first.project.projectId).successfulImageCount).toBe(10);
expect(() => service.continueProjectGeneration({
ownerId, projectId: first.project.projectId, prompt: "第十一张", ratio: "3:4", status: "queued",
})).toThrowError(expect.objectContaining<ProjectError>({ code: "project_history_limit" }));
writeEvidence("TDD-WP2-PROJ-001-new-versus-continue", "db-diff.json", {
active_projects: 20,
first_project_images: 10,
project_delta_on_continue: 0,
});
writeEvidence("TDD-WP2-PROJ-001-new-versus-continue", "response.json", {
history_limit: "blocked",
new_project_ids: [first.project.projectId, second.project.projectId],
ratio_change: "blocked",
});
});
});
describe("TDD-WP2-PROJ-005-failed-draft-retry", () => {
it("keeps retries in one draft and trashes only failed-empty selections", () => {
const service = harness();
const ownerId = randomUUID();
const draft = service.createProjectForGeneration({ ownerId, prompt: "失败草稿", ratio: "4:3", status: "failed" });
const retryOne = service.retryFailedDraft({ ownerId, projectId: draft.project.projectId, prompt: "失败草稿 第一次重试" });
service.markGenerationFailed(retryOne.generationId, "upstream_timeout");
const retryTwo = service.retryFailedDraft({ ownerId, projectId: draft.project.projectId, prompt: "失败草稿 第二次重试" });
service.markGenerationFailed(retryTwo.generationId, "upstream_failed");
expect(service.getProject(ownerId, draft.project.projectId).generations).toHaveLength(3);
const otherDraft = service.createProjectForGeneration({ ownerId, prompt: "另一个失败草稿", ratio: "1:1", status: "failed" });
const successful = service.createProjectForGeneration({ ownerId, prompt: "已有成功图", ratio: "1:1", status: "running" });
service.recordSuccessfulImage({ generationId: successful.generation.generationId, imageId: randomUUID() });
const result = service.trashFailedEmpty(ownerId, [draft.project.projectId, otherDraft.project.projectId, successful.project.projectId]);
expect(result).toEqual({ ignoredProjectIds: [successful.project.projectId], trashedProjectIds: [draft.project.projectId, otherDraft.project.projectId] });
expect(service.getProject(ownerId, successful.project.projectId).status).toBe("active");
expect(service.getProject(ownerId, draft.project.projectId).status).toBe("trashed");
const renamed = service.renameProject(ownerId, successful.project.projectId, " 夏日海报 ");
expect(renamed.name).toBe("夏日海报");
writeEvidence("TDD-WP2-PROJ-005-failed-draft-retry", "db-diff.json", {
failed_empty_trashed: result.trashedProjectIds.length,
successful_project_status: "active",
retry_job_count: 3,
});
writeEvidence("TDD-WP2-PROJ-005-failed-draft-retry", "response.json", result);
});
});