refactor(core): 任务与模型协议治理 - 拆分服务职责并禁止已发起任务取消
This commit is contained in:
@@ -64,7 +64,6 @@ type CanvasProjectWorldLayersProps = {
|
||||
onToggleBatch: (nodeId: string) => void;
|
||||
onSetBatchPrimary: (node: CanvasNodeData) => void;
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onCancelTask: (node: CanvasNodeData) => void;
|
||||
onOpenTaskDetails: (node: CanvasNodeData) => void;
|
||||
onOpenVersions: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
@@ -162,7 +161,6 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
onToggleBatch={props.onToggleBatch}
|
||||
onSetBatchPrimary={props.onSetBatchPrimary}
|
||||
onRetry={props.onRetry}
|
||||
onCancelTask={props.onCancelTask}
|
||||
onOpenTaskDetails={props.onOpenTaskDetails}
|
||||
onOpenVersions={props.onOpenVersions}
|
||||
onViewImage={props.onViewImage}
|
||||
|
||||
@@ -413,8 +413,6 @@ function InfiniteCanvasPage() {
|
||||
const {
|
||||
applyGenerationTaskResult,
|
||||
bindGenerationTask,
|
||||
cancelNodeTask,
|
||||
confirmStopGeneration,
|
||||
finishGenerationRequest,
|
||||
openNodeTaskDetails,
|
||||
runningNodeId,
|
||||
@@ -1417,7 +1415,7 @@ function InfiniteCanvasPage() {
|
||||
generateNodeRef.current = handleGenerateNode;
|
||||
}, [handleGenerateNode]);
|
||||
|
||||
const { cancelSubmittedBatchItem, enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({
|
||||
const { enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({
|
||||
projectId,
|
||||
projectLoaded,
|
||||
nodes,
|
||||
@@ -1532,7 +1530,6 @@ function InfiniteCanvasPage() {
|
||||
onPromptChange={handleNodePromptChange}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onGenerate={handleGenerateNode}
|
||||
onStop={confirmStopGeneration}
|
||||
workspaceMode={workspaceMode}
|
||||
onImageSettingsOpenChange={(open) => {
|
||||
setNodeImageSettingsOpen(open);
|
||||
@@ -1541,7 +1538,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode],
|
||||
[configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode],
|
||||
);
|
||||
|
||||
const renderCanvasNodeContent = useCallback(
|
||||
@@ -1575,7 +1572,6 @@ function InfiniteCanvasPage() {
|
||||
onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)}
|
||||
onRetryBatchItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
|
||||
onStopBatch={(batchId) => stopRemainingBatchItems(contentNode.id, batchId)}
|
||||
onCancelBatchItem={(batchId, itemId) => cancelSubmittedBatchItem(contentNode.id, batchId, itemId)}
|
||||
onAddRow={() => addScriptRow(contentNode.id)}
|
||||
onRemoveRow={(rowId) => removeScriptRow(contentNode.id, rowId)}
|
||||
onUpdateRow={(rowId, patch) => updateScriptRow(contentNode.id, rowId, patch)}
|
||||
@@ -1614,7 +1610,6 @@ function InfiniteCanvasPage() {
|
||||
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
|
||||
onStop={confirmStopGeneration}
|
||||
onGenerate={(nodeId) => {
|
||||
const target = nodesRef.current.find((item) => item.id === nodeId);
|
||||
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
|
||||
@@ -1625,9 +1620,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
[
|
||||
addScriptRow,
|
||||
cancelSubmittedBatchItem,
|
||||
configInputsById,
|
||||
confirmStopGeneration,
|
||||
createAndGenerateScriptVideos,
|
||||
createScriptActionBoards,
|
||||
createScriptImageNodes,
|
||||
@@ -1926,7 +1919,6 @@ function InfiniteCanvasPage() {
|
||||
onToggleBatch={toggleBatchExpanded}
|
||||
onSetBatchPrimary={setBatchPrimary}
|
||||
onRetry={retryCanvasNode}
|
||||
onCancelTask={cancelNodeTask}
|
||||
onOpenTaskDetails={openCanvasNodeTaskDetails}
|
||||
onOpenVersions={openCanvasNodeVersions}
|
||||
onViewImage={viewCanvasNodeImage}
|
||||
|
||||
@@ -253,7 +253,7 @@ export default function SharedCanvasPage() {
|
||||
setContextMenu(null);
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
|
||||
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
|
||||
</InfiniteCanvas>
|
||||
|
||||
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoRegenerate={unauthorized} extractingVideoFrame={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
|
||||
|
||||
@@ -5,8 +5,7 @@ import { nanoid } from "nanoid";
|
||||
import { generationBatchStatus, isGenerationCostUncertainError } from "@/lib/canvas/canvas-generation-batch";
|
||||
import { buildGenerationConfig, createGenerationRetryContext, generationTaskMetadata, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { unchangedModeratedPrompt } from "@/lib/generation-error";
|
||||
import { cancelGenerationTask, listGenerationTasks } from "@/services/api/task-center";
|
||||
import { localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
|
||||
import { listGenerationTasks } from "@/services/api/task-center";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchMode, CanvasNodeData } from "@/types/canvas";
|
||||
@@ -305,7 +304,9 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);
|
||||
if (!batch) return;
|
||||
const nodeById = new Map(nodesRef.current.map((node) => [node.id, node]));
|
||||
const stoppableItems = batch.items.filter((item) => (item.status === "waiting" || item.status === "submitting") && !nodeById.get(item.nodeId)?.metadata?.taskId);
|
||||
// 只允许停止还在本地等待队列中的项目。进入 submitting 后请求可能已经被服务端接收,
|
||||
// 即使前端尚未拿到 taskId 也不能再把它标成取消,避免隐藏已计费任务。
|
||||
const stoppableItems = batch.items.filter((item) => item.status === "waiting" && !nodeById.get(item.nodeId)?.metadata?.taskId);
|
||||
if (!stoppableItems.length) return message.info("没有尚未提交的任务");
|
||||
modal.confirm({
|
||||
title: "停止剩余任务?",
|
||||
@@ -315,9 +316,11 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
const latestNodeById = new Map(nodesRef.current.map((node) => [node.id, node]));
|
||||
const latestStoppableItems = stoppableItems.filter((item) => !latestNodeById.get(item.nodeId)?.metadata?.taskId);
|
||||
const latestBatch = findBatch(nodesRef.current, sourceNodeId, batchId);
|
||||
const latestStoppableItems = latestBatch
|
||||
? latestBatch.items.filter((item) => item.status === "waiting" && stoppableItems.some((candidate) => candidate.id === item.id) && !latestNodeById.get(item.nodeId)?.metadata?.taskId)
|
||||
: [];
|
||||
const stoppableIds = new Set(latestStoppableItems.map((item) => item.id));
|
||||
latestStoppableItems.forEach((item) => controllersRef.current.get(batchItemKey(batchId, item.id))?.abort());
|
||||
updateBatch(sourceNodeId, batchId, (current) => {
|
||||
const items = current.items.map((item) => (stoppableIds.has(item.id) ? { ...item, status: "cancelled" as const, errorDetails: undefined } : item));
|
||||
const nextBatch = { ...current, items, updatedAt: new Date().toISOString() };
|
||||
@@ -329,65 +332,6 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
[message, modal, nodesRef, updateBatch],
|
||||
);
|
||||
|
||||
const cancelSubmittedBatchItem = useCallback(
|
||||
(sourceNodeId: string, batchId: string, itemId: string) => {
|
||||
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);
|
||||
const item = batch?.items.find((candidate) => candidate.id === itemId);
|
||||
const node = item ? nodesRef.current.find((candidate) => candidate.id === item.nodeId) : undefined;
|
||||
const taskId = item?.taskId || node?.metadata?.taskId;
|
||||
if (!item || !taskId) return;
|
||||
const cancellationCopy = localDreaminaCancellationCopy({
|
||||
id: taskId,
|
||||
status: node?.metadata?.taskStatus === "queued" ? "queued" : "running",
|
||||
stage: node?.metadata?.taskStage,
|
||||
receiptRecorded: node?.metadata?.taskReceiptRecorded,
|
||||
});
|
||||
modal.confirm({
|
||||
title: "取消这个后台任务?",
|
||||
content: cancellationCopy?.confirmation || "任务会在后端停止,已经生成完成的其他镜头不会受影响。",
|
||||
okText: cancellationCopy?.action || "取消任务",
|
||||
cancelText: "继续生成",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
const task = await cancelGenerationTask(taskId);
|
||||
const outcome = localDreaminaDetachOutcome(task);
|
||||
const stopMessage = outcome?.message ?? localDreaminaCancellationMessage(task);
|
||||
if (outcome?.kind !== "background") controllersRef.current.get(batchItemKey(batchId, item.id))?.abort();
|
||||
setNodes((current) =>
|
||||
current.map((currentNode) => {
|
||||
if (currentNode.id === item.nodeId)
|
||||
return {
|
||||
...currentNode,
|
||||
metadata: {
|
||||
...currentNode.metadata,
|
||||
...generationTaskMetadata(task),
|
||||
status: outcome?.canvasNodeStatus ?? "error",
|
||||
errorDetails: outcome?.kind === "background" ? undefined : stopMessage,
|
||||
},
|
||||
};
|
||||
if (currentNode.id !== sourceNodeId) return currentNode;
|
||||
const batches = (currentNode.metadata?.generationBatches || []).map((currentBatch) =>
|
||||
currentBatch.id === batchId
|
||||
? withUpdatedItem(currentBatch, item.id, {
|
||||
status: outcome?.batchItemStatus ?? "cancelled",
|
||||
taskId,
|
||||
errorDetails: outcome?.kind === "background" ? undefined : stopMessage,
|
||||
})
|
||||
: currentBatch,
|
||||
);
|
||||
return { ...currentNode, metadata: { ...currentNode.metadata, generationBatches: batches } };
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "任务取消失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[message, modal, nodesRef, setNodes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded) return;
|
||||
reconcileBatches();
|
||||
@@ -404,7 +348,6 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
}, [projectLoaded, reconcileBatches, scheduleWaitingItems]);
|
||||
|
||||
return {
|
||||
cancelSubmittedBatchItem,
|
||||
enqueueGenerationBatch,
|
||||
retryFailedBatchItems,
|
||||
stopRemainingBatchItems,
|
||||
|
||||
@@ -5,13 +5,12 @@ import { App } from "antd";
|
||||
import { applyGenerationTaskResultToNodes, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
|
||||
import { consumeGenerationTaskNode, ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { cancelGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
|
||||
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
|
||||
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
|
||||
import { consumeCanvasAgentGenerationContinuation } from "./use-canvas-agent-operations";
|
||||
|
||||
@@ -31,7 +30,6 @@ type UseCanvasGenerationOptions = {
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
};
|
||||
|
||||
const NODE_STATUS_IDLE = "idle" as const;
|
||||
const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
@@ -194,7 +192,7 @@ export async function recoverCanvasGenerationTaskNode(input: {
|
||||
}
|
||||
|
||||
export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, nodes, nodesRef, setNodes }: UseCanvasGenerationOptions) {
|
||||
const { message, modal } = App.useApp();
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const generationRequestsRef = useRef(new Map<string, CanvasGenerationRequest>());
|
||||
const recoveringTaskIdsRef = useRef(new Set<string>());
|
||||
@@ -219,84 +217,6 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
if (request?.controller === controller) generationRequestsRef.current.delete(targetNodeId);
|
||||
}, []);
|
||||
|
||||
const stopGenerationByRunningId = useCallback(
|
||||
(runningId: string) => {
|
||||
const affectedNodeIds = new Set<string>();
|
||||
generationRequestsRef.current.forEach((request) => {
|
||||
if (request.runningNodeId !== runningId) return;
|
||||
request.controller.abort();
|
||||
generationRequestsRef.current.delete(request.targetNodeId);
|
||||
affectedNodeIds.add(request.targetNodeId);
|
||||
affectedNodeIds.add(request.originNodeId);
|
||||
});
|
||||
setRunningNodeId((current) => (current === runningId ? null : current));
|
||||
if (!affectedNodeIds.size) return;
|
||||
setNodes((current) => current.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
|
||||
},
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
const confirmStopGeneration = useCallback(
|
||||
(nodeId: string) => {
|
||||
modal.confirm({
|
||||
title: "停止生成?",
|
||||
content: "当前生成请求会被中断,已经生成完成的内容会保留。",
|
||||
okText: "停止",
|
||||
cancelText: "继续生成",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => stopGenerationByRunningId(nodeId),
|
||||
});
|
||||
},
|
||||
[modal, stopGenerationByRunningId],
|
||||
);
|
||||
|
||||
const cancelNodeTask = useCallback(
|
||||
(node: CanvasNodeData) => {
|
||||
const taskId = node.metadata?.taskId;
|
||||
if (!taskId) {
|
||||
confirmStopGeneration(node.id);
|
||||
return;
|
||||
}
|
||||
const cancellationCopy = localDreaminaCancellationCopy({
|
||||
id: taskId,
|
||||
status: node.metadata?.taskStatus === "queued" ? "queued" : "running",
|
||||
stage: node.metadata?.taskStage,
|
||||
receiptRecorded: node.metadata?.taskReceiptRecorded,
|
||||
});
|
||||
modal.confirm({
|
||||
title: cancellationCopy?.kind === "background" ? "转入后台?" : "取消任务?",
|
||||
content: cancellationCopy?.confirmation || "任务会在后端停止,已生成完成的内容仍会保留。",
|
||||
okText: cancellationCopy?.action || "取消任务",
|
||||
cancelText: "继续生成",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const task = await cancelGenerationTask(taskId);
|
||||
const outcome = localDreaminaDetachOutcome(task);
|
||||
const stopMessage = outcome?.message ?? localDreaminaCancellationMessage(task);
|
||||
if (outcome?.kind !== "background") generationRequestsRef.current.get(node.id)?.controller.abort();
|
||||
setNodes((current) =>
|
||||
current.map((item) =>
|
||||
item.id === node.id
|
||||
? {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
...generationTaskMetadata(task),
|
||||
status: outcome?.canvasNodeStatus ?? NODE_STATUS_ERROR,
|
||||
errorDetails: outcome?.kind === "background" ? undefined : stopMessage,
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
if (outcome?.kind === "background") message.info(stopMessage);
|
||||
else message.success(stopMessage);
|
||||
},
|
||||
});
|
||||
},
|
||||
[confirmStopGeneration, message, modal, setNodes],
|
||||
);
|
||||
|
||||
const openNodeTaskDetails = useCallback(
|
||||
async (node: CanvasNodeData) => {
|
||||
const taskId = node.metadata?.taskId;
|
||||
@@ -584,8 +504,6 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
return {
|
||||
applyGenerationTaskResult,
|
||||
bindGenerationTask,
|
||||
cancelNodeTask,
|
||||
confirmStopGeneration,
|
||||
finishGenerationRequest,
|
||||
openNodeTaskDetails,
|
||||
runningNodeId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Trash2, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
@@ -23,9 +23,9 @@ import { resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirem
|
||||
import { isGenerationTaskCancelled, logicalModelIDForConfig, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||||
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||||
import { createTextReplayPublisher } from "@/lib/creation-text-replay";
|
||||
import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
|
||||
import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
|
||||
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
|
||||
@@ -763,23 +763,6 @@ export default function CreatePage() {
|
||||
restoreMessageDraft(previous);
|
||||
};
|
||||
|
||||
const cancelPendingMessage = async (item: CreationMessage) => {
|
||||
if (!item.taskIds?.length || !activeConversation) return;
|
||||
const settled = await Promise.allSettled(item.taskIds.map((taskId) => cancelGenerationTask(taskId)));
|
||||
const fulfilled = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
const background = fulfilled.map((task) => ({ task, outcome: localDreaminaDetachOutcome(task) })).find((entry) => entry.outcome?.kind === "background");
|
||||
if (background?.outcome?.kind === "background") {
|
||||
await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: background.outcome!.creationStatus, generationStage: background.task.stage, content: background.outcome!.message }));
|
||||
toast.info(background.outcome.message);
|
||||
return;
|
||||
}
|
||||
const cancelled = fulfilled.filter((task) => task.status === "cancelled");
|
||||
if (cancelled.length === item.taskIds.length) {
|
||||
const outcome = localDreaminaDetachOutcome(cancelled[0]!);
|
||||
await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: outcome?.creationStatus ?? "cancelled", generationStage: "cancelled", content: outcome?.message ?? cancelled[0]!.error ?? "任务已取消" }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!hydrated || !activeConversation) return <div className="grid h-full place-items-center"><Spin /></div>;
|
||||
|
||||
const handleThreadScroll = () => {
|
||||
@@ -830,7 +813,6 @@ export default function CreatePage() {
|
||||
composerFocusRef,
|
||||
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
|
||||
onSubmit: () => void submit(),
|
||||
onStop: () => abortRef.current?.abort(),
|
||||
};
|
||||
|
||||
const visibleShot = shots[visibleShotIndex];
|
||||
@@ -866,7 +848,6 @@ export default function CreatePage() {
|
||||
modelName={item.model ? modelDisplayName(config, item.model) : ""}
|
||||
onRetryFailure={() => retryFailedMessage(item, index)}
|
||||
onCreateVariant={() => createVariant(item, index)}
|
||||
onCancel={() => void cancelPendingMessage(item)}
|
||||
/>)}</div></section>
|
||||
</main>
|
||||
<section className="creation-thread-composer"><CreationComposer {...composerProps} variant="thread" /></section>
|
||||
@@ -892,7 +873,6 @@ export default function CreatePage() {
|
||||
busy={busy}
|
||||
onRetryFailure={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) retryFailedMessage(visibleShot.result, visibleShotResultIndex); }}
|
||||
onCreateVariant={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) createVariant(visibleShot.result, visibleShotResultIndex); }}
|
||||
onCancel={() => { if (visibleShot.result) void cancelPendingMessage(visibleShot.result); }}
|
||||
/> : null}
|
||||
</div>
|
||||
</main>
|
||||
@@ -979,14 +959,14 @@ function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversatio
|
||||
</header>;
|
||||
}
|
||||
|
||||
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||||
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
if (item.role === "user") return <CreationUserMessage item={item} />;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||||
const heading = <><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>;
|
||||
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div></> : <GenerationToolCard status={toolStatus} isBulk={(item.resultUrls?.length || Number(item.settings?.count) || 1) > 1} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} /></GenerationToolCard>}
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div></> : <GenerationToolCard status={toolStatus} isBulk={(item.resultUrls?.length || Number(item.settings?.count) || 1) > 1} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} /></GenerationToolCard>}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -1010,14 +990,14 @@ function CreationUserMessage({ item }: { item: CreationMessage }) {
|
||||
</article>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const resultUrls = item.resultUrls || [];
|
||||
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
|
||||
const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas";
|
||||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} onCancel={item.taskIds?.length ? onCancel : undefined} />;
|
||||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
const isVideo = item.mode === "video";
|
||||
@@ -1028,8 +1008,8 @@ function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) {
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><Sparkles /></span><span className="sr-only">影策正在生成{mode === "video" ? "视频" : "图像"}</span>{onCancel ? <button type="button" onClick={onCancel}>取消任务</button> : null}</div>;
|
||||
function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) {
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><Sparkles /></span><span className="sr-only">影策正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
|
||||
}
|
||||
|
||||
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||||
@@ -1097,7 +1077,6 @@ type ComposerProps = {
|
||||
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
|
||||
placeholderOverride?: string;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
function CreationComposer(props: ComposerProps) {
|
||||
@@ -1115,7 +1094,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
});
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
const actionLabel = props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
const placeholder = props.mode === "text"
|
||||
? "描述你的故事、角色或想继续讨论的创意"
|
||||
: props.mode === "image"
|
||||
@@ -1156,19 +1135,18 @@ function CreationComposer(props: ComposerProps) {
|
||||
<Button
|
||||
type="text"
|
||||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||||
danger={props.busy}
|
||||
disabled={!props.busy && !canSubmit}
|
||||
disabled={props.busy || !canSubmit}
|
||||
style={{
|
||||
color: !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action": !props.busy && !canSubmit ? "var(--creation-surface-hover)" : props.busy ? "var(--status-error)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action": !props.busy && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action-fg": !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
|
||||
} as CSSProperties}
|
||||
onClick={props.busy ? props.onStop : props.onSubmit}
|
||||
onClick={props.busy ? undefined : props.onSubmit}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
>
|
||||
{showCost ? <span className="canvas-node-composer-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>{props.busy ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}</span>
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>{props.busy ? <LoaderCircle className="size-3 animate-spin" /> : <ArrowUp className="size-3" />}</span>
|
||||
</Button>
|
||||
</footer>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
@@ -1353,7 +1331,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
|
||||
</header>;
|
||||
}
|
||||
|
||||
function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant, onCancel }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||||
function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
const user = shot.user;
|
||||
const result = shot.result;
|
||||
const status = result?.status || "queued";
|
||||
@@ -1399,7 +1377,7 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
|
||||
<div className="storyboard-workbench-turn-copy">
|
||||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role is-ai"><Sparkles />影策 AI</span>{modelName ? <span className="storyboard-workbench-turn-model">{modelName}</span> : null}{result?.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={result.createdAt}>{formatMessageTime(result.createdAt)}</time> : null}</div>
|
||||
<div className="storyboard-workbench-turn-bubble">
|
||||
<StoryboardShotResult result={result} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} canvasPath={canvasPath} canvasHandoffAvailable={Boolean(canvasHandoffPath)} />
|
||||
<StoryboardShotResult result={result} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} canvasPath={canvasPath} canvasHandoffAvailable={Boolean(canvasHandoffPath)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1441,7 +1419,7 @@ function StoryboardBriefAttachments({ attachments }: { attachments: CreationAtta
|
||||
})}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></>;
|
||||
}
|
||||
|
||||
function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, onCancel, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) {
|
||||
function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
|
||||
@@ -1451,13 +1429,9 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, onCance
|
||||
const resultUrls = result.resultUrls || [];
|
||||
if (status === "pending" || status === "queued") {
|
||||
const thinking = thinkingFor(mode);
|
||||
const cancellationCopy = result.taskIds?.map((taskId) => localDreaminaCancellationCopy({ id: taskId, status: "running", stage: result.generationStage, receiptRecorded: result.generationStage === "submitted" || result.generationStage === "generating" })).find(Boolean);
|
||||
const hasLocalDreaminaTask = Boolean(result.taskIds?.some(isLocalDreaminaTaskId));
|
||||
const showCancellationAction = !hasLocalDreaminaTask || Boolean(cancellationCopy);
|
||||
return <div className="storyboard-workbench-pending"><div className="storyboard-workbench-thinking">
|
||||
<span className="storyboard-workbench-thinking-copy"><strong>{thinking.title}</strong><span>{thinking.hint}</span></span>
|
||||
<span className="storyboard-workbench-pipeline" aria-hidden="true">{thinking.steps.map((step, index) => <em key={step} style={{ "--step": index } as CSSProperties}><i>{String(index + 1).padStart(2, "0")}</i>{step}</em>)}</span>
|
||||
{result.taskIds?.length && showCancellationAction ? <button type="button" onClick={onCancel}>{cancellationCopy?.action || "取消任务"}</button> : null}
|
||||
</div></div>;
|
||||
}
|
||||
if (status === "error") return <div className="storyboard-workbench-error"><span>{generationErrorMessage(result.error || "")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Form, Input, Modal, Select } from "antd";
|
||||
import { ArrowRight, BookOpenText, FileText, FolderKanban, Images, LayoutGrid, Palette, Plus, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
@@ -44,7 +44,6 @@ export default function ProjectsPage() {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generationStatus, setGenerationStatus] = useState("");
|
||||
const [generationPreview, setGenerationPreview] = useState("");
|
||||
const generationAbortRef = useRef<AbortController | null>(null);
|
||||
const createOpen = searchParams.get("create") === "1";
|
||||
const setCreateOpen = (open: boolean) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
@@ -86,8 +85,6 @@ export default function ProjectsPage() {
|
||||
setGenerating(true);
|
||||
setGenerationStatus("正在创建项目…");
|
||||
setGenerationPreview("");
|
||||
const controller = new AbortController();
|
||||
generationAbortRef.current = controller;
|
||||
try {
|
||||
const project = await createUniqueProjectName(story, selectedStyle);
|
||||
setGenerationStatus("AI 正在生成故事大纲与章节…");
|
||||
@@ -100,7 +97,6 @@ export default function ProjectsPage() {
|
||||
(text) => {
|
||||
setGenerationPreview(text);
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const parsed = parseGeneratedStory(answer);
|
||||
if (!parsed.chapters.length) throw new Error("AI 没有返回有效的章节内容,请重试");
|
||||
@@ -109,13 +105,8 @@ export default function ProjectsPage() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
navigate(`/projects/${project.project.id}/overview`);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
message.info("已停止生成");
|
||||
} else {
|
||||
message.error(error instanceof Error ? error.message : "AI 生成失败,请重试");
|
||||
}
|
||||
message.error(error instanceof Error ? error.message : "AI 生成失败,请重试");
|
||||
} finally {
|
||||
generationAbortRef.current = null;
|
||||
setGenerating(false);
|
||||
setGenerationStatus("");
|
||||
setGenerationPreview("");
|
||||
@@ -142,7 +133,7 @@ export default function ProjectsPage() {
|
||||
const confirmDeleteProject = (projectId: string, name: string) => {
|
||||
modal.confirm({
|
||||
title: "删除项目",
|
||||
content: `确定删除「${name}」吗?项目内的章节、画布与资产将一并删除,此操作不可撤销。`,
|
||||
content: `确定删除「${name}」吗?项目章节、画布关联和素材归属将一并移除;独立画布与素材库原始素材会保留。此操作不可撤销。`,
|
||||
okText: "删除",
|
||||
okButtonProps: { danger: true, loading: deleteMutation.isPending },
|
||||
cancelText: "取消",
|
||||
@@ -294,7 +285,6 @@ export default function ProjectsPage() {
|
||||
<div className="app-story-generating-preview-head"><span>实时草稿</span><span className="app-story-generating-live" /><em>{generationPreview ? "正在输出" : "等待模型输出"}</em></div>
|
||||
<pre>{generationPreview}</pre>
|
||||
</div>
|
||||
<div className="app-story-generating-actions"><Button onClick={() => generationAbortRef.current?.abort()}>停止</Button></div>
|
||||
</div>
|
||||
</Modal>
|
||||
</WorkspacePage>
|
||||
|
||||
@@ -10,8 +10,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
|
||||
import { formatTaskKind, isGenerationTaskSubmissionUncertain, operationOptions, statusLabel } from "@/lib/generation-task-display";
|
||||
import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task";
|
||||
|
||||
import { cancelGenerationTask, createAgentSession, createGenerationTask, deleteGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, refreshGenerationTaskStatus, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { localDreaminaCancellationCopy, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
|
||||
import { createAgentSession, createGenerationTask, deleteGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, refreshGenerationTaskStatus, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
@@ -20,7 +19,7 @@ import { listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
import { TaskGridCard } from "./task-grid-card";
|
||||
import { TaskGroupHeader, type TaskGroup } from "./task-group-header";
|
||||
import { TaskListRow } from "./task-list-row";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskCancellable, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
|
||||
import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter";
|
||||
|
||||
type TaskKindFilter = "all" | "text" | "image" | "video";
|
||||
@@ -151,7 +150,7 @@ export default function TasksPage() {
|
||||
setRetryingGroup(key);
|
||||
try {
|
||||
for (const task of retryable) {
|
||||
await runAction(task.id, "retry");
|
||||
await runAction(task.id);
|
||||
}
|
||||
} finally {
|
||||
setRetryingGroup("");
|
||||
@@ -168,8 +167,7 @@ export default function TasksPage() {
|
||||
creditsEnabled={creditsEnabled}
|
||||
actingId={actingId}
|
||||
onOpen={() => void openTaskDetail(task)}
|
||||
onRetry={() => void runAction(task.id, "retry")}
|
||||
onCancel={() => void runAction(task.id, "cancel")}
|
||||
onRetry={() => void runAction(task.id)}
|
||||
onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })}
|
||||
/>
|
||||
);
|
||||
@@ -180,8 +178,7 @@ export default function TasksPage() {
|
||||
task={task}
|
||||
actingId={actingId}
|
||||
onOpen={() => void openTaskDetail(task)}
|
||||
onRetry={() => void runAction(task.id, "retry")}
|
||||
onCancel={() => void runAction(task.id, "cancel")}
|
||||
onRetry={() => void runAction(task.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -290,32 +287,20 @@ export default function TasksPage() {
|
||||
};
|
||||
}, [loadTasks]);
|
||||
|
||||
const runAction = async (id: string, action: "retry" | "cancel") => {
|
||||
const runAction = async (id: string) => {
|
||||
const currentTask = tasksRef.current.find((task) => task.id === id);
|
||||
if (action === "cancel" && currentTask && !isTaskCancellable(currentTask)) {
|
||||
message.warning("任务已开始生成,无法取消");
|
||||
return;
|
||||
}
|
||||
if (action === "retry" && currentTask && isGenerationTaskSubmissionUncertain(currentTask)) {
|
||||
if (currentTask && isGenerationTaskSubmissionUncertain(currentTask)) {
|
||||
message.warning("提交结果尚未确认,不能自动重试;请先核对官方状态,避免重复生成。");
|
||||
return;
|
||||
}
|
||||
setActingId(id);
|
||||
try {
|
||||
const next = action === "retry" ? await retryGenerationTask(id) : await cancelGenerationTask(id);
|
||||
const next = await retryGenerationTask(id);
|
||||
setTasks((items) => items.map((item) => (item.id === id ? next : item)));
|
||||
setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current));
|
||||
if (action === "retry") {
|
||||
setStatusFilter("active");
|
||||
setPage(1);
|
||||
}
|
||||
const localOutcome = localDreaminaDetachOutcome(next);
|
||||
if (action === "retry") message.success("任务已重新入队");
|
||||
else if (localOutcome?.kind === "background") message.info(localOutcome.message);
|
||||
else if (next.providerCancelStatus === "requested") message.info("已请求上游取消,正在确认费用状态");
|
||||
else if (next.providerCancelStatus === "confirmed") message.success("上游已确认取消,积分已退回");
|
||||
else if (next.providerCancelStatus === "uncertain") message.warning("任务已取消,上游费用待核对");
|
||||
else message.success("任务已取消,积分已退回");
|
||||
setStatusFilter("active");
|
||||
setPage(1);
|
||||
message.success("任务已重新入队");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "操作失败");
|
||||
} finally {
|
||||
@@ -324,14 +309,13 @@ export default function TasksPage() {
|
||||
};
|
||||
|
||||
const deleteLocalTask = (task: GenerationTask) => {
|
||||
if (task.status === "queued" || task.status === "running") {
|
||||
message.warning("任务正在执行,不能删除本机记录;请等待任务完成");
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: "删除本机任务记录?",
|
||||
content:
|
||||
localDreaminaCancellationCopy(task)?.kind === "background"
|
||||
? "任务已由官方接受;删除后仍会在后台同步官方状态。"
|
||||
: task.status === "queued"
|
||||
? "任务尚未提交官方;删除会取消本机排队且不会触发官方请求。"
|
||||
: "这只会删除本机任务记录,不会删除已生成的素材。",
|
||||
content: "这只会删除本机任务记录,不会删除已生成的素材。",
|
||||
okText: "删除本机记录",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "保留",
|
||||
@@ -555,11 +539,6 @@ export default function TasksPage() {
|
||||
更新官方状态
|
||||
</Button>
|
||||
) : null}
|
||||
{detailTask.provider === "dreamina-cli" && isTaskCancellable(detailTask) ? (
|
||||
<Button danger loading={actingId === detailTask.id} onClick={() => void runAction(detailTask.id, "cancel")}>
|
||||
{localDreaminaCancellationCopy(detailTask)?.action || "取消任务"}
|
||||
</Button>
|
||||
) : null}
|
||||
{detailTask.provider === "dreamina-cli" ? (
|
||||
<Button danger aria-label="删除本机记录" icon={<Trash2 className="size-4" />} loading={actingId === detailTask.id} onClick={() => deleteLocalTask(detailTask)}>
|
||||
删除本机记录
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { Eye, FileText, Image as ImageIcon, RotateCcw, Video, X } from "lucide-react";
|
||||
import { Eye, FileText, Image as ImageIcon, RotateCcw, Video } from "lucide-react";
|
||||
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
|
||||
import { statusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { isTaskCancellable, isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
|
||||
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
|
||||
|
||||
export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void; onCancel: () => void }) {
|
||||
export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void }) {
|
||||
const isActive = task.status === "queued" || task.status === "running";
|
||||
const isCancellable = isTaskCancellable(task);
|
||||
const isFailed = isTaskFailed(task);
|
||||
const isVideo = task.previewKind === "video";
|
||||
const fallbackVideo = task.type.includes("video");
|
||||
@@ -39,11 +38,6 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { ta
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isCancellable ? (
|
||||
<Tooltip title="取消任务">
|
||||
<Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-grid-body">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video, X } from "lucide-react";
|
||||
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
@@ -7,7 +7,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
|
||||
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskCancellable, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
|
||||
|
||||
export function TaskListRow({
|
||||
task,
|
||||
@@ -18,7 +18,6 @@ export function TaskListRow({
|
||||
actingId,
|
||||
onOpen,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onPreview,
|
||||
}: {
|
||||
task: GenerationTask;
|
||||
@@ -29,12 +28,10 @@ export function TaskListRow({
|
||||
actingId: string;
|
||||
onOpen: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
onPreview: () => void;
|
||||
}) {
|
||||
const context = getTaskCanvasContext(task, canvasById, projectNameById);
|
||||
const isActive = task.status === "queued" || task.status === "running";
|
||||
const isCancellable = isTaskCancellable(task);
|
||||
const isFailed = isTaskFailed(task);
|
||||
return (
|
||||
<article className={`task-record-row group${isFailed ? " is-attention" : ""}`}>
|
||||
@@ -95,11 +92,6 @@ export function TaskListRow({
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isCancellable ? (
|
||||
<Tooltip title="取消任务">
|
||||
<Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -17,10 +17,6 @@ export function isTaskFailed(task: GenerationTask) {
|
||||
return task.status === "failed" || task.status === "cancelled";
|
||||
}
|
||||
|
||||
export function isTaskCancellable(task: GenerationTask) {
|
||||
return task.status === "queued";
|
||||
}
|
||||
|
||||
export function taskAttentionReason(task: GenerationTask) {
|
||||
if (task.status === "cancelled") return providerCancelStatusLabel(task);
|
||||
if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务";
|
||||
|
||||
Reference in New Issue
Block a user