refactor(core): 任务与模型协议治理 - 拆分服务职责并禁止已发起任务取消

This commit is contained in:
ddCat
2026-08-22 09:52:09 +08:00
parent 7418ddfa9c
commit 633560a5d8
91 changed files with 6380 additions and 4247 deletions

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
import { Button, InputNumber, Segmented, Select } from "antd";
import { ModelPicker } from "@/components/model-picker";
@@ -23,7 +23,6 @@ type CanvasConfigNodePanelProps = {
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number; characterCount: number };
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onGenerate: (nodeId: string) => void;
onStop: (nodeId: string) => void;
onComposerToggle: () => void;
workspaceMode?: CanvasWorkspaceMode;
};
@@ -40,7 +39,7 @@ const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperat
{ label: "版本对比", value: "compare_versions" },
];
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) {
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) {
const globalConfig = useEffectiveConfig();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
@@ -188,17 +187,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
danger={isRunning}
disabled={!isRunning && !canGenerate}
disabled={isRunning || !canGenerate}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => (isRunning ? onStop(node.id) : onGenerate(node.id))}
onClick={() => onGenerate(node.id)}
>
<span className="inline-flex items-center gap-1.5">
{isRunning ? (
<>
<LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" />
<span>停止</span>
<span>生成中</span>
</>
) : (
<>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { AlertCircle, BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Square, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
@@ -34,7 +34,6 @@ type CanvasNodeContentProps = {
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
onRetry?: (node: CanvasNodeData) => void;
onCancelTask?: (node: CanvasNodeData) => void;
onOpenTaskDetails?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
@@ -49,7 +48,7 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) {
|| (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content);
if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onCancelTask={props.onCancelTask} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
@@ -117,7 +116,7 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp
);
}
function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<CanvasNodeContentProps, "node" | "theme" | "onCancelTask" | "onOpenTaskDetails">) {
function LoadingContent({ node, theme, onOpenTaskDetails }: Pick<CanvasNodeContentProps, "node" | "theme" | "onOpenTaskDetails">) {
const taskId = node.metadata?.taskId;
const displayTask = {
provider: node.metadata?.taskProvider,
@@ -152,9 +151,6 @@ function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<C
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: theme.toolbar.itemHover, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpenTaskDetails?.(node); }}><FileText className="size-3" />详情</button>
{!submissionUncertain ? (
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
) : null}
</div>
</div>
) : null}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video } from "lucide-react";
import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
@@ -30,7 +30,6 @@ type CanvasNodePromptPanelProps = {
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
workspaceMode?: CanvasWorkspaceMode;
@@ -47,7 +46,7 @@ const PROMPT_EDITOR_VERTICAL_PADDING = 12;
const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20;
const PROMPT_EDITOR_MAX_LINES = 8;
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const themeName = useThemeStore((state) => state.theme);
const theme = canvasThemes[themeName];
@@ -204,13 +203,12 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const renderSubmitButton = (expanded: boolean) => {
const showCost = creditsEnabled && credits !== null;
const formattedCredits = credits?.toLocaleString();
const actionLabel = isRunning ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
return (
<Button
type="text"
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
danger={isRunning}
disabled={isSubmitDisabled}
disabled={isRunning || isSubmitDisabled}
style={
{
color: isSubmitDisabled ? theme.node.faint : theme.node.text,
@@ -218,7 +216,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
"--canvas-composer-submit-action-fg": isSubmitDisabled ? theme.node.faint : theme.canvas.background,
} as CSSProperties
}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
onClick={() => (expanded ? submitExpandedPrompt() : submit())}
aria-label={actionLabel}
title={actionLabel}
>
@@ -229,7 +227,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</span>
) : null}
<span className="canvas-node-composer-submit-action" aria-hidden>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
{isRunning ? <span className="size-2.5 animate-pulse" aria-hidden="true" /> : <ArrowUp className="size-3" />}
</span>
</Button>
);

View File

@@ -49,7 +49,6 @@ type CanvasNodeProps = {
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;
@@ -93,7 +92,6 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch,
onSetBatchPrimary,
onRetry,
onCancelTask,
onOpenTaskDetails,
onOpenVersions,
onViewImage,
@@ -360,7 +358,6 @@ export const CanvasNode = React.memo(function CanvasNode({
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
onRetry={onRetry}
onCancelTask={onCancelTask}
onOpenTaskDetails={onOpenTaskDetails}
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
@@ -515,7 +512,6 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.onToggleBatch === next.onToggleBatch &&
previous.onSetBatchPrimary === next.onSetBatchPrimary &&
previous.onRetry === next.onRetry &&
previous.onCancelTask === next.onCancelTask &&
previous.onOpenTaskDetails === next.onOpenTaskDetails &&
previous.onOpenVersions === next.onOpenVersions &&
previous.onViewImage === next.onViewImage &&

View File

@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, use
import { Button, Checkbox, Dropdown, Input, InputNumber, Modal, Segmented, Select, Table, Tooltip } from "antd";
import type { MenuProps } from "antd";
import type { ColumnsType } from "antd/es/table";
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video, X } from "lucide-react";
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video } from "lucide-react";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { ModelPicker } from "@/components/model-picker";
@@ -74,7 +74,6 @@ export function CanvasScriptNodeContent({
onRetryBatch,
onRetryBatchItem,
onStopBatch,
onCancelBatchItem,
onAddRow,
onRemoveRow,
onUpdateRow,
@@ -104,7 +103,6 @@ export function CanvasScriptNodeContent({
onRetryBatch: (batchId: string) => void;
onRetryBatchItem: (batchId: string, itemId: string) => void;
onStopBatch: (batchId: string) => void;
onCancelBatchItem: (batchId: string, itemId: string) => void;
onAddRow: () => void;
onRemoveRow: (rowId: string) => void;
onUpdateRow: (rowId: string, patch: Partial<StoryboardRow>) => void;
@@ -270,7 +268,7 @@ export function CanvasScriptNodeContent({
</div>
{batch ? (
<Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>
<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />
<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} />
</Modal>
) : null}
<StoryboardMiniPipeline pipeline={pipeline} theme={theme} rows={rows} />
@@ -525,7 +523,7 @@ function StoryboardMiniPipeline({ pipeline, theme, rows }: { pipeline: CanvasSto
);
}
function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { batch: CanvasGenerationBatch; rows: StoryboardRow[]; onRetryItem: (itemId: string) => void; onCancelItem: (itemId: string) => void }) {
function GenerationBatchDetails({ batch, rows, onRetryItem }: { batch: CanvasGenerationBatch; rows: StoryboardRow[]; onRetryItem: (itemId: string) => void }) {
const shotByRowId = new Map(rows.map((row) => [row.id, row.shotNumber]));
return (
<div className="w-80" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
@@ -535,7 +533,6 @@ function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { ba
</div>
<div className="thin-scrollbar max-h-72 overflow-y-auto">
{batch.items.map((item) => {
const cancellable = Boolean(item.taskId && (item.status === "queued" || item.status === "running"));
const requiresPromptChange = isContentModerationError(item.errorDetails);
return (
<div key={item.id} className="flex min-h-9 items-center gap-2 border-t border-foreground/10 py-1.5 first:border-t-0">
@@ -556,18 +553,6 @@ function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { ba
</button>
</Tooltip>
) : null}
{cancellable ? (
<Tooltip title="取消这个后台任务">
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-red-500/10 focus-visible:ring-2"
onClick={() => onCancelItem(item.id)}
aria-label={`取消镜头 ${shotByRowId.get(item.rowId) || ""} 任务`}
>
<X className="size-3.5" />
</button>
</Tooltip>
) : null}
</div>
);
})}