feat: complete TASK-WP2-05 generation submission

This commit is contained in:
suyx
2026-08-03 00:26:34 +08:00
parent 4551d73e76
commit c8643c080d
22 changed files with 2372 additions and 22 deletions
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@dada/shared-contracts": "workspace:*",
"@fastify/multipart": "10.1.0",
"@fastify/swagger": "9.8.1",
"@sinclair/typebox": "0.34.52",
"better-sqlite3": "13.0.1",
+358
View File
@@ -36,6 +36,12 @@ import {
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
GenerationCreateHeadersSchema,
GenerationCreateResponseSchema,
GenerationParamsSchema,
GenerationMultipartBodySchema,
GenerationTaskResponseSchema,
GenerationTaskStatusSchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LoginSendRequestSchema,
@@ -88,6 +94,8 @@ import {
type LoginCompleteRequest,
type LoginSendRequest,
type FailedEmptyTrashRequest,
type GenerationCreateHeaders,
type GenerationParams,
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
@@ -97,6 +105,7 @@ import {
type RegistrationSendRequest,
} from "@dada/shared-contracts";
import swagger from "@fastify/swagger";
import multipart from "@fastify/multipart";
import Fastify, { type FastifyReply } from "fastify";
import {
@@ -116,6 +125,14 @@ import { EventHub } from "./event-hub.js";
import { CreditError } from "./credit-errors.js";
import type { CreditService } from "./credits.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { GenerationSubmissionError } from "./generation-submission-errors.js";
import type {
GenerationSubmissionFields,
GenerationSubmissionService,
GenerationTaskView,
GenerationUploadSession,
NewGenerationReference,
} from "./generation-submission.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import { ProjectError } from "./project-errors.js";
import type { ProjectService } from "./projects.js";
@@ -144,6 +161,7 @@ export interface CreateAppOptions {
browserSupportSecret?: Buffer;
credits?: CreditService;
eventHub?: EventHub;
generations?: GenerationSubmissionService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
projects?: ProjectService;
@@ -248,6 +266,175 @@ function creditFailure(reply: FastifyReply, correlationId: string, error: unknow
return reply.code(status).send(null);
}
function generationTaskResponse(task: GenerationTaskView) {
return {
confirmed_credit_cost: task.confirmedCreditCost,
created_at: task.createdAt,
generation_id: task.generationId,
model_config_version: task.modelConfigVersion,
model_id: task.modelId,
project_id: task.projectId,
prompt: task.prompt,
ratio: task.ratio,
reference_count: task.referenceCount,
reserved_credits: task.reservedCredits,
status: task.status,
updated_at: task.updatedAt,
};
}
function generationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (error instanceof GenerationSubmissionError) {
if (error.code === "model_config_stale") {
return reply.code(412).send(createErrorEnvelope({
code: "MODEL_CONFIG_VERSION_CONFLICT",
correlationId,
details: { latest_version: error.latest?.configVersion ?? 0 },
}));
}
if (error.code === "generation_idempotency_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
}
if (error.code === "generation_blocked") {
return reply.code(503).send(createErrorEnvelope({
code: "AUTH_SERVICE_UNAVAILABLE",
correlationId,
...(error.errorCategory ? { errorCategory: error.errorCategory } : {}),
}));
}
if (error.code === "generation_storage_unavailable") {
return reply.code(507).send(createErrorEnvelope({
code: "STORAGE_CAPACITY_EXCEEDED",
correlationId,
details: {
capacity_status: error.storage?.capacityStatus ?? "unavailable",
remaining_bytes: error.storage?.remainingBytes ?? 0,
},
}));
}
return reply.code(error.code === "generation_not_found" ? 404 : 400).send(null);
}
if (error && typeof error === "object" && "code" in error) {
if (typeof error.code === "string" && error.code.startsWith("FST_")) {
return reply.code(400).send(null);
}
if (error.code === "STORAGE_CAPACITY_EXCEEDED") {
const details = "details" in error && error.details && typeof error.details === "object"
? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number }
: {};
return reply.code(507).send(createErrorEnvelope({
code: "STORAGE_CAPACITY_EXCEEDED",
correlationId,
details: {
capacity_status: details.capacity_status ?? "full",
remaining_bytes: details.remaining_bytes ?? 0,
},
}));
}
if (error.code === "storage_unavailable") {
return reply.code(507).send(createErrorEnvelope({
code: "STORAGE_CAPACITY_EXCEEDED",
correlationId,
details: { capacity_status: "unavailable", remaining_bytes: 0 },
}));
}
}
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
const generationFieldNames = new Set([
"client_submission_id",
"confirmed_credit_cost",
"creation_mode",
"existing_reference_asset_ids",
"model_config_version",
"model_id",
"project_id",
"prompt",
"ratio",
"reference_manifest",
]);
interface ReferenceManifestEntry {
fileName: string;
mimeType: NewGenerationReference["mimeType"];
projectedBytes: number;
}
function positiveIntegerField(value: string | undefined) {
if (!value || !/^[1-9][0-9]*$/.test(value)) throw new GenerationSubmissionError("generation_request_invalid");
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
return parsed;
}
function jsonStringArray(value: string | undefined) {
if (value === undefined) return [];
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new GenerationSubmissionError("generation_request_invalid");
}
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
throw new GenerationSubmissionError("generation_request_invalid");
}
return parsed;
}
function referenceManifest(value: string | undefined): ReferenceManifestEntry[] {
if (value === undefined) return [];
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new GenerationSubmissionError("generation_request_invalid");
}
if (!Array.isArray(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
return parsed.map((entry) => {
if (!entry || typeof entry !== "object") throw new GenerationSubmissionError("generation_request_invalid");
const item = entry as Record<string, unknown>;
const keys = Object.keys(item).toSorted();
if (keys.join(",") !== "file_name,mime_type,size" || typeof item.file_name !== "string"
|| !["image/jpeg", "image/png", "image/webp"].includes(String(item.mime_type))
|| !Number.isSafeInteger(item.size) || Number(item.size) <= 0) {
throw new GenerationSubmissionError("generation_request_invalid");
}
return {
fileName: item.file_name,
mimeType: item.mime_type as NewGenerationReference["mimeType"],
projectedBytes: Number(item.size),
};
});
}
function generationFields(
values: Map<string, string>,
input: { idempotencyKey: string; userId: string },
): { fields: GenerationSubmissionFields; manifest: ReferenceManifestEntry[] } {
for (const name of values.keys()) {
if (!generationFieldNames.has(name)) throw new GenerationSubmissionError("generation_request_invalid");
}
const creationMode = values.get("creation_mode");
if (creationMode !== "new_project" && creationMode !== "existing_project") {
throw new GenerationSubmissionError("generation_request_invalid");
}
const fields: GenerationSubmissionFields = {
clientSubmissionId: values.get("client_submission_id") ?? "",
confirmedCreditCost: positiveIntegerField(values.get("confirmed_credit_cost")),
existingReferenceAssetIds: jsonStringArray(values.get("existing_reference_asset_ids")),
idempotencyKey: input.idempotencyKey,
mode: creationMode,
modelConfigVersion: positiveIntegerField(values.get("model_config_version")),
modelId: values.get("model_id") ?? "",
...(values.has("project_id") ? { projectId: values.get("project_id")! } : {}),
prompt: values.get("prompt") ?? "",
ratio: values.get("ratio") as GenerationSubmissionFields["ratio"],
userId: input.userId,
};
return { fields, manifest: referenceManifest(values.get("reference_manifest")) };
}
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
@@ -351,9 +538,26 @@ export async function createApp(options: CreateAppOptions = {}) {
},
});
await app.register(multipart, {
limits: {
fieldNameSize: 120,
fieldSize: 16 * 1024,
fields: 20,
fileSize: 100 * 1024 * 1024,
files: 16,
parts: 36,
},
});
for (const schema of [
CorrelationIdSchema,
GenerationErrorCategorySchema,
GenerationTaskStatusSchema,
GenerationTaskResponseSchema,
GenerationCreateResponseSchema,
GenerationParamsSchema,
GenerationCreateHeadersSchema,
GenerationMultipartBodySchema,
StableEngineeringErrorCodeSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
@@ -1277,6 +1481,160 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/generations/current",
{
schema: {
operationId: "getCurrentGeneration",
response: {
200: Type.Ref(GenerationTaskResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Generations"],
},
},
async (request, reply) => {
if (!options.registration || !options.generations) {
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 task = options.generations.readCurrentTask(session.userId);
return task ? generationTaskResponse(task) : reply.code(404).send(null);
},
);
app.get(
"/api/v1/generations/:generationId",
{
attachValidation: true,
schema: {
operationId: "getGeneration",
params: Type.Ref(GenerationParamsSchema),
response: {
200: Type.Ref(GenerationTaskResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Generations"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.generations) {
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 generationTaskResponse(options.generations.readTask(session.userId, (request.params as GenerationParams).generationId));
} catch (error) {
return generationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/generations",
{
attachValidation: true,
schema: {
consumes: ["multipart/form-data"],
body: Type.Optional(Type.Ref(GenerationMultipartBodySchema)),
headers: Type.Ref(GenerationCreateHeadersSchema),
operationId: "createGeneration",
response: {
200: Type.Ref(GenerationCreateResponseSchema),
201: Type.Ref(GenerationCreateResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
412: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
507: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Generations"],
},
validatorCompiler: () => (data) => ({ value: data }),
},
async (request, reply) => {
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
if (!options.registration || !options.generations) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey)
|| !csrfToken || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null);
const headers = { "idempotency-key": idempotencyKey, "x-csrf-token": csrfToken } satisfies GenerationCreateHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
let upload: GenerationUploadSession | undefined;
try {
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const current = options.generations.readCurrentTask(owner.userId);
if (current) return { created: false, task: generationTaskResponse(current) };
const values = new Map<string, string>();
let manifest: ReferenceManifestEntry[] | undefined;
let fileIndex = 0;
for await (const part of request.parts()) {
if (part.type === "field") {
if (upload || values.has(part.fieldname) || typeof part.value !== "string") {
throw new GenerationSubmissionError("generation_request_invalid");
}
values.set(part.fieldname, part.value);
continue;
}
if (part.fieldname !== "reference_files" || !part.filename) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
if (!upload) {
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
manifest = parsed.manifest;
upload = options.generations.beginUpload(parsed.fields);
}
const expected = manifest?.[fileIndex];
if (!expected || expected.fileName !== part.filename || expected.mimeType !== part.mimetype) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
await upload.stageReference({
content: part.file,
fileName: part.filename,
mimeType: expected.mimeType,
projectedBytes: expected.projectedBytes,
});
if (part.file.truncated) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
fileIndex += 1;
}
if (!upload) {
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
manifest = parsed.manifest;
upload = options.generations.beginUpload(parsed.fields);
}
if (fileIndex !== (manifest?.length ?? 0)) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
const result = await upload.commit();
return reply.code(result.created ? 201 : 200).send({ created: result.created, task: generationTaskResponse(result.task) });
} catch (error) {
upload?.abort();
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: error instanceof CreditError
? creditFailure(reply, request.id, error)
: generationFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/projects",
{
+1
View File
@@ -414,6 +414,7 @@ export class CreditService {
}
private immediate<T>(action: () => T): T {
if (this.database.inTransaction) return action();
this.database.exec("BEGIN IMMEDIATE");
try {
const result = action();
@@ -0,0 +1,30 @@
export type GenerationSubmissionErrorCode =
| "generation_blocked"
| "generation_idempotency_conflict"
| "generation_not_found"
| "generation_request_invalid"
| "generation_storage_unavailable"
| "model_config_stale"
| "reference_invalid";
export class GenerationSubmissionError extends Error {
readonly code: GenerationSubmissionErrorCode;
readonly errorCategory: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | undefined;
readonly latest: { configVersion: number; creditCost: number; modelId: string } | undefined;
readonly storage: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number } | undefined;
constructor(
code: GenerationSubmissionErrorCode,
options: {
errorCategory?: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid";
latest?: { configVersion: number; creditCost: number; modelId: string };
storage?: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number };
} = {},
) {
super(code);
this.code = code;
this.errorCategory = options.errorCategory;
this.latest = options.latest;
this.storage = options.storage;
}
}
+526
View File
@@ -0,0 +1,526 @@
import { createHash, randomUUID } from "node:crypto";
import type { Readable } from "node:stream";
import type BetterSqlite3 from "better-sqlite3";
import type { CreditService } from "./credits.js";
import { GenerationSubmissionError } from "./generation-submission-errors.js";
import type { ManagedStorage, StagedManagedFile } from "./managed-storage.js";
import {
defaultCanvasState,
defaultProjectName,
historyLimit,
normalizePrompt,
projectLimit,
projectRatios,
ratioPixels,
stableJson,
type ProjectRatio,
} from "./projects.js";
import { classifyCapacity } from "./storage-policy.js";
export { GenerationSubmissionError } from "./generation-submission-errors.js";
const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export interface GenerationModelSnapshot {
configSetVersion: number;
configVersion: number;
contractValidationStatus: "verified" | "unverified";
creditCost: number;
enabled: boolean;
modelId: string;
promptMaxLength: number;
referenceLimits: { maxFileBytes: number; maxFiles: number; maxTotalBytes: number };
runtimeAvailability: {
availableForNewJobs: boolean;
reason: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | null;
};
supportedRatios: readonly ProjectRatio[];
}
export interface GenerationModelCatalog {
readModel(modelId: string): GenerationModelSnapshot | undefined;
}
export class StaticGenerationModelCatalog implements GenerationModelCatalog {
private readonly models = new Map<string, GenerationModelSnapshot>();
constructor(models: GenerationModelSnapshot[]) {
for (const model of models) this.models.set(model.modelId, structuredClone(model));
}
readModel(modelId: string) {
const model = this.models.get(modelId);
return model ? structuredClone(model) : undefined;
}
replace(model: GenerationModelSnapshot) {
this.models.set(model.modelId, structuredClone(model));
}
}
export interface NewGenerationReference {
content: Readable;
fileName: string;
mimeType: "image/jpeg" | "image/png" | "image/webp";
projectedBytes: number;
}
export interface GenerationSubmissionInput {
clientSubmissionId: string;
confirmedCreditCost: number;
existingReferenceAssetIds: string[];
idempotencyKey: string;
mode: "new_project" | "existing_project";
modelConfigVersion: number;
modelId: string;
newReferences: NewGenerationReference[];
projectId?: string;
prompt: string;
ratio: ProjectRatio;
userId: string;
}
export type GenerationSubmissionFields = Omit<GenerationSubmissionInput, "newReferences">;
export interface GenerationTaskView {
confirmedCreditCost: number;
createdAt: string;
generationId: string;
modelConfigVersion: number;
modelId: string;
projectId: string;
prompt: string;
ratio: ProjectRatio;
referenceCount: number;
reservedCredits: number;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updatedAt: string;
}
export type GenerationSubmissionResult = { created: boolean; task: GenerationTaskView };
export interface GenerationUploadSession {
abort(): void;
commit(): Promise<GenerationSubmissionResult>;
stageReference(reference: NewGenerationReference): Promise<void>;
}
interface GenerationRow {
confirmed_credit_cost: number;
created_at: number;
generation_id: string;
model_config_version: number;
model_id: string;
owner_id: string;
project_id: string;
prompt: string;
ratio: ProjectRatio;
reserved_credits: number;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updated_at: number;
}
function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
function digest(value: string) {
return createHash("sha256").update(value, "utf8").digest("hex");
}
function safeInteger(value: number) {
return Number.isSafeInteger(value) && value > 0;
}
export class GenerationSubmissionService {
readonly database: BetterSqlite3.Database;
private readonly beforeTransaction: (() => Promise<void>) | undefined;
private readonly clock: () => number;
private readonly credits: CreditService;
private readonly models: GenerationModelCatalog;
private readonly storage: ManagedStorage;
constructor(input: {
beforeTransaction?: () => Promise<void>;
clock?: () => number;
credits: CreditService;
models: GenerationModelCatalog;
storage: ManagedStorage;
}) {
this.beforeTransaction = input.beforeTransaction;
this.clock = input.clock ?? Date.now;
this.credits = input.credits;
this.database = input.credits.database;
this.models = input.models;
this.storage = input.storage;
this.migrate();
}
close() {
// The database connection is owned by CreditService.
}
readCurrentTask(userId: string) {
const row = this.database.prepare(`
SELECT * FROM generation_jobs
WHERE owner_id = ? AND status IN ('queued', 'running') AND submission_ready = 1
ORDER BY created_at DESC, generation_id DESC LIMIT 1
`).get(userId) as GenerationRow | undefined;
return row ? this.taskView(row) : undefined;
}
readTask(userId: string, generationId: string) {
const row = this.database.prepare(`
SELECT * FROM generation_jobs WHERE generation_id = ? AND owner_id = ? AND submission_ready = 1
`).get(generationId, userId) as GenerationRow | undefined;
if (!row) throw new GenerationSubmissionError("generation_not_found");
return this.taskView(row);
}
async submit(input: GenerationSubmissionInput): Promise<GenerationSubmissionResult> {
const current = this.readCurrentTask(input.userId);
if (current) return { created: false as const, task: current };
const { newReferences, ...fields } = input;
const upload = this.beginUpload(fields);
try {
for (const reference of newReferences) await upload.stageReference(reference);
return await upload.commit();
} catch (error) {
upload.abort();
throw error;
}
}
beginUpload(input: GenerationSubmissionFields): GenerationUploadSession {
const preflightModel = this.validate({ ...input, newReferences: [] });
const staged: StagedManagedFile[] = [];
let finished = false;
const abort = () => {
if (finished) return;
finished = true;
for (const file of staged) this.storage.abandonStagedFile(file);
};
return {
abort,
commit: async () => {
if (finished) throw new GenerationSubmissionError("generation_request_invalid");
if (this.beforeTransaction) await this.beforeTransaction();
try {
const result = this.immediate(() => this.commitSubmission({ ...input, newReferences: [] }, staged));
if (result.created) finished = true;
else abort();
return result;
} catch (error) {
abort();
throw error;
}
},
stageReference: async (reference) => {
if (finished) throw new GenerationSubmissionError("generation_request_invalid");
const nextCount = input.existingReferenceAssetIds.length + staged.length + 1;
const nextBytes = staged.reduce((sum, file) => sum + file.bytes, 0) + reference.projectedBytes;
if (nextCount > preflightModel.referenceLimits.maxFiles || nextBytes > preflightModel.referenceLimits.maxTotalBytes
|| !safeInteger(reference.projectedBytes) || reference.projectedBytes > preflightModel.referenceLimits.maxFileBytes) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
try {
staged.push(await this.storage.stagePrivateImage({
content: reference.content,
expectedMimeType: reference.mimeType,
fileName: reference.fileName,
maximumBytes: preflightModel.referenceLimits.maxFileBytes,
operationId: randomUUID(),
ownerRef: input.userId,
projectedWriteBytes: reference.projectedBytes,
}));
} catch (error) {
if (error instanceof GenerationSubmissionError || (error && typeof error === "object" && "code" in error)) throw error;
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
},
};
}
private validate(input: GenerationSubmissionInput) {
if (!uuidPattern.test(input.userId) || !uuidPattern.test(input.clientSubmissionId)
|| !idempotencyPattern.test(input.idempotencyKey) || !projectRatios.includes(input.ratio)
|| !safeInteger(input.modelConfigVersion) || !safeInteger(input.confirmedCreditCost)
|| (input.mode === "existing_project" && (!input.projectId || !uuidPattern.test(input.projectId)))
|| (input.mode === "new_project" && input.projectId !== undefined)) {
throw new GenerationSubmissionError("generation_request_invalid");
}
const prompt = normalizePrompt(input.prompt);
const model = this.models.readModel(input.modelId);
if (!model) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" });
if (model.configVersion !== input.modelConfigVersion || model.creditCost !== input.confirmedCreditCost) {
throw new GenerationSubmissionError("model_config_stale", {
latest: { configVersion: model.configVersion, creditCost: model.creditCost, modelId: model.modelId },
});
}
if (!model.enabled) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" });
if (model.contractValidationStatus !== "verified") {
throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" });
}
if (!model.runtimeAvailability.availableForNewJobs) {
throw new GenerationSubmissionError("generation_blocked", { errorCategory: model.runtimeAvailability.reason ?? "model_disabled" });
}
if (prompt.length > model.promptMaxLength || !model.supportedRatios.includes(input.ratio)) {
throw new GenerationSubmissionError("generation_request_invalid");
}
const referenceCount = input.newReferences.length + input.existingReferenceAssetIds.length;
const projectedBytes = input.newReferences.reduce((sum, reference) => sum + reference.projectedBytes, 0);
if (referenceCount > model.referenceLimits.maxFiles || projectedBytes > model.referenceLimits.maxTotalBytes
|| input.newReferences.some((reference) => !safeInteger(reference.projectedBytes) || reference.projectedBytes > model.referenceLimits.maxFileBytes)
|| new Set(input.existingReferenceAssetIds).size !== input.existingReferenceAssetIds.length
|| input.existingReferenceAssetIds.some((id) => !uuidPattern.test(id))
|| (input.mode === "new_project" && input.existingReferenceAssetIds.length > 0)) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
const storageState = this.storage.getState();
if (storageState.storage_status !== "active") {
throw new GenerationSubmissionError("generation_storage_unavailable", {
storage: {
capacityStatus: storageState.storage_status,
remainingBytes: Math.max(
0,
storageState.hard_limit_bytes - storageState.managed_content_bytes - storageState.active_storage_reservations_bytes,
),
},
});
}
return model;
}
private commitSubmission(input: GenerationSubmissionInput, staged: StagedManagedFile[]) {
const model = this.validate(input);
const requestHash = digest(stableJson({
client_submission_id: input.clientSubmissionId,
confirmed_credit_cost: input.confirmedCreditCost,
existing_reference_asset_ids: input.existingReferenceAssetIds,
mode: input.mode,
model_config_version: input.modelConfigVersion,
model_id: input.modelId,
new_references: staged.map((file) => ({ bytes: file.bytes, mime_type: file.mimeType, sha256: file.sha256 })),
project_id: input.projectId ?? null,
prompt: normalizePrompt(input.prompt),
ratio: input.ratio,
}));
const keyDigest = digest(input.idempotencyKey);
const receipt = this.database.prepare(`
SELECT r.request_hash, g.* FROM generation_submission_receipts r
JOIN generation_jobs g ON g.generation_id = r.generation_id
WHERE r.owner_id = ? AND r.idempotency_key_digest = ?
`).get(input.userId, keyDigest) as (GenerationRow & { request_hash: string }) | undefined;
if (receipt) {
if (receipt.request_hash !== requestHash) throw new GenerationSubmissionError("generation_idempotency_conflict");
return { created: false as const, task: this.taskView(receipt) };
}
const bySubmission = this.database.prepare("SELECT * FROM generation_jobs WHERE client_submission_id = ?")
.get(input.clientSubmissionId) as (GenerationRow & { submission_request_hash: string | null }) | undefined;
if (bySubmission) {
if (bySubmission.submission_request_hash !== requestHash || bySubmission.owner_id !== input.userId) {
throw new GenerationSubmissionError("generation_idempotency_conflict");
}
return { created: false as const, task: this.taskView(bySubmission) };
}
const current = this.readCurrentTask(input.userId);
if (current) return { created: false as const, task: current };
const now = this.clock();
const prompt = normalizePrompt(input.prompt);
const generationId = randomUUID();
const projectId = input.mode === "new_project"
? this.insertProject(input.userId, prompt, input.ratio, now)
: this.validateExistingProject(input.userId, input.projectId!, input.ratio, prompt, now);
this.database.prepare(`
INSERT INTO generation_jobs (
generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version,
confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at,
client_submission_id, submission_request_hash, submission_ready, config_snapshot_json
) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, NULL, NULL, ?, ?, ?, ?, 0, ?)
`).run(
generationId, input.userId, projectId, prompt, input.ratio, input.modelId, input.modelConfigVersion,
input.confirmedCreditCost, now, now, input.clientSubmissionId, requestHash,
stableJson({
config_set_version: model.configSetVersion,
config_version: model.configVersion,
credit_cost: model.creditCost,
model_id: model.modelId,
prompt_max_length: model.promptMaxLength,
reference_limits: model.referenceLimits,
supported_ratios: model.supportedRatios,
}),
);
const referenceIds: string[] = [];
for (const file of staged) {
this.storage.moveStagedFile(file);
this.database.prepare(`
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
VALUES (?, 'reference', ?, ?, ?, ?, ?, 'committed', ?)
`).run(file.fileId, input.userId, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(now));
this.database.prepare(`INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'reference', ?)`)
.run(projectId, file.fileId, now);
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)`)
.run(`project:${projectId}:${file.fileId}`, file.fileId, iso(now));
referenceIds.push(file.fileId);
}
for (const referenceId of input.existingReferenceAssetIds) {
const allowed = this.database.prepare(`
SELECT mf.file_id FROM managed_files mf
JOIN project_resource_files prf ON prf.managed_file_id = mf.file_id
JOIN projects p ON p.project_id = prf.project_id
WHERE mf.file_id = ? AND mf.file_kind = 'reference' AND mf.status = 'committed'
AND mf.owner_ref = ? AND prf.project_id = ? AND p.owner_id = ? AND p.status = 'active'
`).get(referenceId, input.userId, projectId, input.userId);
if (!allowed) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
referenceIds.push(referenceId);
}
referenceIds.forEach((referenceId, index) => this.database.prepare(`
INSERT INTO generation_reference_snapshots (generation_id, position, managed_file_id, source_kind, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(generationId, index, referenceId, index < staged.length ? "uploaded" : "existing", now));
this.credits.reserveGeneration({
creditCost: input.confirmedCreditCost,
generationId,
modelId: input.modelId,
operationKey: `generation:${generationId}:reserve`,
userId: input.userId,
});
this.database.prepare("UPDATE generation_jobs SET submission_ready = 1 WHERE generation_id = ?").run(generationId);
this.database.prepare(`
INSERT INTO generation_submission_receipts (owner_id, idempotency_key_digest, request_hash, generation_id, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(input.userId, keyDigest, requestHash, generationId, now);
this.consumeStagedStorage(staged, now);
return { created: true as const, task: this.readTask(input.userId, generationId) };
}
private insertProject(ownerId: string, prompt: string, ratio: ProjectRatio, now: number) {
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
.get(ownerId) as { count: number };
if (active.count >= projectLimit) throw new GenerationSubmissionError("generation_request_invalid");
const projectId = randomUUID();
const pixels = ratioPixels[ratio];
const name = defaultProjectName(prompt, now);
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, ownerId, name, prompt, ratio, pixels.width, pixels.height, now, now);
this.database.prepare(`
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, 1, ?, ?, ?)
`).run(projectId, name, stableJson(defaultCanvasState(ratio, pixels, null)), now);
return projectId;
}
private validateExistingProject(ownerId: string, projectId: string, ratio: ProjectRatio, prompt: string, now: number) {
const project = this.database.prepare("SELECT ratio FROM projects WHERE project_id = ? AND owner_id = ? AND status = 'active'")
.get(projectId, ownerId) as { ratio: ProjectRatio } | undefined;
if (!project || project.ratio !== ratio) throw new GenerationSubmissionError("generation_request_invalid");
const history = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?").get(projectId) as { count: number };
if (history.count >= historyLimit) throw new GenerationSubmissionError("generation_request_invalid");
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?").run(prompt, now, projectId);
return projectId;
}
private consumeStagedStorage(staged: StagedManagedFile[], now: number) {
if (staged.length === 0) return;
const total = staged.reduce((sum, file) => sum + file.bytes, 0);
for (const file of staged) {
this.database.prepare(`
UPDATE storage_reservations SET status = 'consumed', resolved_at = ?
WHERE operation_id = ? AND status = 'active'
`).run(iso(now), file.operationId);
}
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1")
.get() as { managed_content_bytes: number };
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'")
.get() as { bytes: number };
const nextBytes = state.managed_content_bytes + total;
const classification = classifyCapacity(nextBytes, active.bytes);
this.database.prepare(`
UPDATE local_backend_storage_state
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
WHERE singleton = 1
`).run(nextBytes, classification.capacity_notice_level, classification.storage_status, iso(now));
}
private taskView(row: GenerationRow): GenerationTaskView {
const referenceCount = (this.database.prepare("SELECT COUNT(*) AS count FROM generation_reference_snapshots WHERE generation_id = ?")
.get(row.generation_id) as { count: number }).count;
return {
confirmedCreditCost: row.confirmed_credit_cost,
createdAt: iso(row.created_at),
generationId: row.generation_id,
modelConfigVersion: row.model_config_version,
modelId: row.model_id,
projectId: row.project_id,
prompt: row.prompt,
ratio: row.ratio,
referenceCount,
reservedCredits: row.reserved_credits,
status: row.status,
updatedAt: iso(row.updated_at),
};
}
private immediate<T>(action: () => T): T {
this.database.exec("BEGIN IMMEDIATE");
try {
const result = action();
this.database.exec("COMMIT");
return result;
} catch (error) {
if (this.database.inTransaction) this.database.exec("ROLLBACK");
throw error;
}
}
private ensureColumn(table: string, column: string, definition: string) {
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
private migrate() {
this.ensureColumn("generation_jobs", "client_submission_id", "TEXT");
this.ensureColumn("generation_jobs", "submission_request_hash", "TEXT");
this.ensureColumn("generation_jobs", "submission_ready", "INTEGER NOT NULL DEFAULT 0");
this.ensureColumn("generation_jobs", "config_snapshot_json", "TEXT");
this.database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_active_owner
ON generation_jobs(owner_id) WHERE status IN ('queued', 'running');
CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_client_submission
ON generation_jobs(client_submission_id) WHERE client_submission_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS generation_submission_receipts (
owner_id TEXT NOT NULL,
idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64),
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
generation_id TEXT NOT NULL UNIQUE REFERENCES generation_jobs(generation_id),
created_at INTEGER NOT NULL,
PRIMARY KEY (owner_id, idempotency_key_digest)
);
CREATE TABLE IF NOT EXISTS generation_reference_snapshots (
generation_id TEXT NOT NULL REFERENCES generation_jobs(generation_id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK (position >= 0),
managed_file_id TEXT NOT NULL REFERENCES managed_files(file_id),
source_kind TEXT NOT NULL CHECK (source_kind IN ('uploaded', 'existing')),
created_at INTEGER NOT NULL,
PRIMARY KEY (generation_id, position),
UNIQUE (generation_id, managed_file_id)
);
CREATE TRIGGER IF NOT EXISTS generation_reference_snapshots_no_update
BEFORE UPDATE ON generation_reference_snapshots BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END;
DROP TRIGGER IF EXISTS generation_reference_snapshots_no_delete;
CREATE TRIGGER generation_reference_snapshots_no_delete
BEFORE DELETE ON generation_reference_snapshots
WHEN dada_allow_privacy_purge() <> 1
BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END;
`);
}
}
+99 -1
View File
@@ -113,6 +113,9 @@ function sniffMime(prefix: Buffer) {
return "image/png";
}
if (prefix.length >= 3 && prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg";
if (prefix.length >= 12 && prefix.subarray(0, 4).toString("ascii") === "RIFF" && prefix.subarray(8, 12).toString("ascii") === "WEBP") {
return "image/webp";
}
return "application/octet-stream";
}
@@ -129,7 +132,7 @@ function listFiles(root: string): string[] {
export interface CommitStreamInput {
content: Readable;
expectedMimeType: "image/png" | "image/jpeg" | "application/octet-stream";
expectedMimeType: "image/png" | "image/jpeg" | "image/webp" | "application/octet-stream";
expectedSha256?: string;
failurePoint?: CommitFailurePoint;
fileKind: ManagedFileKind;
@@ -139,6 +142,20 @@ export interface CommitStreamInput {
projectedWriteBytes: number;
}
export interface StagedManagedFile {
bytes: number;
destinationPath: string;
fileId: string;
fileKind: ManagedFileKind;
mimeType: "image/png" | "image/jpeg" | "image/webp";
operationId: string;
ownerRef: string;
relativePath: string;
sha256: string;
stagingDirectory: string;
stagingPath: string;
}
export class ManagedStorage {
readonly dataRoot: string;
readonly databasePath: string;
@@ -479,6 +496,87 @@ export class ManagedStorage {
}
}
async stagePrivateImage(input: {
content: Readable;
expectedMimeType: "image/png" | "image/jpeg" | "image/webp";
fileName: string;
maximumBytes: number;
operationId: string;
ownerRef: string;
projectedWriteBytes: number;
}): Promise<StagedManagedFile> {
const fileId = randomUUID();
const destination = this.destination({
content: input.content,
expectedMimeType: input.expectedMimeType,
fileKind: "reference",
fileName: input.fileName,
operationId: input.operationId,
ownerRef: input.ownerRef,
projectedWriteBytes: input.projectedWriteBytes,
}, fileId);
if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes <= 0) throw new Error("maximum_bytes_invalid");
this.reserve(input.operationId, input.projectedWriteBytes);
const stagingDirectory = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}`);
const stagingPath = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}/payload.tmp`);
try {
mkdirSync(stagingDirectory, { recursive: true });
const hash = createHash("sha256");
let byteSize = 0;
let prefix = Buffer.alloc(0);
const inspect = new Transform({
transform(chunk: Buffer | string, encoding, callback) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
byteSize += bytes.byteLength;
if (byteSize > input.maximumBytes) return callback(new Error("content_size_invalid"));
hash.update(bytes);
if (prefix.byteLength < 16) prefix = Buffer.concat([prefix, bytes.subarray(0, 16 - prefix.byteLength)]);
callback(null, bytes);
},
});
await pipeline(input.content, inspect, createWriteStream(stagingPath, { flags: "wx" }));
validatePositiveBytes(byteSize, "actual_write_bytes");
if (sniffMime(prefix) !== input.expectedMimeType) throw new Error("content_mime_invalid");
const state = this.getState();
const otherReservations = this.activeReservationBytes(input.operationId);
if (state.managed_content_bytes + otherReservations + byteSize > HARD_LIMIT_BYTES) {
throw new StorageCapacityError({ activeReservationBytes: otherReservations, managedContentBytes: state.managed_content_bytes, projectedWriteBytes: byteSize });
}
this.database.prepare("UPDATE storage_reservations SET projected_bytes = ? WHERE operation_id = ? AND status = 'active'")
.run(byteSize, input.operationId);
this.refreshState();
return {
bytes: byteSize,
destinationPath: destination.absolutePath,
fileId,
fileKind: "reference",
mimeType: input.expectedMimeType,
operationId: input.operationId,
ownerRef: input.ownerRef,
relativePath: destination.relativePath,
sha256: hash.digest("hex"),
stagingDirectory,
stagingPath,
};
} catch (error) {
rmSync(stagingDirectory, { force: true, recursive: true });
this.releaseReservation(input.operationId);
throw error;
}
}
moveStagedFile(file: StagedManagedFile) {
mkdirSync(dirname(file.destinationPath), { recursive: true });
renameSync(file.stagingPath, file.destinationPath);
rmSync(file.stagingDirectory, { force: true, recursive: true });
}
abandonStagedFile(file: StagedManagedFile) {
if (existsSync(file.destinationPath)) this.queueCompensation(file.relativePath, statSync(file.destinationPath).size);
else rmSync(file.stagingDirectory, { force: true, recursive: true });
this.releaseReservation(file.operationId);
}
async commitBufferFixture(fileKind: ManagedFileKind, fileName: string, bytes: Buffer) {
return this.commitStream({
content: Readable.from(bytes),
+7 -7
View File
@@ -15,14 +15,14 @@ export type ProjectRatio = typeof projectRatios[number];
export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference";
const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
export 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;
export const projectLimit = 20;
export const historyLimit = 10;
const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000;
const generationErrorCategories = new Set([
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
@@ -76,7 +76,7 @@ function isProjectRatio(value: string): value is ProjectRatio {
return projectRatios.includes(value as ProjectRatio);
}
function normalizePrompt(value: string) {
export 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;
@@ -100,7 +100,7 @@ function localDate(timestamp: number) {
.join("-");
}
function defaultProjectName(prompt: string, timestamp: number) {
export function defaultProjectName(prompt: string, timestamp: number) {
const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作";
return `${summary} ${localDate(timestamp)}`;
}
@@ -115,7 +115,7 @@ function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
export function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
return {
background: {
adjustments: {
@@ -138,7 +138,7 @@ function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width
};
}
function stableJson(value: unknown): string {
export function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
+22 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -90,6 +90,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
return response.json() as Promise<RegistrationCompleteResponse>;
}
export async function createGeneration(options: ClientOptions = {}): Promise<GenerationCreateResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<GenerationCreateResponse>;
}
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} });
@@ -150,10 +157,24 @@ export async function getBootstrap(options: ClientOptions = {}): Promise<{
}>;
}
export async function getCurrentGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/current`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<GenerationTaskResponse>;
}
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
return `${options.baseUrl ?? ""}/api/v1/events`;
}
export async function getGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/{generationId}`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<GenerationTaskResponse>;
}
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
+45
View File
@@ -320,8 +320,36 @@ export type FailedEmptyTrashResponse = {
"trashed_project_ids": Array<ProjectId>;
};
export type GenerationCreateHeaders = {
"idempotency-key": string;
"x-csrf-token": string;
};
export type GenerationCreateResponse = {
"created": boolean;
"task": GenerationTaskResponse;
};
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 GenerationMultipartBody = {
"client_submission_id": string;
"confirmed_credit_cost": string;
"creation_mode": "new_project" | "existing_project";
"existing_reference_asset_ids"?: string;
"model_config_version": string;
"model_id": string;
"project_id"?: string;
"prompt": string;
"ratio": ProjectRatio;
"reference_files"?: Array<string>;
"reference_manifest"?: string;
};
export type GenerationParams = {
"generationId": string;
};
export type GenerationProjectItem = {
"created_at": string;
"error_category": GenerationErrorCategory | null;
@@ -332,6 +360,23 @@ export type GenerationProjectItem = {
"updated_at": string;
};
export type GenerationTaskResponse = {
"confirmed_credit_cost": number;
"created_at": string;
"generation_id": string;
"model_config_version": number;
"model_id": string;
"project_id": ProjectId;
"prompt": string;
"ratio": ProjectRatio;
"reference_count": number;
"reserved_credits": number;
"status": GenerationTaskStatus;
"updated_at": string;
};
export type GenerationTaskStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export type LoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
+93
View File
@@ -370,6 +370,93 @@
font-size: 13px;
}
.current-task-active {
display: grid;
gap: 22px;
align-content: start;
margin-top: 28px;
padding-top: 18px;
border-top: 1px solid #a6a6a0;
}
.current-task-active .task-state {
justify-self: start;
padding: 7px 10px;
border: 1px solid #111111;
background: #f2f500;
font-family: Consolas, monospace;
font-size: 12px;
font-weight: 800;
}
.current-task-active > strong {
overflow-wrap: anywhere;
font-size: 20px;
line-height: 1.4;
}
.current-task-active dl {
display: grid;
gap: 0;
margin: 0;
border-top: 1px solid #b6b6b0;
}
.current-task-active dl > div {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 11px 0;
border-bottom: 1px solid #b6b6b0;
}
.current-task-active dt {
color: #65655f;
}
.current-task-active dd {
margin: 0;
font-weight: 800;
}
.current-task-active a {
color: #111111;
font-weight: 800;
}
.capacity-critical,
.generation-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #111111;
}
.capacity-critical {
background: #ffdf66;
}
.capacity-critical span,
.generation-notice span {
font-size: 13px;
}
.generation-notice {
background: #ffffff;
}
.generation-notice button {
flex: 0 0 auto;
min-height: 38px;
padding: 7px 12px;
border: 1px solid #111111;
background: #f2f500;
font-weight: 800;
}
.recent-projects {
grid-column: 1;
margin-top: 28px;
@@ -1167,6 +1254,12 @@
text-align: left;
}
.capacity-critical,
.generation-notice {
align-items: flex-start;
flex-direction: column;
}
.projects-title {
display: grid;
}
+207 -10
View File
@@ -16,9 +16,53 @@ type ProjectStatus = "active" | "failed_empty" | "trashed";
interface SessionPayload {
credits: { available_balance: number; reserved_balance: number };
csrf_token: string;
local_data?: LocalDataPayload;
user: { creator_name: string };
}
interface LocalDataPayload {
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
hard_limit_bytes: number;
managed_content_bytes: number;
}
interface AccountSettingsPayload {
local_data: LocalDataPayload;
}
interface ModelPayload {
config_set_version: number;
configured_default_model_id: string;
models: Array<{
config_version: number;
contract_validation_status: "verified" | "unverified";
credit_cost: number;
enabled: boolean;
is_default: boolean;
model_id: string;
prompt_max_length: number;
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
runtime_availability: { available_for_new_jobs: boolean; checked_at: string; reason: string | null };
supported_ratios: Ratio[];
}>;
recommended_model_id: string | null;
}
interface GenerationTaskPayload {
confirmed_credit_cost: number;
created_at: string;
generation_id: string;
model_config_version: number;
model_id: string;
project_id: string;
prompt: string;
ratio: Ratio;
reference_count: number;
reserved_credits: number;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updated_at: string;
}
interface ProjectSummary {
current_image_id: string | null;
deleted_at?: string | null;
@@ -67,6 +111,17 @@ async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>;
}
async function readOptionalJson<T>(url: string): Promise<T | undefined> {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (response.status === 404) return undefined;
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));
}
@@ -151,10 +206,17 @@ export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState<SessionPayload>();
const [projects, setProjects] = useState<ProjectListPayload>();
const [models, setModels] = useState<ModelPayload>();
const [currentTask, setCurrentTask] = useState<GenerationTaskPayload>();
const [localData, setLocalData] = useState<LocalDataPayload>();
const [generationStateLoaded, setGenerationStateLoaded] = useState(false);
const [loadingFailed, setLoadingFailed] = useState(false);
const [prompt, setPrompt] = useState("");
const [ratio, setRatio] = useState<Ratio>("3:4");
const [references, setReferences] = useState<string[]>([]);
const [references, setReferences] = useState<File[]>([]);
const [submitting, setSubmitting] = useState(false);
const [generationNotice, setGenerationNotice] = useState("");
const [requiresReconfirmation, setRequiresReconfirmation] = useState(false);
useEffect(() => {
let active = true;
@@ -165,12 +227,113 @@ export function WorkspacePage() {
if (!active) return;
setSession(nextSession);
setProjects(nextProjects);
setLocalData(nextSession.local_data);
return Promise.allSettled([
readOptionalJson<ModelPayload>("/api/v1/models"),
readOptionalJson<GenerationTaskPayload>("/api/v1/generations/current"),
readOptionalJson<AccountSettingsPayload>("/api/v1/account/settings"),
]).then(([modelResult, taskResult, settingsResult]) => {
if (!active) return;
if (modelResult.status === "fulfilled") setModels(modelResult.value);
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
setGenerationStateLoaded(true);
});
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, []);
const selectedModel = useMemo(() => {
if (!models) return undefined;
const modelId = models.recommended_model_id ?? models.configured_default_model_id;
return models.models.find((model) => model.model_id === modelId && model.enabled
&& model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs);
}, [models]);
const referenceBytes = references.reduce((sum, file) => sum + file.size, 0);
const referencesValid = selectedModel !== undefined
&& references.length <= selectedModel.reference_limits.max_files
&& referenceBytes <= selectedModel.reference_limits.max_total_bytes
&& references.every((file) => file.size > 0 && file.size <= selectedModel.reference_limits.max_file_bytes);
const capacityBlocksGeneration = localData?.capacity_status === "full" || localData?.capacity_status === "unavailable";
const canSubmit = generationStateLoaded && !currentTask && !submitting && !requiresReconfirmation && selectedModel !== undefined
&& prompt.trim().length > 0 && prompt.trim().length <= selectedModel.prompt_max_length
&& selectedModel.supported_ratios.includes(ratio) && referencesValid
&& session !== undefined && session.credits.available_balance >= selectedModel.credit_cost && !capacityBlocksGeneration;
async function submitGeneration(event: FormEvent) {
event.preventDefault();
if (!session || !selectedModel || !canSubmit) return;
const body = new FormData();
body.append("client_submission_id", crypto.randomUUID());
body.append("confirmed_credit_cost", String(selectedModel.credit_cost));
body.append("creation_mode", "new_project");
body.append("existing_reference_asset_ids", "[]");
body.append("model_config_version", String(selectedModel.config_version));
body.append("model_id", selectedModel.model_id);
body.append("prompt", prompt.trim());
body.append("ratio", ratio);
body.append("reference_manifest", JSON.stringify(references.map((file) => ({
file_name: file.name,
mime_type: file.type,
size: file.size,
}))));
for (const file of references) body.append("reference_files", file, file.name);
setSubmitting(true);
setGenerationNotice("");
try {
const response = await fetch("/api/v1/generations", {
body,
credentials: "same-origin",
headers: {
"Idempotency-Key": `generation-${crypto.randomUUID()}`,
"X-CSRF-Token": session.csrf_token,
},
method: "POST",
});
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
return;
}
if (response.status === 412) {
setRequiresReconfirmation(true);
setGenerationNotice("模型配置已更新,请确认最新配置后重新提交。");
return;
}
if (!response.ok) {
setGenerationNotice(response.status === 507 ? "本机存储空间不足,当前不能创建新任务。" : "任务未提交,请检查当前状态后重试。");
return;
}
const result = await response.json() as { created: boolean; task: GenerationTaskPayload };
setCurrentTask(result.task);
setSession((current) => current ? {
...current,
credits: {
available_balance: current.credits.available_balance - (result.created ? result.task.reserved_credits : 0),
reserved_balance: current.credits.reserved_balance + (result.created ? result.task.reserved_credits : 0),
},
} : current);
setGenerationNotice(result.created ? "任务已提交。" : "已返回当前进行中的任务。");
} catch {
setGenerationNotice("任务未提交,请检查本机服务后重试。");
} finally {
setSubmitting(false);
}
}
async function confirmLatestModelConfiguration() {
try {
const next = await readJson<ModelPayload>("/api/v1/models");
setModels(next);
setRequiresReconfirmation(false);
setGenerationNotice("已确认最新配置,请重新检查点数和参考图后提交。");
} catch {
setGenerationNotice("暂时无法读取最新模型配置。");
}
}
if (loadingFailed) {
return (
<main className="product-loading">
@@ -197,7 +360,7 @@ export function WorkspacePage() {
</section>
) : null}
<main className={`workspace-main ${empty ? "is-empty" : "has-projects"}`}>
<section className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"}>
<form className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"} onSubmit={submitGeneration}>
{!empty ? (
<div className="workspace-compact-heading">
<div><p>NEW PROJECT</p><h1 id="workspace-compact-title"></h1></div>
@@ -215,14 +378,21 @@ export function WorkspacePage() {
<div className="generation-options">
<div className="model-status" aria-live="polite">
<span></span>
<strong></strong>
<strong>{selectedModel ? selectedModel.model_id : "当前没有可用于新任务的模型"}</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} />
<input
checked={ratio === value}
disabled={selectedModel !== undefined && !selectedModel.supported_ratios.includes(value)}
name="ratio"
onChange={() => setRatio(value)}
type="radio"
value={value}
/>
<span>{value}</span>
</label>
))}
@@ -231,22 +401,49 @@ export function WorkspacePage() {
</div>
<label className="reference-input">
<span></span>
<small>{references.length > 0 ? references.join("、") : "尚未提交,仅保留在当前页面"}</small>
<small>{references.length > 0 ? references.map((file) => file.name).join("、") : "尚未提交,仅保留在当前页面"}</small>
<input
accept="image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setReferences(Array.from(event.target.files ?? []).map((file) => file.name))}
onChange={(event) => setReferences(Array.from(event.target.files ?? []))}
type="file"
/>
</label>
{localData?.capacity_status === "critical" ? (
<div className="capacity-critical" role="status">
<strong> 90%</strong>
<span></span>
</div>
) : null}
{generationNotice ? (
<div className="generation-notice" role="status">
<span>{generationNotice}</span>
{requiresReconfirmation ? (
<button onClick={confirmLatestModelConfiguration} type="button"></button>
) : null}
</div>
) : null}
<div className="generation-submit">
<span></span>
<button disabled type="button"></button>
<span>{selectedModel ? `本次预计冻结 ${selectedModel.credit_cost}` : "预计点数将在模型可用后显示"}</span>
<button disabled={!canSubmit} type="submit">{submitting ? "正在提交" : "生成一张图片"}</button>
</div>
</section>
</form>
<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>
{currentTask ? (
<div className="current-task-active">
<span className="task-state">{currentTask.status === "queued" ? "排队中" : "处理中"}</span>
<strong>{currentTask.prompt}</strong>
<dl>
<div><dt></dt><dd>{currentTask.ratio}</dd></div>
<div><dt></dt><dd>{currentTask.reference_count} </dd></div>
<div><dt></dt><dd> {currentTask.reserved_credits} </dd></div>
</dl>
<a href={`/app/projects/${currentTask.project_id}`}></a>
</div>
) : (
<div className="current-task-empty"><i /><strong></strong><span></span></div>
)}
</aside>
{!empty ? (
<section className="recent-projects" aria-labelledby="recent-title">