Files
Genarrative/src/components/image-editor/ImageCanvasTaskSidebarView.tsx
T
kdletters 754d364227 修复画布生成刷新后完成同步
任务列表识别当前画布项目的未确认完成任务并发出完成通知。

编辑器收到任务完成通知后重新加载项目快照并刷新素材库。

补充刷新后任务已完成和轮询完成的回归测试。
2026-07-07 19:45:16 +08:00

754 lines
23 KiB
TypeScript

import {
CheckCircle2,
Clock3,
ListChecks,
Loader2,
XCircle,
} from 'lucide-react';
import {
type WheelEvent as ReactWheelEvent,
type UIEvent as ReactUIEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTaskStatus } from './ImageCanvasEditorTypes';
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
const COMPLETED_TASK_LIST_LIMIT = 20;
const COMPLETED_TASK_LIST_MAX_LIMIT = 100;
const ACTIVE_TASK_LIST_LIMIT = 100;
const ACTIVE_TASK_POLL_INTERVAL_MS = 4000;
type TaskSidebarTab = 'active' | 'completed';
type TaskSidebarItem = {
id: string;
title: string;
detail: string;
progressDetail?: string;
status: CanvasTaskStatus;
startedAt?: number;
finishedAt?: number;
elapsedMs?: number;
errorMessage?: string;
onClick?: () => void;
sortTime: number;
};
type ImageCanvasTaskSidebarViewProps = {
refreshKey?: number;
open: boolean;
onToggleOpen: () => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
};
function parseTaskTimeMs(value?: string | null) {
if (!value) {
return undefined;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function formatElapsedMs(elapsedMs?: number) {
if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs)) {
return null;
}
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds >= 60) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}${seconds}秒` : `${minutes}分`;
}
return `${Math.max(1, totalSeconds)}秒`;
}
function formatTaskTimeMs(timestampMs?: number) {
if (typeof timestampMs !== 'number' || !Number.isFinite(timestampMs)) {
return null;
}
const date = new Date(timestampMs);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}`;
}
function normalizeTaskPrompt(value?: string | null) {
const prompt = value?.trim();
return prompt || null;
}
function externalTaskDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
) {
if (status === 'failed') {
return task.error?.trim() || task.phaseDetail || task.updatedAt;
}
if (status === 'done') {
return (
normalizeTaskPrompt(task.requestPrompt) ||
task.requestLabel.trim() ||
task.jobKind
);
}
const phaseLabel = task.phaseLabel.trim();
const requestLabel = task.requestLabel.trim();
const prompt = normalizeTaskPrompt(task.requestPrompt);
return (
(prompt && prompt !== requestLabel ? prompt : null) ||
(phaseLabel && phaseLabel !== requestLabel ? phaseLabel : null) ||
''
);
}
function externalTaskProgressDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
) {
if (!isActiveTaskStatus(status)) {
return null;
}
const values = [task.phaseDetail.trim()].filter((value): value is string =>
Boolean(value),
);
return values.length > 0 ? values.join(' · ') : null;
}
function stopTaskSidebarWheel(event: ReactWheelEvent<HTMLElement>) {
event.stopPropagation();
}
function canvasTaskStatusFromExternalTask(
status: ExternalGenerationTaskRecord['status'],
): CanvasTaskStatus {
if (status === 'running') {
return 'running';
}
if (status === 'queued') {
return 'pending';
}
if (status === 'failed') {
return 'failed';
}
return 'done';
}
function statusLabel(status: CanvasTaskStatus) {
if (status === 'pending') {
return '排队中';
}
if (status === 'running') {
return '进行中';
}
if (status === 'failed') {
return '失败';
}
return '完成';
}
function statusIcon(status: CanvasTaskStatus) {
if (status === 'pending') {
return <Clock3 className="h-3.5 w-3.5" aria-hidden="true" />;
}
if (status === 'running') {
return <Loader2 className="h-3.5 w-3.5" aria-hidden="true" />;
}
if (status === 'failed') {
return <XCircle className="h-3.5 w-3.5" aria-hidden="true" />;
}
return <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />;
}
function isActiveTaskStatus(status: CanvasTaskStatus) {
return status === 'running' || status === 'pending';
}
function isTimedTaskStatus(status: CanvasTaskStatus) {
return status === 'running';
}
function activeTaskSortPriority(status: CanvasTaskStatus) {
if (status === 'running') {
return 0;
}
if (status === 'pending') {
return 1;
}
return 2;
}
function mergeExternalTasks(
currentTasks: ExternalGenerationTaskRecord[],
nextTasks: ExternalGenerationTaskRecord[],
) {
const taskById = new Map(currentTasks.map((task) => [task.jobId, task]));
for (const task of nextTasks) {
taskById.set(task.jobId, task);
}
return Array.from(taskById.values());
}
function isActiveExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'queued' || task.status === 'running';
}
function isTerminalExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'completed' || task.status === 'failed';
}
function isCompletedExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'completed';
}
function isEditorCanvasTaskForProject(
task: ExternalGenerationTaskRecord,
projectId?: string | null,
) {
const normalizedProjectId = projectId?.trim();
return (
task.sourceModule === 'editor-canvas' &&
Boolean(normalizedProjectId) &&
task.sourceEntityId.trim() === normalizedProjectId
);
}
function mergeExternalTaskRefresh(
currentTasks: ExternalGenerationTaskRecord[],
refreshedTasks: ExternalGenerationTaskRecord[],
) {
const refreshedIds = new Set(refreshedTasks.map((task) => task.jobId));
return mergeExternalTasks(
currentTasks.filter(
(task) => !isActiveExternalTask(task) && !refreshedIds.has(task.jobId),
),
refreshedTasks,
);
}
function trimStoredExternalTasks(
tasks: ExternalGenerationTaskRecord[],
completedLimit: number,
) {
return mergeExternalTasks(
tasks.filter(isActiveExternalTask),
tasks
.filter(isTerminalExternalTask)
.sort(
(left, right) =>
(parseTaskTimeMs(right.completedAt) ??
parseTaskTimeMs(right.updatedAt) ??
0) -
(parseTaskTimeMs(left.completedAt) ??
parseTaskTimeMs(left.updatedAt) ??
0),
)
.slice(0, completedLimit),
);
}
export function ImageCanvasTaskSidebarView({
refreshKey = 0,
open,
onToggleOpen,
onFocusExternalTask,
onExternalTasksCompleted,
}: ImageCanvasTaskSidebarViewProps) {
const projectId = useImageCanvasContextStore((state) => state.projectId);
const normalizedProjectId = projectId?.trim() ?? '';
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
const [now, setNow] = useState(() => Date.now());
const [externalTasks, setExternalTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const [completedListState, setCompletedListState] = useState(() => ({
limit: COMPLETED_TASK_LIST_LIMIT,
projectId: normalizedProjectId,
}));
const completedListLimit =
completedListState.projectId === normalizedProjectId
? completedListState.limit
: COMPLETED_TASK_LIST_LIMIT;
const previousActiveItemCountRef = useRef(0);
const activeExternalTaskIdsRef = useRef<Set<string>>(new Set());
const completedRefreshNotifiedTaskIdsRef = useRef<Set<string>>(new Set());
const filterVisibleExternalTasks = useCallback(
(tasks: ExternalGenerationTaskRecord[]) =>
tasks.filter((task) =>
isEditorCanvasTaskForProject(task, normalizedProjectId),
),
[normalizedProjectId],
);
const notifyCompletedExternalTasks = useCallback(
(
tasks: ExternalGenerationTaskRecord[],
options: {
activeTaskIds?: ReadonlySet<string>;
unacknowledgedOnly?: boolean;
} = {},
) => {
const newlyCompletedTasks = tasks.filter((task) => {
if (
!isCompletedExternalTask(task) ||
completedRefreshNotifiedTaskIdsRef.current.has(task.jobId)
) {
return false;
}
if (options.activeTaskIds && !options.activeTaskIds.has(task.jobId)) {
return false;
}
if (options.unacknowledgedOnly && task.notificationAcknowledgedAt) {
return false;
}
return true;
});
if (!newlyCompletedTasks.length) {
return;
}
for (const task of newlyCompletedTasks) {
completedRefreshNotifiedTaskIdsRef.current.add(task.jobId);
}
onExternalTasksCompleted?.(newlyCompletedTasks);
},
[onExternalTasksCompleted],
);
useEffect(() => {
setCompletedListState((state) =>
state.projectId === normalizedProjectId
? state
: { limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId },
);
completedRefreshNotifiedTaskIdsRef.current = new Set();
}, [normalizedProjectId]);
useEffect(() => {
let disposed = false;
const controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
return;
}
const visibleActiveTasks = filterVisibleExternalTasks(
activeResponse.tasks,
);
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
});
setExternalTasks(
trimStoredExternalTasks(
mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks),
completedListLimit,
),
);
})
.catch(() => {
if (!disposed) {
setExternalTasks([]);
}
});
return () => {
disposed = true;
controller.abort();
};
}, [
completedListLimit,
filterVisibleExternalTasks,
notifyCompletedExternalTasks,
refreshKey,
]);
const activeExternalTaskIds = useMemo(
() =>
externalTasks
.filter((task) => task.status === 'queued' || task.status === 'running')
.map((task) => task.jobId),
[externalTasks],
);
const activeExternalTaskKey = activeExternalTaskIds.join('|');
useEffect(() => {
activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds);
}, [activeExternalTaskKey, activeExternalTaskIds]);
useEffect(() => {
if (!activeExternalTaskIds.length) {
return undefined;
}
let disposed = false;
let timerId: number | null = null;
const refreshActiveTasks = () => {
const controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
return;
}
const refreshedTasks = mergeExternalTasks(
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
);
notifyCompletedExternalTasks(refreshedTasks, {
activeTaskIds: activeExternalTaskIdsRef.current,
});
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
mergeExternalTaskRefresh(currentTasks, refreshedTasks),
completedListLimit,
),
);
})
.catch(() => {
// 下轮继续刷新;单次列表读取失败不应该冻结正在生成的任务状态。
})
.finally(() => {
if (disposed) {
return;
}
timerId = window.setTimeout(
refreshActiveTasks,
ACTIVE_TASK_POLL_INTERVAL_MS,
);
});
};
timerId = window.setTimeout(
refreshActiveTasks,
ACTIVE_TASK_POLL_INTERVAL_MS,
);
return () => {
disposed = true;
if (timerId !== null) {
window.clearTimeout(timerId);
}
};
}, [
activeExternalTaskIds,
activeExternalTaskKey,
completedListLimit,
filterVisibleExternalTasks,
notifyCompletedExternalTasks,
]);
const handleTaskListScroll = useCallback(
(event: ReactUIEvent<HTMLDivElement>) => {
if (activeTab !== 'completed') {
return;
}
const element = event.currentTarget;
const remaining =
element.scrollHeight - element.scrollTop - element.clientHeight;
if (remaining > 24) {
return;
}
setCompletedListState((state) => {
const limit =
state.projectId === normalizedProjectId
? state.limit
: COMPLETED_TASK_LIST_LIMIT;
return {
limit: Math.min(
COMPLETED_TASK_LIST_MAX_LIMIT,
limit + COMPLETED_TASK_LIST_LIMIT,
),
projectId: normalizedProjectId,
};
});
},
[activeTab, normalizedProjectId],
);
useEffect(() => {
const hasRunningTask = externalTasks.some(
(task) => task.status === 'running',
);
if (!hasRunningTask) {
return undefined;
}
const timerId = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timerId);
}, [externalTasks]);
const items = useMemo<TaskSidebarItem[]>(() => {
const externalVisibleTasks = externalTasks;
const externalItems = externalVisibleTasks.map<TaskSidebarItem>((task) => {
const status = canvasTaskStatusFromExternalTask(task.status);
const startedAt =
parseTaskTimeMs(task.startedAt) ?? parseTaskTimeMs(task.createdAt);
const finishedAt =
parseTaskTimeMs(task.completedAt) ?? parseTaskTimeMs(task.updatedAt);
return {
id: `external-${task.jobId}`,
title: task.requestLabel.trim() || task.jobKind,
detail: externalTaskDetail(task, status),
progressDetail: externalTaskProgressDetail(task, status) ?? undefined,
status,
startedAt,
finishedAt,
elapsedMs:
isTimedTaskStatus(status) && startedAt
? now - startedAt
: !isActiveTaskStatus(status) && startedAt && finishedAt
? finishedAt - startedAt
: undefined,
errorMessage: task.error ?? undefined,
onClick: () => onFocusExternalTask(task),
sortTime:
finishedAt ??
startedAt ??
parseTaskTimeMs(task.createdAt) ??
parseTaskTimeMs(task.updatedAt) ??
0,
};
});
return externalItems;
}, [externalTasks, now, onFocusExternalTask]);
const activeItems = useMemo(
() =>
items
.filter((item) => isActiveTaskStatus(item.status))
.sort(
(left, right) =>
activeTaskSortPriority(left.status) -
activeTaskSortPriority(right.status) ||
right.sortTime - left.sortTime ||
left.id.localeCompare(right.id),
),
[items],
);
const completedItems = useMemo(
() =>
items
.filter((item) => !isActiveTaskStatus(item.status))
.sort(
(left, right) =>
right.sortTime - left.sortTime || left.id.localeCompare(right.id),
)
.slice(0, COMPLETED_TASK_LIST_MAX_LIMIT),
[items],
);
useEffect(() => {
const previousActiveItemCount = previousActiveItemCountRef.current;
if (activeItems.length > previousActiveItemCount) {
setActiveTab('active');
}
previousActiveItemCountRef.current = activeItems.length;
}, [activeItems.length]);
const visibleItems = activeTab === 'active' ? activeItems : completedItems;
if (!open && items.length === 0) {
return (
<div className="image-canvas-editor__task-sidebar image-canvas-editor__task-sidebar--closed">
<EditorIconButton
className="image-canvas-editor__task-sidebar-toggle"
label="任务列表"
title="任务列表"
icon={ListChecks}
expanded={false}
onClick={onToggleOpen}
/>
</div>
);
}
return (
<aside
className={`image-canvas-editor__task-sidebar ${open ? 'image-canvas-editor__task-sidebar--open' : 'image-canvas-editor__task-sidebar--closed'}`}
aria-label="画布任务列表"
onWheel={stopTaskSidebarWheel}
onWheelCapture={stopTaskSidebarWheel}
>
<EditorIconButton
className="image-canvas-editor__task-sidebar-toggle"
label="任务列表"
title="任务列表"
icon={ListChecks}
expanded={open}
onClick={onToggleOpen}
/>
{open ? (
<div className="image-canvas-editor__task-sidebar-panel">
<div className="image-canvas-editor__task-sidebar-header">
<strong>任务列表</strong>
<span>{items.length}</span>
</div>
<div
className="image-canvas-editor__task-sidebar-tabs"
role="tablist"
>
<button
type="button"
role="tab"
aria-selected={activeTab === 'active'}
className={
activeTab === 'active'
? 'image-canvas-editor__task-sidebar-tab image-canvas-editor__task-sidebar-tab--active'
: 'image-canvas-editor__task-sidebar-tab'
}
onClick={() => setActiveTab('active')}
>
排队/生成中
<span>{activeItems.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'completed'}
className={
activeTab === 'completed'
? 'image-canvas-editor__task-sidebar-tab image-canvas-editor__task-sidebar-tab--active'
: 'image-canvas-editor__task-sidebar-tab'
}
onClick={() => setActiveTab('completed')}
>
已完成
<span>{completedItems.length}</span>
</button>
</div>
{visibleItems.length > 0 ? (
<div
className="image-canvas-editor__task-sidebar-list"
onScroll={handleTaskListScroll}
>
{visibleItems.map((item) => {
const elapsedLabel = formatElapsedMs(item.elapsedMs);
const finishedLabel =
item.status === 'done' || item.status === 'failed'
? formatTaskTimeMs(item.finishedAt)
: null;
const detailText = [
item.detail,
item.errorMessage && item.detail !== item.errorMessage
? item.errorMessage
: null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const timeText = [
elapsedLabel
? isTimedTaskStatus(item.status)
? `已用时 ${elapsedLabel}`
: `耗时 ${elapsedLabel}`
: null,
finishedLabel
? item.status === 'failed'
? `失败于 ${finishedLabel}`
: `完成于 ${finishedLabel}`
: null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const progressText = [
item.progressDetail,
isTimedTaskStatus(item.status) ? timeText : null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const titleText = [detailText, item.progressDetail, timeText]
.filter((value): value is string => Boolean(value))
.join(' · ');
const metaTimeText = isActiveTaskStatus(item.status)
? null
: timeText;
return (
<button
key={item.id}
type="button"
className={`image-canvas-editor__task-sidebar-item image-canvas-editor__task-sidebar-item--${item.status}`}
onClick={item.onClick}
disabled={!item.onClick}
>
<span className="image-canvas-editor__task-sidebar-item-icon">
{statusIcon(item.status)}
</span>
<span className="image-canvas-editor__task-sidebar-item-main">
<span className="image-canvas-editor__task-sidebar-item-title-row">
<span className="image-canvas-editor__task-sidebar-item-title">
{item.title}
</span>
<span className="image-canvas-editor__task-sidebar-item-meta">
<span className="image-canvas-editor__task-sidebar-item-status">
{statusLabel(item.status)}
</span>
{metaTimeText ? (
<span className="image-canvas-editor__task-sidebar-item-time">
{metaTimeText}
</span>
) : null}
</span>
</span>
<span
className="image-canvas-editor__task-sidebar-item-detail"
title={titleText}
>
{detailText}
</span>
{progressText ? (
<span
className="image-canvas-editor__task-sidebar-item-progress"
title={progressText}
>
{progressText}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : (
<div className="image-canvas-editor__task-sidebar-empty">
暂无任务
</div>
)}
</div>
) : null}
</aside>
);
}