feat: complete TASK-WP2-06 generation terminal states
This commit is contained in:
@@ -363,12 +363,14 @@ export type GenerationProjectItem = {
|
||||
export type GenerationTaskResponse = {
|
||||
"confirmed_credit_cost": number;
|
||||
"created_at": string;
|
||||
"error_category": GenerationErrorCategory | null;
|
||||
"generation_id": string;
|
||||
"model_config_version": number;
|
||||
"model_id": string;
|
||||
"project_id": ProjectId;
|
||||
"prompt": string;
|
||||
"ratio": ProjectRatio;
|
||||
"reference_asset_ids": Array<string>;
|
||||
"reference_count": number;
|
||||
"reserved_credits": number;
|
||||
"status": GenerationTaskStatus;
|
||||
|
||||
@@ -227,6 +227,17 @@
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.model-status select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 6px 30px 6px 9px;
|
||||
border: 1px solid #75756f;
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.model-status span,
|
||||
.ratio-control legend,
|
||||
.reference-input small {
|
||||
@@ -419,11 +430,20 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.current-task-active a {
|
||||
.current-task-active a,
|
||||
.current-task-active button {
|
||||
color: #111111;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.current-task-active button {
|
||||
justify-self: start;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.capacity-critical,
|
||||
.generation-notice {
|
||||
display: flex;
|
||||
|
||||
@@ -51,18 +51,36 @@ interface ModelPayload {
|
||||
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<GenerationErrorCategory, string> = {
|
||||
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;
|
||||
@@ -207,6 +225,7 @@ export function WorkspacePage() {
|
||||
const [session, setSession] = useState<SessionPayload>();
|
||||
const [projects, setProjects] = useState<ProjectListPayload>();
|
||||
const [models, setModels] = useState<ModelPayload>();
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>();
|
||||
const [currentTask, setCurrentTask] = useState<GenerationTaskPayload>();
|
||||
const [localData, setLocalData] = useState<LocalDataPayload>();
|
||||
const [generationStateLoaded, setGenerationStateLoaded] = useState(false);
|
||||
@@ -217,6 +236,9 @@ export function WorkspacePage() {
|
||||
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<string | undefined>(() => query.get("retry") ?? query.get("continue") ?? undefined);
|
||||
const [existingReferenceAssetIds, setExistingReferenceAssetIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -245,12 +267,31 @@ export function WorkspacePage() {
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetProjectId || prompt) return;
|
||||
readOptionalJson<ProjectDetailPayload>(`/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<GenerationTaskPayload>(`/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 = models.recommended_model_id ?? models.configured_default_model_id;
|
||||
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]);
|
||||
}, [models, selectedModelId]);
|
||||
|
||||
const referenceBytes = references.reduce((sum, file) => sum + file.size, 0);
|
||||
const referencesValid = selectedModel !== undefined
|
||||
@@ -269,12 +310,13 @@ export function WorkspacePage() {
|
||||
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("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,
|
||||
@@ -323,6 +365,28 @@ export function WorkspacePage() {
|
||||
}
|
||||
}
|
||||
|
||||
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<ModelPayload>("/api/v1/models");
|
||||
@@ -378,7 +442,12 @@ export function WorkspacePage() {
|
||||
<div className="generation-options">
|
||||
<div className="model-status" aria-live="polite">
|
||||
<span>模型</span>
|
||||
<strong>{selectedModel ? selectedModel.model_id : "当前没有可用于新任务的模型"}</strong>
|
||||
{models?.models.some((model) => model.enabled && model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs) ? (
|
||||
<select aria-label="模型" onChange={(event) => setSelectedModelId(event.target.value || undefined)} value={selectedModel?.model_id ?? ""}>
|
||||
{models.models.filter((model) => model.enabled && model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs)
|
||||
.map((model) => <option key={model.model_id} value={model.model_id}>{model.model_id}</option>)}
|
||||
</select>
|
||||
) : <strong>当前没有可用于新任务的模型</strong>}
|
||||
</div>
|
||||
<fieldset className="ratio-control">
|
||||
<legend>画面比例</legend>
|
||||
@@ -432,14 +501,20 @@ export function WorkspacePage() {
|
||||
<h2 id="current-task-title">当前任务</h2>
|
||||
{currentTask ? (
|
||||
<div className="current-task-active">
|
||||
<span className="task-state">{currentTask.status === "queued" ? "排队中" : "处理中"}</span>
|
||||
<span className="task-state">{{
|
||||
failed: "生成失败", queued: "排队中", rejected: "请求未通过", running: "处理中", succeeded: "生成成功",
|
||||
}[currentTask.status]}</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>
|
||||
<div><dt>点数</dt><dd>{currentTask.status === "succeeded" ? `已扣除 ${currentTask.confirmed_credit_cost} 点` : ["failed", "rejected"].includes(currentTask.status) ? "已释放冻结点" : `已冻结 ${currentTask.reserved_credits} 点`}</dd></div>
|
||||
</dl>
|
||||
<a href={`/app/projects/${currentTask.project_id}`}>返回当前项目</a>
|
||||
{currentTask.status === "succeeded" ? <a href={`/app/projects/${currentTask.project_id}`}>进入编辑</a> : null}
|
||||
{["queued", "running"].includes(currentTask.status) ? <a href={`/app/projects/${currentTask.project_id}`}>返回当前项目</a> : null}
|
||||
{["failed", "rejected"].includes(currentTask.status) && currentTask.error_category ? (
|
||||
<button onClick={() => handleTerminalAction(currentTask)} type="button">{generationErrorActions[currentTask.error_category]}</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="current-task-empty"><i /><strong>没有进行中的任务</strong><span>任务状态会持续显示在这里</span></div>
|
||||
|
||||
Reference in New Issue
Block a user