import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react"; import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts"; import { ConflictExportGuard, ProjectAutoSaveQueue, ProjectStateConflict, type ProjectSaveStatus, } from "./project-autosave.js"; 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; 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; error_category: GenerationErrorCategory | null; generation_id: string; model_config_version: number; model_id: string; project_id: string; prompt: string; ratio: Ratio; reference_asset_ids?: string[]; reference_count: number; reserved_credits: number; status: "queued" | "running" | "succeeded" | "failed" | "rejected"; updated_at: string; } type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; const generationErrorActions: Record = { gateway_balance_insufficient: "选择未受影响模型或联系管理员", gateway_contract_invalid: "选择其他模型或联系管理员", model_disabled: "选择其他模型或等待", reference_invalid: "更换或移除参考图", safety_rejected: "修改提示词或参考图", unknown_non_retryable: "联系管理员", unknown_retryable: "稍后重试", upstream_failed: "稍后重试", upstream_timeout: "使用原输入重试", }; 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 { canvas_state: CanvasState; 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 }>; latest_exports: Array<{ byte_size: number; created_at: string; download_url: string; export_id: string; format: "jpg" | "png"; pixel_height: number; pixel_width: number; sha256: string; state_version: number; }>; pixel_height?: number; pixel_width?: number; save_status: "saved"; } async function readJson(url: string, init?: RequestInit): Promise { 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; } async function readOptionalJson(url: string): Promise { 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; } function formatUpdatedAt(value: string) { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); } function initialCanvasState(project: Pick): CanvasState { return { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0, }, asset_id: project.current_image_id, }, elements: [], pixel_height: project.pixel_height ?? (project.ratio === "9:16" ? 1920 : project.ratio === "3:4" ? 1440 : 1080), pixel_width: project.pixel_width ?? (project.ratio === "4:3" ? 1440 : 1080), ratio: project.ratio, schema_version: 1, }; } async function downloadConflictPng(canvasState: CanvasState, projectName: string) { const canvas = document.createElement("canvas"); canvas.width = canvasState.pixel_width; canvas.height = canvasState.pixel_height; const context = canvas.getContext("2d"); if (!context) throw new Error("canvas_unavailable"); context.fillStyle = "#f6f6f4"; context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = "#d4d4cf"; const stripe = canvas.width / 4; for (let index = 0; index < 4; index += 1) { if (index % 2 === 1) context.fillRect(index * stripe, 0, stripe, canvas.height); } context.fillStyle = "#111111"; context.font = `900 ${Math.max(64, Math.floor(canvas.width / 7))}px Arial`; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText("DADA", canvas.width / 2, canvas.height / 2); const blob = await new Promise((resolve, reject) => { canvas.toBlob((value) => value ? resolve(value) : reject(new Error("canvas_export_failed")), "image/png"); }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.download = `${projectName.trim().replace(/[\\/:*?"<>|]+/g, "-") || "Dada"}-本页版本.png`; anchor.href = url; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 0); } export function ProductHeader({ current }: { current: "workspace" | "projects" | "credits" }) { return (
DADA
); } export function LocalOnlyFooter() { return
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
; } function LoadingPage({ label }: { label: string }) { return
{label}
; } function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectStatus }) { return ( ); } export function WorkspacePage() { const promptId = useId(); const [session, setSession] = useState(); const [projects, setProjects] = useState(); const [models, setModels] = useState(); const [selectedModelId, setSelectedModelId] = useState(); const [currentTask, setCurrentTask] = useState(); const [localData, setLocalData] = useState(); const [generationStateLoaded, setGenerationStateLoaded] = useState(false); const [loadingFailed, setLoadingFailed] = useState(false); const [prompt, setPrompt] = useState(""); const [ratio, setRatio] = useState("3:4"); const [references, setReferences] = useState([]); const [submitting, setSubmitting] = useState(false); const [generationNotice, setGenerationNotice] = useState(""); const [requiresReconfirmation, setRequiresReconfirmation] = useState(false); const query = useMemo(() => new URLSearchParams(window.location.search), []); const [targetProjectId, setTargetProjectId] = useState(() => query.get("retry") ?? query.get("continue") ?? undefined); const [existingReferenceAssetIds, setExistingReferenceAssetIds] = useState([]); useEffect(() => { let active = true; Promise.all([ readJson("/api/v1/auth/session"), readJson("/api/v1/projects?status=active"), ]).then(([nextSession, nextProjects]) => { if (!active) return; setSession(nextSession); setProjects(nextProjects); setLocalData(nextSession.local_data); return Promise.allSettled([ readOptionalJson("/api/v1/models"), readOptionalJson("/api/v1/generations/current"), readOptionalJson("/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; }; }, []); useEffect(() => { if (!targetProjectId || prompt) return; readOptionalJson(`/api/v1/projects/${targetProjectId}`).then((project) => { if (!project) return; setPrompt(project.draft_prompt); setRatio(project.ratio); }).catch(() => setGenerationNotice("暂时无法读取原项目。")); }, [prompt, targetProjectId]); useEffect(() => { if (!currentTask || !["queued", "running"].includes(currentTask.status)) return; const timer = window.setInterval(() => { readOptionalJson(`/api/v1/generations/${currentTask.generation_id}`) .then((task) => { if (task) setCurrentTask(task); }) .catch(() => undefined); }, 2_000); return () => window.clearInterval(timer); }, [currentTask?.generation_id, currentTask?.status]); const selectedModel = useMemo(() => { if (!models) return undefined; const modelId = selectedModelId ?? 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, selectedModelId]); 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", targetProjectId ? "existing_project" : "new_project"); body.append("existing_reference_asset_ids", JSON.stringify(existingReferenceAssetIds)); 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); if (targetProjectId) body.append("project_id", targetProjectId); 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); } } function handleTerminalAction(task: GenerationTaskPayload) { const category = task.error_category; if (!category) return; if (category === "unknown_non_retryable") { setGenerationNotice("请联系超级管理员处理此任务。"); return; } setPrompt(task.prompt); setRatio(task.ratio); setTargetProjectId(task.project_id); setExistingReferenceAssetIds(task.reference_asset_ids ?? []); setCurrentTask(undefined); if (["model_disabled", "gateway_balance_insufficient", "gateway_contract_invalid"].includes(category)) { setSelectedModelId(undefined); setGenerationNotice("请选择当前可用模型后重新提交。"); } else if (category === "safety_rejected" || category === "reference_invalid") { setGenerationNotice("请修改输入后重新提交。"); } else { setGenerationNotice("已恢复原任务输入,可以重新提交。"); } } async function confirmLatestModelConfiguration() { try { const next = await readJson("/api/v1/models"); setModels(next); setRequiresReconfirmation(false); setGenerationNotice("已确认最新配置,请重新检查点数和参考图后提交。"); } catch { setGenerationNotice("暂时无法读取最新模型配置。"); } } if (loadingFailed) { return (

创作工作台暂时无法读取。

); } if (!session || !projects) return ; const empty = projects.projects.length === 0; return (
{empty ? (
DADA

开始一张新作品

) : null}
{!empty ? (

NEW PROJECT

新建创作

当前可用 {session.credits.available_balance} 点
) : null}