升级外部生成为正式任务列表

将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源

新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示

保护画布生成队列超时、快照回写和自动保存覆盖场景

将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
This commit is contained in:
2026-06-24 15:17:12 +08:00
parent ed6b17442a
commit 73a17193d4
66 changed files with 2416 additions and 122 deletions
@@ -1120,6 +1120,79 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
vi.useFakeTimers();
try {
const refreshWalletBalance = vi.fn();
const runningState = createQueueState({
status: 'running',
phaseLabel: '生成中',
phaseDetail: '后端任务继续执行中',
progress: 0.35,
});
generateEditorImageMock.mockResolvedValueOnce({
...createGenerated({
imageSrc: '/generated-editor-images/project/image.png',
width: 1024,
height: 1024,
prompt: '队列生成',
}),
queueState: runningState,
});
getExternalGenerationJobStatusMock.mockResolvedValue({
job: runningState,
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '队列生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(getExternalGenerationJobStatusMock).toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:generating:closed:-:placeholder:-',
);
expect(screen.getByTestId('dialog').textContent).not.toContain(
'生成任务仍在队列中',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
} finally {
vi.useRealTimers();
}
});
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
@@ -199,12 +199,12 @@ async function runEditorGenerationWithWalletRefresh<T>(
async function waitForEditorGenerationQueue(
queueState: ExternalGenerationJobStatusRecord,
) {
): Promise<ExternalGenerationJobStatusRecord | null> {
const startedAt = Date.now();
let current = queueState;
while (current.status === 'queued' || current.status === 'running') {
if (Date.now() - startedAt > EDITOR_GENERATION_QUEUE_TIMEOUT_MS) {
throw new Error('生成任务仍在队列中,请稍后刷新画布查看结果');
return null;
}
await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS);
current = (
@@ -227,11 +227,15 @@ async function applyQueuedEditorGenerationProject(
if (!queueState) {
return false;
}
let terminalState: ExternalGenerationJobStatusRecord | null = null;
try {
await waitForEditorGenerationQueue(queueState);
terminalState = await waitForEditorGenerationQueue(queueState);
} finally {
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
}
if (!terminalState) {
return true;
}
if (projectId && applyProjectSnapshot) {
applyProjectSnapshot(await loadEditorProject(projectId));
}
@@ -274,6 +274,67 @@ function ProjectPersistenceHarness({
>
move generation again
</button>
<button
type="button"
onClick={() => {
persistence.applyProjectSnapshot({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated',
resourceId: 'resource-generated',
title: '生成结果',
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
zIndex: 1,
sourceType: 'generated',
},
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '后端完成生成器',
status: 'idle',
composerOpen: true,
generatedLayerId: 'layer-generated',
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
},
},
},
],
resources: [
{
resourceId: 'resource-generated',
projectId: 'editor-project-default',
imageSrc: '/generated/result.png',
objectKey: 'generated/result.png',
assetObjectId: 'asset-object-result',
width: 420,
height: 420,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:01.000Z',
});
}}
>
apply completed project
</button>
</div>
);
}
@@ -731,6 +792,62 @@ describe('useImageCanvasProjectPersistence', () => {
vi.useRealTimers();
});
it('drops stale pending generating saves when a backend completion snapshot is applied', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '刷新后继续生成',
status: 'generating',
composerOpen: false,
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
};
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: savedDialog,
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move generation' }).click();
});
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:后端完成生成器:42',
);
expect(screen.getByTestId('layers').textContent).toBe(
'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result',
);
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('saves generated layers together with their generator snapshots', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
@@ -260,6 +260,7 @@ export function useImageCanvasProjectPersistence({
const pendingProjectLayoutSaveRef =
useRef<PendingProjectLayoutSave | null>(null);
const isProjectLayoutSaveRunningRef = useRef(false);
const skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const projectTitleRef = useRef('未命名画布');
const [projectId, setProjectId] = useState<string | null>(null);
@@ -274,6 +275,14 @@ export function useImageCanvasProjectPersistence({
restoreCanvasGenerationDialogs,
} = setters;
const clearPendingProjectLayoutSave = useCallback(() => {
pendingProjectLayoutSaveRef.current = null;
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}, []);
const runPendingProjectLayoutSave = useCallback(
function runPendingProjectLayoutSave() {
if (isProjectLayoutSaveRunningRef.current) {
@@ -422,6 +431,8 @@ export function useImageCanvasProjectPersistence({
const applyProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
clearPendingProjectLayoutSave();
skipNextProjectLayoutSaveRef.current = true;
projectIdRef.current = project.projectId;
setProjectId(project.projectId);
const nextProjectTitle = project.title?.trim() || '未命名画布';
@@ -471,6 +482,7 @@ export function useImageCanvasProjectPersistence({
restoreCanvasGenerationDialogs(generationDialogs);
},
[
clearPendingProjectLayoutSave,
refs,
restoreCanvasGenerationDialogs,
selectSingleLayer,
@@ -558,6 +570,10 @@ export function useImageCanvasProjectPersistence({
if (!projectId || !isProjectReady) {
return undefined;
}
if (skipNextProjectLayoutSaveRef.current) {
skipNextProjectLayoutSaveRef.current = false;
return undefined;
}
queueProjectLayoutSave(
projectId,
{
@@ -69,6 +69,7 @@ describe('buildExternalGenerationQueueStatus', () => {
{
pendingCount: 7,
runningCount: 3,
unacknowledgedTerminalCount: 1,
updatedAtMicros: 1_781_222_400_000_000,
},
{
@@ -85,6 +86,8 @@ describe('buildExternalGenerationQueueStatus', () => {
currentProgress: 35,
pendingCount: 7,
runningCount: 3,
unacknowledgedTerminalCount: 1,
tasks: null,
});
});
@@ -99,6 +102,7 @@ describe('buildExternalGenerationQueueStatus', () => {
currentProgress: 42.4,
pendingCount: 2,
runningCount: 1,
unacknowledgedTerminalCount: 0,
}),
).toEqual({
statusLabel: '生成中',
@@ -107,7 +111,9 @@ describe('buildExternalGenerationQueueStatus', () => {
runningLabel: '1',
pendingCount: 2,
runningCount: 1,
unacknowledgedTerminalCount: 0,
progress: 42,
tasks: [],
shouldShow: true,
});
@@ -41,7 +41,7 @@ import type {
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
ExternalGenerationTaskRecord,
} from '../../../packages/shared/src/contracts/externalGeneration';
import type {
JumpHopJumpRequest,
@@ -193,7 +193,10 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
import {
acknowledgeExternalGenerationTasks,
listExternalGenerationTasks,
} from '../../services/external-generation';
import {
jumpHopClient,
type JumpHopGalleryCardResponse,
@@ -720,6 +723,54 @@ function isExternalGenerationQueueStage(selectionStage: SelectionStage) {
);
}
function isUnacknowledgedExternalGenerationTerminalTask(
task: ExternalGenerationTaskRecord,
) {
return (
(task.status === 'completed' || task.status === 'failed') &&
!task.notificationAcknowledgedAt
);
}
function resolveExternalGenerationTaskCompletionDialog(
tasks: readonly ExternalGenerationTaskRecord[],
): PlatformTaskCompletionDialogState | null {
const terminalTasks = tasks.filter(
isUnacknowledgedExternalGenerationTerminalTask,
);
if (terminalTasks.length === 0) {
return null;
}
const jobIds = terminalTasks.map((task) => task.jobId);
const latestUpdatedAtMicros = terminalTasks.reduce(
(latest, task) => Math.max(latest, task.updatedAtMicros),
0,
);
const completedAtMs =
latestUpdatedAtMicros > 0
? Math.floor(latestUpdatedAtMicros / 1000)
: Date.now();
return {
key: `external-generation:${jobIds.join('|')}:${latestUpdatedAtMicros}`,
source: '生成任务',
message:
terminalTasks.length > 1
? `${terminalTasks.length} 个生成任务已结束。`
: '生成任务已结束。',
completedAtMs,
taskJobIds: jobIds,
items: terminalTasks.map((task) => ({
source: task.requestLabel.trim() || task.jobKind || task.jobId,
message:
task.status === 'failed'
? (task.error?.trim() || '生成失败。')
: '生成已完成。',
})),
};
}
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
@@ -1839,8 +1890,16 @@ export function PlatformEntryFlowShellImpl({
const [puzzleOperation, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
null,
);
const [externalGenerationQueueOverview, setExternalGenerationQueueOverview] =
useState<ExternalGenerationQueueOverview | null>(null);
const [externalGenerationTasks, setExternalGenerationTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const [externalGenerationTaskOverview, setExternalGenerationTaskOverview] =
useState<{
pendingCount: number;
runningCount: number;
unacknowledgedTerminalCount: number;
updatedAtMicros: number;
} | null>(null);
const [jumpHopQueueState, setJumpHopQueueState] =
useState<ExternalGenerationJobStatusRecord | null>(null);
const [puzzleClearQueueState, setPuzzleClearQueueState] =
@@ -4922,18 +4981,21 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.platformTab === 'profile' &&
platformBootstrap.canReadProtectedData);
const isHostNetworkOnline = useHostNetworkOnline();
const shouldPollExternalGenerationTasks =
platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id);
const externalGenerationQueueOwnerKey =
shouldShowExternalGenerationQueueStatus
shouldPollExternalGenerationTasks
? (authUi?.user?.id ?? 'anonymous')
: null;
useEffect(() => {
setExternalGenerationQueueOverview(null);
setExternalGenerationTasks([]);
setExternalGenerationTaskOverview(null);
}, [externalGenerationQueueOwnerKey]);
useEffect(() => {
if (
!shouldShowExternalGenerationQueueStatus ||
!shouldPollExternalGenerationTasks ||
!isHostNetworkOnline
) {
return;
@@ -4942,24 +5004,30 @@ export function PlatformEntryFlowShellImpl({
let disposed = false;
let controller: AbortController | null = null;
const refreshQueueOverview = () => {
const refreshTaskList = () => {
controller?.abort();
controller = new AbortController();
getExternalGenerationQueueOverview(controller.signal)
listExternalGenerationTasks({
limit: 20,
includeAcknowledgedTerminal: false,
signal: controller.signal,
})
.then((response) => {
if (!disposed) {
setExternalGenerationQueueOverview(response.overview);
setExternalGenerationTasks(response.tasks);
setExternalGenerationTaskOverview(response.overview);
}
})
.catch(() => {
if (!disposed) {
setExternalGenerationQueueOverview(null);
setExternalGenerationTasks([]);
setExternalGenerationTaskOverview(null);
}
});
};
refreshQueueOverview();
const intervalId = window.setInterval(refreshQueueOverview, 4000);
refreshTaskList();
const intervalId = window.setInterval(refreshTaskList, 4000);
return () => {
disposed = true;
@@ -4969,7 +5037,7 @@ export function PlatformEntryFlowShellImpl({
}, [
externalGenerationQueueOwnerKey,
isHostNetworkOnline,
shouldShowExternalGenerationQueueStatus,
shouldPollExternalGenerationTasks,
]);
const activeExternalGenerationJobState = useMemo(() => {
const candidates = [
@@ -4998,10 +5066,15 @@ export function PlatformEntryFlowShellImpl({
const externalGenerationQueueStatus = useMemo(
() =>
buildExternalGenerationQueueStatus(
externalGenerationQueueOverview,
externalGenerationTaskOverview,
activeExternalGenerationJobState,
externalGenerationTasks,
),
[activeExternalGenerationJobState, externalGenerationQueueOverview],
[
activeExternalGenerationJobState,
externalGenerationTaskOverview,
externalGenerationTasks,
],
);
const profileExternalGenerationQueueStatus =
platformBootstrap.canReadProtectedData &&
@@ -5235,13 +5308,24 @@ export function PlatformEntryFlowShellImpl({
woodenFishRun?.runId,
woodenFishSession?.sessionId,
]);
const externalGenerationTaskCompletionDialog =
useMemo<PlatformTaskCompletionDialogState | null>(
() => resolveExternalGenerationTaskCompletionDialog(externalGenerationTasks),
[externalGenerationTasks],
);
const currentPlatformTaskCompletionDialog =
useMemo<PlatformTaskCompletionDialogState | null>(
() => pendingPlatformTaskCompletionDialog,
[pendingPlatformTaskCompletionDialog],
() =>
pendingPlatformTaskCompletionDialog ??
externalGenerationTaskCompletionDialog,
[
externalGenerationTaskCompletionDialog,
pendingPlatformTaskCompletionDialog,
],
);
const activePlatformTaskCompletionDialog =
selectionStage === 'image-editor'
selectionStage === 'image-editor' &&
!(currentPlatformTaskCompletionDialog?.taskJobIds?.length ?? 0)
? null
: resolveActivePlatformDialog(
currentPlatformTaskCompletionDialog,
@@ -5374,6 +5458,36 @@ export function PlatformEntryFlowShellImpl({
if (dismissKey) {
setDismissedPlatformTaskCompletionDialogKey(dismissKey);
}
const taskJobIds = currentPlatformTaskCompletionDialog.taskJobIds ?? [];
if (taskJobIds.length > 0) {
const acknowledgedJobIds = new Set(taskJobIds);
setExternalGenerationTasks((current) =>
current.filter((task) => !acknowledgedJobIds.has(task.jobId)),
);
setExternalGenerationTaskOverview((current) =>
current
? {
...current,
unacknowledgedTerminalCount: Math.max(
0,
current.unacknowledgedTerminalCount - acknowledgedJobIds.size,
),
}
: current,
);
void acknowledgeExternalGenerationTasks({ jobIds: taskJobIds })
.then((response) => {
setExternalGenerationTasks((current) =>
current.filter(
(task) =>
!response.acknowledgedTasks.some(
(acknowledgedTask) => acknowledgedTask.jobId === task.jobId,
),
),
);
})
.catch(() => undefined);
}
setPendingPlatformTaskCompletionDialog(null);
}, [currentPlatformTaskCompletionDialog]);
const shouldPollPuzzleGenerationSession =
@@ -69,7 +69,37 @@ export function PlatformProfileGenerationQueueCard({
fillClassName="bg-[linear-gradient(90deg,#e47631,#ce5f2a)]"
/>
<div className="mt-3 grid grid-cols-2 gap-2">
{presentation.tasks.length > 0 ? (
<div className="mt-3 space-y-2">
{presentation.tasks.map((task) => (
<div
key={task.id}
className="rounded-[8px] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 truncate text-[12px] font-black text-[var(--platform-text-strong)]">
{task.title}
</span>
<PlatformPillBadge
tone={task.tone}
size="xxs"
className="shrink-0"
>
{task.statusLabel}
</PlatformPillBadge>
</div>
<div className="mt-1 flex min-w-0 items-center justify-between gap-2 text-[11px] font-medium text-[var(--platform-text-base)]">
<span className="min-w-0 truncate">{task.detail}</span>
<span className="shrink-0 font-black text-[var(--platform-text-strong)]">
{task.priceLabel}
</span>
</div>
</div>
))}
</div>
) : null}
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="platform-profile-generation-queue-card__metric">
<span></span>
<strong>{presentation.pendingLabel}</strong>
@@ -78,6 +108,10 @@ export function PlatformProfileGenerationQueueCard({
<span></span>
<strong>{presentation.runningLabel}</strong>
</div>
<div className="platform-profile-generation-queue-card__metric">
<span></span>
<strong>{presentation.unacknowledgedTerminalCount}</strong>
</div>
</div>
</section>
);
@@ -3,6 +3,10 @@ import { PlatformReportDialog } from '../common/PlatformReportDialog';
export type PlatformTaskCompletionDialogPayload = {
source: string;
message: string;
items?: Array<{
source: string;
message: string;
}>;
};
type PlatformTaskCompletionDialogProps = {
@@ -26,17 +30,23 @@ export function PlatformTaskCompletionDialog({
copyIdleLabel="复制内容"
fields={
completion
? [
{
label: '来源',
value: completion.source,
},
{
label: '状态',
value: completion.message,
? completion.items?.length
? completion.items.map((item, index) => ({
label: item.source || `任务 ${index + 1}`,
value: item.message,
multiline: true,
},
]
}))
: [
{
label: '来源',
value: completion.source,
},
{
label: '状态',
value: completion.message,
multiline: true,
},
]
: []
}
overlayClassName={overlayClassName}
@@ -13,6 +13,7 @@ export type PlatformTaskCompletionDialogState =
PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
taskJobIds?: string[];
};
export type PlatformDialogCandidate = {
@@ -68,7 +69,7 @@ export function buildPlatformTaskCompletionDialogDismissKey(
completion: PlatformTaskCompletionDialogState | null,
) {
return completion
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}:${completion.taskJobIds?.join(',') ?? ''}`
: null;
}
@@ -1,6 +1,7 @@
import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
ExternalGenerationTaskRecord,
} from '../../../packages/shared/src/contracts/externalGeneration';
export type ExternalGenerationQueueStatus = {
@@ -8,6 +9,17 @@ export type ExternalGenerationQueueStatus = {
currentProgress?: number | null;
pendingCount?: number | null;
runningCount?: number | null;
tasks?: ExternalGenerationTaskRecord[] | null;
unacknowledgedTerminalCount?: number | null;
};
export type ExternalGenerationQueueTaskPresentation = {
id: string;
title: string;
statusLabel: string;
priceLabel: string;
detail: string;
tone: 'profile' | 'danger' | 'success';
};
export type ExternalGenerationQueuePresentation = {
@@ -17,15 +29,18 @@ export type ExternalGenerationQueuePresentation = {
runningLabel: string;
pendingCount: number;
runningCount: number;
unacknowledgedTerminalCount: number;
progress: number | null;
tasks: ExternalGenerationQueueTaskPresentation[];
shouldShow: boolean;
};
export function buildExternalGenerationQueueStatus(
overview: ExternalGenerationQueueOverview | null,
job: ExternalGenerationJobStatusRecord | null,
tasks: ExternalGenerationTaskRecord[] | null = null,
): ExternalGenerationQueueStatus | null {
if (!overview && !job) {
if (!overview && !job && !tasks?.length) {
return null;
}
@@ -34,6 +49,9 @@ export function buildExternalGenerationQueueStatus(
currentProgress: job?.progress ?? null,
pendingCount: overview?.pendingCount ?? null,
runningCount: overview?.runningCount ?? null,
unacknowledgedTerminalCount:
overview?.unacknowledgedTerminalCount ?? null,
tasks,
};
}
@@ -94,6 +112,26 @@ export function normalizeExternalGenerationQueueProgress(
return Math.max(0, Math.min(100, Math.round(value)));
}
export function buildExternalGenerationTaskPresentation(
task: ExternalGenerationTaskRecord,
): ExternalGenerationQueueTaskPresentation {
const statusLabel = resolveExternalGenerationQueueStatusLabel(task.status) ?? '待处理';
return {
id: task.jobId,
title: task.requestLabel.trim() || task.jobKind,
statusLabel,
priceLabel:
task.priceMudPoints > 0 ? `${task.priceMudPoints}泥点` : '未计费',
detail: task.error?.trim() || task.phaseDetail || task.updatedAt,
tone:
task.status === 'failed'
? 'danger'
: task.status === 'completed'
? 'success'
: 'profile',
};
}
export function buildExternalGenerationQueuePresentation(
status: ExternalGenerationQueueStatus | null | undefined,
): ExternalGenerationQueuePresentation {
@@ -103,6 +141,9 @@ export function buildExternalGenerationQueuePresentation(
const runningCount = normalizeExternalGenerationQueueCount(
status?.runningCount,
);
const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount(
status?.unacknowledgedTerminalCount,
);
const progress = normalizeExternalGenerationQueueProgress(
status?.currentProgress,
);
@@ -126,7 +167,17 @@ export function buildExternalGenerationQueuePresentation(
runningLabel: runningCount.toString(),
pendingCount,
runningCount,
unacknowledgedTerminalCount,
progress: progressValue,
shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0),
tasks: (status?.tasks ?? [])
.slice(0, 5)
.map(buildExternalGenerationTaskPresentation),
shouldShow: Boolean(
statusLabel ||
pendingCount > 0 ||
runningCount > 0 ||
unacknowledgedTerminalCount > 0 ||
(status?.tasks?.length ?? 0) > 0,
),
};
}
@@ -1,6 +1,9 @@
import type {
ExternalGenerationJobStatusResponse,
ExternalGenerationQueueOverviewResponse,
ExternalGenerationTaskAcknowledgeRequest,
ExternalGenerationTaskAcknowledgeResponse,
ExternalGenerationTaskListResponse,
} from '../../../packages/shared/src/contracts/externalGeneration';
import { BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson } from '../apiClient';
@@ -43,7 +46,54 @@ export async function getExternalGenerationJobStatus(
);
}
export async function listExternalGenerationTasks(
options: {
limit?: number;
includeAcknowledgedTerminal?: boolean;
signal?: AbortSignal;
} = {},
) {
const params = new URLSearchParams();
if (typeof options.limit === 'number') {
params.set('limit', String(options.limit));
}
if (typeof options.includeAcknowledgedTerminal === 'boolean') {
params.set(
'includeAcknowledgedTerminal',
options.includeAcknowledgedTerminal ? 'true' : 'false',
);
}
const query = params.toString();
return requestJson<ExternalGenerationTaskListResponse>(
`${EXTERNAL_GENERATION_API_BASE}/jobs${query ? `?${query}` : ''}`,
{
method: 'GET',
signal: options.signal,
},
'读取生成任务列表失败',
EXTERNAL_GENERATION_READ_OPTIONS,
);
}
export async function acknowledgeExternalGenerationTasks(
payload: ExternalGenerationTaskAcknowledgeRequest,
signal?: AbortSignal,
) {
return requestJson<ExternalGenerationTaskAcknowledgeResponse>(
`${EXTERNAL_GENERATION_API_BASE}/jobs/acknowledge`,
{
method: 'POST',
body: JSON.stringify(payload),
signal,
},
'确认生成任务通知失败',
EXTERNAL_GENERATION_READ_OPTIONS,
);
}
export const externalGenerationClient = {
getQueueOverview: getExternalGenerationQueueOverview,
getJobStatus: getExternalGenerationJobStatus,
listTasks: listExternalGenerationTasks,
acknowledgeTasks: acknowledgeExternalGenerationTasks,
};
@@ -1,5 +1,7 @@
export {
acknowledgeExternalGenerationTasks,
externalGenerationClient,
getExternalGenerationJobStatus,
getExternalGenerationQueueOverview,
listExternalGenerationTasks,
} from './externalGenerationClient';