feat: complete TASK-WP2-01 project foundations
This commit is contained in:
@@ -29,6 +29,20 @@ import {
|
|||||||
LogoutResponseSchema,
|
LogoutResponseSchema,
|
||||||
ModelConfigSseEventSchema,
|
ModelConfigSseEventSchema,
|
||||||
ModelRuntimeSseEventSchema,
|
ModelRuntimeSseEventSchema,
|
||||||
|
FailedEmptyTrashRequestSchema,
|
||||||
|
FailedEmptyTrashResponseSchema,
|
||||||
|
GenerationProjectItemSchema,
|
||||||
|
ProjectDetailResponseSchema,
|
||||||
|
ProjectIdSchema,
|
||||||
|
ProjectImageItemSchema,
|
||||||
|
ProjectListQuerySchema,
|
||||||
|
ProjectListResponseSchema,
|
||||||
|
ProjectParamsSchema,
|
||||||
|
ProjectRatioSchema,
|
||||||
|
ProjectRenameRequestSchema,
|
||||||
|
ProjectRenameResponseSchema,
|
||||||
|
ProjectSummarySchema,
|
||||||
|
ProjectViewStatusSchema,
|
||||||
RegistrationCompleteHeadersSchema,
|
RegistrationCompleteHeadersSchema,
|
||||||
RegistrationCompleteRequestSchema,
|
RegistrationCompleteRequestSchema,
|
||||||
RegistrationCompleteResponseSchema,
|
RegistrationCompleteResponseSchema,
|
||||||
@@ -48,6 +62,10 @@ import {
|
|||||||
type AccountProfileUpdateRequest,
|
type AccountProfileUpdateRequest,
|
||||||
type LoginCompleteRequest,
|
type LoginCompleteRequest,
|
||||||
type LoginSendRequest,
|
type LoginSendRequest,
|
||||||
|
type FailedEmptyTrashRequest,
|
||||||
|
type ProjectListQuery,
|
||||||
|
type ProjectParams,
|
||||||
|
type ProjectRenameRequest,
|
||||||
type RegistrationCompleteRequest,
|
type RegistrationCompleteRequest,
|
||||||
type RegistrationSendRequest,
|
type RegistrationSendRequest,
|
||||||
} from "@dada/shared-contracts";
|
} from "@dada/shared-contracts";
|
||||||
@@ -70,6 +88,8 @@ import {
|
|||||||
import { EventHub } from "./event-hub.js";
|
import { EventHub } from "./event-hub.js";
|
||||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||||
|
import { ProjectError } from "./project-errors.js";
|
||||||
|
import type { ProjectService } from "./projects.js";
|
||||||
import {
|
import {
|
||||||
RegistrationError,
|
RegistrationError,
|
||||||
registrationFieldError,
|
registrationFieldError,
|
||||||
@@ -96,6 +116,7 @@ export interface CreateAppOptions {
|
|||||||
eventHub?: EventHub;
|
eventHub?: EventHub;
|
||||||
networkBoundary?: NetworkBoundaryOptions;
|
networkBoundary?: NetworkBoundaryOptions;
|
||||||
publicAssets?: PublicAssetResolver;
|
publicAssets?: PublicAssetResolver;
|
||||||
|
projects?: ProjectService;
|
||||||
registration?: RegistrationService;
|
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) {
|
function isSupportGateRequest(method: string, path: string) {
|
||||||
if (method === "POST" && path === "/api/v1/support/check") return true;
|
if (method === "POST" && path === "/api/v1/support/check") return true;
|
||||||
if (method !== "GET" && method !== "HEAD") return false;
|
if (method !== "GET" && method !== "HEAD") return false;
|
||||||
@@ -261,6 +340,20 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
ModelConfigSseEventSchema,
|
ModelConfigSseEventSchema,
|
||||||
ModelRuntimeSseEventSchema,
|
ModelRuntimeSseEventSchema,
|
||||||
SseEventSchema,
|
SseEventSchema,
|
||||||
|
ProjectIdSchema,
|
||||||
|
ProjectRatioSchema,
|
||||||
|
ProjectViewStatusSchema,
|
||||||
|
ProjectSummarySchema,
|
||||||
|
ProjectListQuerySchema,
|
||||||
|
ProjectListResponseSchema,
|
||||||
|
ProjectParamsSchema,
|
||||||
|
GenerationProjectItemSchema,
|
||||||
|
ProjectImageItemSchema,
|
||||||
|
ProjectDetailResponseSchema,
|
||||||
|
ProjectRenameRequestSchema,
|
||||||
|
ProjectRenameResponseSchema,
|
||||||
|
FailedEmptyTrashRequestSchema,
|
||||||
|
FailedEmptyTrashResponseSchema,
|
||||||
]) {
|
]) {
|
||||||
app.addSchema(schema);
|
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(
|
app.post(
|
||||||
"/api/v1/support/check",
|
"/api/v1/support/check",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createApp } from "./app.js";
|
|||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
|
import { ProjectService } from "./projects.js";
|
||||||
import { RegistrationService } from "./registration.js";
|
import { RegistrationService } from "./registration.js";
|
||||||
import { MockResendAdapter } from "./resend-adapter.js";
|
import { MockResendAdapter } from "./resend-adapter.js";
|
||||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
@@ -15,6 +16,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC
|
|||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
let registration: RegistrationService | undefined;
|
let registration: RegistrationService | undefined;
|
||||||
|
let projects: ProjectService | undefined;
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
@@ -23,17 +25,21 @@ if (credentialChannelEnabled) {
|
|||||||
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||||
.digest();
|
.digest();
|
||||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
registration = new RegistrationService({
|
registration = new RegistrationService({
|
||||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
challengePepper: derivePepper("challenge-pepper"),
|
challengePepper: derivePepper("challenge-pepper"),
|
||||||
currentPrivacyNoticeVersion: registrationNotice.version,
|
currentPrivacyNoticeVersion: registrationNotice.version,
|
||||||
databasePath: join(dataRoot, "db", "dada.sqlite3"),
|
databasePath,
|
||||||
invitePepper: derivePepper("invite-pepper"),
|
invitePepper: derivePepper("invite-pepper"),
|
||||||
resend: new MockResendAdapter(),
|
resend: new MockResendAdapter(),
|
||||||
sessionPepper: derivePepper("session-pepper"),
|
sessionPepper: derivePepper("session-pepper"),
|
||||||
});
|
});
|
||||||
|
projects = new ProjectService({ databasePath });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
projects?.close();
|
||||||
|
projects = undefined;
|
||||||
registration?.close();
|
registration?.close();
|
||||||
registration = undefined;
|
registration = undefined;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -45,6 +51,7 @@ if (credentialChannelEnabled) {
|
|||||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
|
...(projects ? { projects } : {}),
|
||||||
...(registration ? { registration } : {}),
|
...(registration ? { registration } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ if (controlPipeIndex >= 0) {
|
|||||||
let storage: ManagedStorage | undefined;
|
let storage: ManagedStorage | undefined;
|
||||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
projects?.close();
|
||||||
registration?.close();
|
registration?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }) {
|
logoutUser(input: { csrfToken: string; sessionToken: string }) {
|
||||||
const now = this.options.clock();
|
const now = this.options.clock();
|
||||||
this.runImmediate("session_revoke", () => {
|
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 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 credit_accounts WHERE user_id = ?").run(session.user_id);
|
||||||
this.database.prepare("DELETE FROM user_profiles 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 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 email_challenges WHERE email = ?").run(session.normalized_email);
|
||||||
this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?")
|
this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// 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; }
|
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`;
|
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> {
|
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
|
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>;
|
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> {
|
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
|
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>;
|
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> {
|
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
|
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>;
|
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> {
|
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|||||||
@@ -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 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 = {
|
export type LoginCompleteRequest = {
|
||||||
"registration_id": string;
|
"registration_id": string;
|
||||||
"verification_code": string;
|
"verification_code": string;
|
||||||
@@ -231,6 +250,74 @@ export type ModelRuntimeSseEvent = {
|
|||||||
"runtime_availability_version": number;
|
"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 = {
|
export type RegistrationCompleteHeaders = {
|
||||||
"idempotency-key": string;
|
"idempotency-key": string;
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-6
@@ -5,6 +5,7 @@ import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
|||||||
import { AdminAuthPage } from "./admin-auth.js";
|
import { AdminAuthPage } from "./admin-auth.js";
|
||||||
import { UserAuthPage } from "./user-auth.js";
|
import { UserAuthPage } from "./user-auth.js";
|
||||||
import { AccountSettingsPage } from "./account-settings.js";
|
import { AccountSettingsPage } from "./account-settings.js";
|
||||||
|
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
@@ -20,11 +21,14 @@ let authRevision = 0;
|
|||||||
|
|
||||||
function renderAuthenticationEntry() {
|
function renderAuthenticationEntry() {
|
||||||
authRevision += 1;
|
authRevision += 1;
|
||||||
const authenticationPage = window.location.pathname === "/app/settings"
|
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
|
||||||
? <AccountSettingsPage key={authRevision} />
|
let authenticationPage;
|
||||||
: window.location.pathname.startsWith("/admin")
|
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
|
||||||
? <AdminAuthPage key={authRevision} />
|
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
|
||||||
: <UserAuthPage key={authRevision} />;
|
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(
|
appRoot.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
{authenticationPage}
|
{authenticationPage}
|
||||||
@@ -33,5 +37,8 @@ function renderAuthenticationEntry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Any authenticated surface can dispatch this after a revoked/invalid session response.
|
// 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();
|
renderAuthenticationEntry();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1317,6 +1317,50 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"GenerationErrorCategory": {
|
||||||
"anyOf": [
|
"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": {
|
"LoginCompleteRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1551,6 +1674,375 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"RegistrationCompleteHeaders": {
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"properties": {
|
"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": {
|
"/api/v1/support/check": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "checkBrowserSupport",
|
"operationId": "checkBrowserSupport",
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"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: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:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"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": "node scripts/run-wp1-05-validation.mjs",
|
||||||
"test:wp1-05:red": "node scripts/run-wp1-05-validation.mjs --phase red",
|
"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": "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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export * from "./api.js";
|
|||||||
export * from "./auth.js";
|
export * from "./auth.js";
|
||||||
export * from "./bootstrap.js";
|
export * from "./bootstrap.js";
|
||||||
export * from "./events.js";
|
export * from "./events.js";
|
||||||
|
export * from "./projects.js";
|
||||||
export * from "./registration-notice.js";
|
export * from "./registration-notice.js";
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
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 { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.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 roots: string[] = [];
|
||||||
const services: RegistrationService[] = [];
|
const services: RegistrationService[] = [];
|
||||||
const storages: ManagedStorage[] = [];
|
const storages: ManagedStorage[] = [];
|
||||||
|
const projectServices: ProjectService[] = [];
|
||||||
|
|
||||||
function writeEvidence(environmentName: string, file: string, value: unknown) {
|
function writeEvidence(environmentName: string, file: string, value: unknown) {
|
||||||
const directory = process.env[environmentName];
|
const directory = process.env[environmentName];
|
||||||
@@ -53,10 +55,13 @@ async function createRegisteredHarness(email = "delete-me@example.invalid") {
|
|||||||
registrationId: sent.registrationId,
|
registrationId: sent.registrationId,
|
||||||
socialId: "@delete_me",
|
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(() => {
|
afterEach(() => {
|
||||||
|
for (const projects of projectServices.splice(0)) projects.close();
|
||||||
for (const service of services.splice(0)) service.close();
|
for (const service of services.splice(0)) service.close();
|
||||||
for (const storage of storages.splice(0)) storage.close();
|
for (const storage of storages.splice(0)) storage.close();
|
||||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
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 userId = harness.completed.user.userId;
|
||||||
const secondSession = harness.service.issueAuthenticatedSession(userId, "user");
|
const secondSession = harness.service.issueAuthenticatedSession(userId, "user");
|
||||||
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
|
const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken);
|
||||||
|
harness.projects.createProjectForGeneration({ ownerId: userId, prompt: "注销项目", ratio: "3:4", status: "failed" });
|
||||||
const files = await Promise.all([
|
const files = await Promise.all([
|
||||||
harness.storage.commitStream({
|
harness.storage.commitStream({
|
||||||
content: Readable.from(Buffer.from("reference")), expectedMimeType: "application/octet-stream",
|
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,
|
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,
|
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,
|
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,
|
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(files.every((file) => harness.storage.resolveManagedFile(file.file_id) === undefined)).toBe(true);
|
||||||
expect(harness.storage.inspectCounts().pending_cleanup).toBe(3);
|
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);
|
expect(harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE normalized_email = ?").get(harness.email).count).toBe(0);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user