feat: add task lifecycle management and improve generation reliability
Centralize timeout policies, stall detection, and error classification for image/video/text generation tasks. Improve ecommerce OSS upload flow and add script evaluation enhancements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,12 @@ import {
|
||||
import { renderMarkdownBlocks } from "./markdownRenderer";
|
||||
import { downloadResultAsset } from "./workbenchDownload";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
formatTextTokenUsage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../../utils/taskLifecycle";
|
||||
import { detectMentionTrigger } from "../../utils/mentionTrigger";
|
||||
import {
|
||||
isHappyHorseModel,
|
||||
@@ -865,6 +871,9 @@ function WorkbenchPage({
|
||||
|
||||
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
|
||||
let taskPollFailures = 0;
|
||||
let lastProgressAt = task.startedAt || Date.now();
|
||||
const taskKind = task.mode === "image" ? "image" : "video";
|
||||
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
|
||||
const abortController = new AbortController();
|
||||
taskAbortControllersRef.current.set(task.taskId, abortController);
|
||||
if (activeConversationIdRef.current === task.conversationId) {
|
||||
@@ -911,6 +920,9 @@ function WorkbenchPage({
|
||||
const progress = status.status === "completed"
|
||||
? 100
|
||||
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
|
||||
if (progress > lastKnownProgress || status.status === "completed") {
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
lastKnownProgress = Math.max(lastKnownProgress, progress);
|
||||
const isSuperResolveTask = task.operation === "video-super-resolution";
|
||||
const statusLabel =
|
||||
@@ -935,6 +947,28 @@ function WorkbenchPage({
|
||||
setGenerationProgress(progress);
|
||||
}
|
||||
|
||||
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
|
||||
? isTaskLocallyTimedOut({
|
||||
startedAt: task.startedAt || Date.now(),
|
||||
lastProgressAt,
|
||||
progress,
|
||||
policy: timeoutPolicy,
|
||||
})
|
||||
: null;
|
||||
if (localTimeoutReason) {
|
||||
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
|
||||
body: buildLocalTimeoutMessage(taskKind),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: "unknown",
|
||||
taskProgress: progress,
|
||||
taskStatusLabel: "本地等待超时",
|
||||
});
|
||||
removeKeepaliveTask(task.taskId);
|
||||
onRefreshUsage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "completed" && status.resultUrl) {
|
||||
const completedPatch: Partial<ChatMessage> = {
|
||||
body: isSuperResolveTask
|
||||
@@ -1982,6 +2016,7 @@ function WorkbenchPage({
|
||||
runKeepalivePoll(keepaliveTask);
|
||||
} else {
|
||||
let streamedText = "";
|
||||
let chatUsage: ChatMessage["taskUsage"] | undefined;
|
||||
setGenerationProgress(36);
|
||||
setGenerationStatus("正在回复");
|
||||
updateAssistantMessage(assistantMessageId, {
|
||||
@@ -2014,6 +2049,9 @@ function WorkbenchPage({
|
||||
});
|
||||
},
|
||||
abortController.signal,
|
||||
(usage) => {
|
||||
chatUsage = usage;
|
||||
},
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -2022,6 +2060,7 @@ function WorkbenchPage({
|
||||
const completedMessages = updateAssistantMessage(assistantMessageId, {
|
||||
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
|
||||
status: "completed",
|
||||
taskUsage: chatUsage,
|
||||
});
|
||||
if (!conversationId) {
|
||||
const conv = await conversationClient.create(
|
||||
@@ -2149,6 +2188,38 @@ function WorkbenchPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReleaseStuckTask = (message: ChatMessage) => {
|
||||
if (message.taskId) {
|
||||
taskAbortControllersRef.current.get(message.taskId)?.abort();
|
||||
taskAbortControllersRef.current.delete(message.taskId);
|
||||
removeKeepaliveTask(message.taskId);
|
||||
}
|
||||
if (message.conversationId) {
|
||||
void patchConversationMessage(message.conversationId, message.id, {
|
||||
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: message.taskRefundStatus || "unknown",
|
||||
taskStatusLabel: "本地占用已释放",
|
||||
});
|
||||
}
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: item.taskRefundStatus || "unknown",
|
||||
taskStatusLabel: "本地占用已释放",
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
syncActiveGenerationUi();
|
||||
};
|
||||
|
||||
const handleSuperResolveVideo = async (message: ChatMessage) => {
|
||||
if (!message.resultUrl || message.resultType !== "video") {
|
||||
setProjectError("仅支持对视频结果进行超分");
|
||||
@@ -3007,7 +3078,7 @@ function WorkbenchPage({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||
<div className="ai-chat-failed-actions">
|
||||
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
|
||||
<ReloadOutlined /> 重试
|
||||
@@ -3015,9 +3086,12 @@ function WorkbenchPage({
|
||||
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
|
||||
<AppstoreOutlined /> 切换模型
|
||||
</button>
|
||||
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
|
||||
<StopOutlined /> 释放卡住任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
|
||||
)}
|
||||
{message.status === "thinking" && message.mode === "chat" && (
|
||||
@@ -3025,6 +3099,11 @@ function WorkbenchPage({
|
||||
<span>{message.taskStatusLabel || generationStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
|
||||
<div className="ai-chat-task-billing-note">
|
||||
{formatTextTokenUsage(message.taskUsage)}
|
||||
</div>
|
||||
)}
|
||||
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
|
||||
<ResultCard
|
||||
message={message}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||
|
||||
export type WorkbenchMode = "chat" | "image" | "video";
|
||||
|
||||
export interface WorkbenchChatAttachment {
|
||||
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
|
||||
body: string;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
status?: "thinking" | "queued" | "completed" | "failed";
|
||||
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||
taskRefundStatus?: TaskRefundStatus;
|
||||
taskUsage?: TextTokenUsage;
|
||||
taskId?: string;
|
||||
conversationId?: number;
|
||||
taskProgress?: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isServerRequestError } from "../../api/serverConnection";
|
||||
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
|
||||
import type { WebGenerationPreviewTask } from "../../types";
|
||||
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type WorkbenchMode = "chat" | "image" | "video";
|
||||
@@ -71,7 +72,10 @@ export interface ChatMessage {
|
||||
body: string;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
status?: "thinking" | "queued" | "completed" | "failed";
|
||||
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||
taskRefundStatus?: TaskRefundStatus;
|
||||
taskUsage?: TextTokenUsage;
|
||||
taskId?: string;
|
||||
conversationId?: number;
|
||||
taskProgress?: number;
|
||||
@@ -366,11 +370,16 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
|
||||
return (
|
||||
patch.status === "completed" ||
|
||||
patch.status === "failed" ||
|
||||
patch.status === "local_timeout" ||
|
||||
patch.status === "stopping" ||
|
||||
typeof patch.taskId === "string" ||
|
||||
typeof patch.resultUrl === "string" ||
|
||||
typeof patch.resultOssKey === "string" ||
|
||||
typeof patch.resultOriginalUrl === "string" ||
|
||||
typeof patch.resultMimeType === "string"
|
||||
typeof patch.resultMimeType === "string" ||
|
||||
typeof patch.taskRefundStatus === "string" ||
|
||||
typeof patch.taskLifecycleStatus === "string" ||
|
||||
typeof patch.taskUsage === "object"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -401,4 +410,4 @@ export function buildAssistantResult(
|
||||
summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。",
|
||||
specs: [model, ...specs],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user