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
+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">