fix: recover completed generation jobs after browser close
This commit is contained in:
@@ -78,8 +78,8 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video"|"reverse-prompt", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. Reverse-prompt now runs through the same job queue but does not deduct credits. Duplicate active jobs are deduped semantically while ignoring top-level `clientRequestId`, but users may submit a different task while another task is running. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. Status may be `queued`, `running`, `succeeded`, `failed`, or `cancelled`. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab. |
|
||||
| PATCH | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID plus `{ action: "cancel" }` | Owner or admin can cancel a `queued`/`running` job. The route marks the row `cancelled`, clears payload/result, writes a cancellation progress payload and `finished_at`, and returns `{ success: true, cancelled: true }`. Workers re-check that the job is still `running` before charging credits, persisting history, or writing success/failure so late upstream responses cannot resurrect a cancelled job. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. Status may be `queued`, `running`, `succeeded`, `failed`, or `cancelled`. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab; client-side pending job ids also use it to recover terminal results/errors that happened while the browser was closed. |
|
||||
| PATCH | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID plus `{ action: "cancel" }` | Owner or admin can cancel a `queued`/`running` job. The route marks the row `cancelled`, clears payload/result, writes a cancellation progress payload and `finished_at`, and returns the updated job row with `jobId`; if the job already settled it returns the existing row. Workers re-check that the job is still `running` before charging credits, persisting history, or writing success/failure so late upstream responses cannot resurrect a cancelled job. |
|
||||
|
||||
## Admin Invitation Routes
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ Create panel
|
||||
-> client polls GET /api/generation-jobs/[id]
|
||||
-> create panels can recover queued/running jobs from GET /api/generation-jobs after refresh, auth change, or tab switch
|
||||
(anonymous recovery list polling is skipped, and same-token/type list requests are briefly deduped client-side)
|
||||
-> client-side pending job ids also query GET /api/generation-jobs/[id] so jobs that reached succeeded/failed/cancelled while the browser was closed still display their terminal state once before being cleared
|
||||
-> history/gallery persistence via works APIs
|
||||
```
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. Create panels now also recover queued/running jobs from `/api/generation-jobs` so a refresh or re-login can reattach the live task before it finishes. Create-page history hooks intentionally request only the current mode and recent limit; if a create panel misses old records, check the API `mode` filter against `type`, `params.creationMode`, `params.workType`, `params.mode`, and legacy reference-image inference before raising the limit or reverting to full history. If the task card reappears after refresh but never turns into a result/error, inspect `src/components/create/use-generation-job-recovery.ts`; active-task state updates must not be part of the polling effect dependency list, or the recovery poller can be cancelled immediately after reattaching a job. |
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. Create panels recover queued/running jobs from `/api/generation-jobs` so a refresh or re-login can reattach the live task before it finishes; they also store created job ids in per-user browser localStorage and query `/api/generation-jobs/[id]` for terminal `succeeded`/`failed`/`cancelled` jobs that finished while the browser was closed. Create-page history hooks intentionally request only the current mode and recent limit; if a create panel misses old records, check the API `mode` filter against `type`, `params.creationMode`, `params.workType`, `params.mode`, and legacy reference-image inference before raising the limit or reverting to full history. If the task card reappears after refresh but never turns into a result/error, inspect `src/components/create/use-generation-job-recovery.ts`; active-task state updates must not be part of the polling effect dependency list, or the recovery poller can be cancelled immediately after reattaching a job. |
|
||||
| Detail delete removes only local history, skips confirmation, or record reappears after refresh | `src/components/creation-detail-dialog.tsx`, `src/components/ui/alert-dialog.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/profile/creation-history-tab.tsx` | The detail action is labeled `删除作品` and must open a confirmation dialog warning that deletion cannot be recovered. Logged-in deletion should call `DELETE /api/creation-history?id=...` first, then refresh local history from the server. Check bearer token availability and route ownership filter (`id` + `user_id`). |
|
||||
| Published work not in gallery or share to gallery is slow | `src/lib/creation-history-store.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, stable `/api/local-storage/...` `result_url`, media copied/reused into gallery storage, and current filters. New generated `/api/local-storage/...` image/video URLs should use the publish fast path in `gallery-publish-media` and must not synchronously copy object-backed originals during share; external URLs still need copying and should fail the publish request if media preparation fails. Also check whether the browser marked the work shared before `/api/gallery/publish` returned success; local `published=true` without `publishedAt` is stale and should not block retry. For older incidents, inspect server logs/API status for publish failures that the previous frontend swallowed. |
|
||||
| 图生图/图生视频分享到画廊后看不到参考图,或复用/获取灵感没有带上参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts` | The create panels should send `referenceImage`, `referenceImages`, `refImageCount`, and `referenceImageAnnotations` to `shareToGallery`. `/api/gallery/publish` should persist data URL or remote reference images into stable `/api/local-storage/gallery/references/...` URLs before storing them in `works.params`. Public gallery detail and inspiration detail may preview reference images but must not expose reference-image download actions; reuse drafts should prefer original `referenceImages` and only fall back to output media as reference when no references exist. |
|
||||
|
||||
@@ -67,7 +67,7 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Responsibility | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. Active-job recovery skips anonymous list polling and reuses same-token, same-type list requests briefly, so refresh/auth-change recovery does not add duplicate `/api/generation-jobs` pressure while tasks keep polling individually until success/failure. |
|
||||
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. Active-job recovery skips anonymous list polling and reuses same-token, same-type list requests briefly, so refresh/auth-change recovery does not add duplicate `/api/generation-jobs` pressure while tasks keep polling individually until success/failure. Created job ids are also stored per logged-in user in browser localStorage until the job reaches `succeeded`, `failed`, or `cancelled`; `useGenerationJobRecovery` merges that pending list with server queued/running jobs and queries `/api/generation-jobs/[id]` so jobs that finish while the browser is closed can still reappear with their terminal result/error before being cleared. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`, including queued/running system-default jobs already waiting for the same user. Active queued/running jobs are semantically deduped while ignoring top-level `clientRequestId`, so a double-click or fast retry returns the existing job instead of creating a second one. |
|
||||
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
|
||||
|
||||
@@ -100,6 +100,31 @@ await runTest('active generation job recovery avoids anonymous polling and dedup
|
||||
assert.match(source, /getActiveJobsRequestKey\(normalizedTypes, authToken\)/);
|
||||
});
|
||||
|
||||
await runTest('generation jobs stay recoverable after the browser closes before the result is consumed', () => {
|
||||
const clientSource = read('src/lib/generation-job-client.ts');
|
||||
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
||||
|
||||
assert.match(clientSource, /PENDING_GENERATION_JOBS_STORAGE_PREFIX/);
|
||||
assert.match(clientSource, /rememberPendingGenerationJob/);
|
||||
assert.match(clientSource, /forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /fetchGenerationJobStatus/);
|
||||
assert.match(clientSource, /fetchRecoverableGenerationJobs/);
|
||||
assert.match(clientSource, /rememberPendingGenerationJob\(type,\s*createData\.jobId/);
|
||||
assert.match(recoverySource, /fetchRecoverableGenerationJobs/);
|
||||
assert.doesNotMatch(recoverySource, /const jobs = await fetchActiveGenerationJobs\(typesRef\.current\);/);
|
||||
});
|
||||
|
||||
await runTest('terminal recovered generation jobs clear pending browser recovery state', () => {
|
||||
const clientSource = read('src/lib/generation-job-client.ts');
|
||||
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
||||
|
||||
assert.match(clientSource, /statusData\.status === 'succeeded'[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /statusData\.status === 'failed'[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /statusData\.status === 'cancelled'[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /cancelGenerationJob[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(recoverySource, /forgetPendingGenerationJob/);
|
||||
});
|
||||
|
||||
await runTest('active job recovery dedupes locally submitted tasks by client request id', () => {
|
||||
const taskListSource = read('src/components/create/generation-task-list.tsx');
|
||||
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
GenerationJobCancelledError,
|
||||
GenerationJobStillRunningError,
|
||||
continueGenerationJob,
|
||||
fetchActiveGenerationJobs,
|
||||
fetchRecoverableGenerationJobs,
|
||||
forgetPendingGenerationJob,
|
||||
type GenerationJobStatus,
|
||||
type GenerationJobType,
|
||||
} from '@/lib/generation-job-client';
|
||||
@@ -67,6 +68,10 @@ function getJobIdentityIds(job: GenerationJobStatus, task: ActiveGenerationTask)
|
||||
].filter((id): id is string => Boolean(id))));
|
||||
}
|
||||
|
||||
function isTerminalGenerationJobStatus(status: GenerationJobStatus['status']): boolean {
|
||||
return status === 'succeeded' || status === 'failed' || status === 'cancelled';
|
||||
}
|
||||
|
||||
export function useGenerationJobRecovery({
|
||||
types,
|
||||
knownJobIds = [],
|
||||
@@ -111,7 +116,7 @@ export function useGenerationJobRecovery({
|
||||
try {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 800));
|
||||
if (cancelled) return;
|
||||
const jobs = await fetchActiveGenerationJobs(typesRef.current);
|
||||
const jobs = await fetchRecoverableGenerationJobs(typesRef.current);
|
||||
if (cancelled) return;
|
||||
for (const job of jobs) {
|
||||
const task = normalizeJobTask(job);
|
||||
@@ -120,17 +125,35 @@ export function useGenerationJobRecovery({
|
||||
if (identityIds.some(id => activeJobIdsRef.current.has(id) || knownJobIdsRef.current.has(id))) continue;
|
||||
for (const id of identityIds) activeJobIdsRef.current.add(id);
|
||||
onTaskRecoveredRef.current(task, job);
|
||||
if (isTerminalGenerationJobStatus(job.status)) {
|
||||
for (const id of identityIds) {
|
||||
activeJobIdsRef.current.delete(id);
|
||||
forgetPendingGenerationJob(id);
|
||||
}
|
||||
if (job.status === 'succeeded') {
|
||||
onTaskFinishedRef.current(task.id, job);
|
||||
} else {
|
||||
onTaskFailedRef.current(task.id, job.status === 'cancelled' ? '任务已取消' : job.error || '生成任务失败', job);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
void (async () => {
|
||||
const timeoutMs = job.type === 'video' ? 600_000 : job.type === 'reverse-prompt' ? 300_000 : 900_000;
|
||||
const onStatus = (status: GenerationJobStatus) => {
|
||||
if (cancelled) return;
|
||||
if (status.status === 'failed' || status.status === 'cancelled') {
|
||||
for (const id of identityIds) activeJobIdsRef.current.delete(id);
|
||||
for (const id of identityIds) {
|
||||
activeJobIdsRef.current.delete(id);
|
||||
forgetPendingGenerationJob(id);
|
||||
}
|
||||
onTaskFailedRef.current(task.id, status.status === 'cancelled' ? '任务已取消' : status.error || '生成任务失败', status);
|
||||
return;
|
||||
}
|
||||
if (status.status === 'succeeded') {
|
||||
for (const id of identityIds) activeJobIdsRef.current.delete(id);
|
||||
for (const id of identityIds) {
|
||||
activeJobIdsRef.current.delete(id);
|
||||
forgetPendingGenerationJob(id);
|
||||
}
|
||||
onTaskFinishedRef.current(task.id, status);
|
||||
}
|
||||
};
|
||||
@@ -149,7 +172,10 @@ export function useGenerationJobRecovery({
|
||||
continue;
|
||||
}
|
||||
if (error instanceof GenerationJobCancelledError) {
|
||||
for (const id of identityIds) activeJobIdsRef.current.delete(id);
|
||||
for (const id of identityIds) {
|
||||
activeJobIdsRef.current.delete(id);
|
||||
forgetPendingGenerationJob(id);
|
||||
}
|
||||
onTaskFailedRef.current(task.id, '任务已取消', error.status);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,16 @@ type PollGenerationJobOptions = GenerationJobOptions & {
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
type PendingGenerationJob = {
|
||||
jobId: string;
|
||||
type: GenerationJobType;
|
||||
createdAt: number;
|
||||
clientRequestId?: string;
|
||||
};
|
||||
|
||||
const ACTIVE_JOBS_REQUEST_TTL_MS = 1200;
|
||||
const PENDING_GENERATION_JOBS_STORAGE_PREFIX = 'miaojing:generation-jobs:pending:';
|
||||
const PENDING_GENERATION_JOBS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const activeJobsRequestCache = new Map<string, {
|
||||
expiresAt: number;
|
||||
@@ -78,6 +87,10 @@ function normalizeGenerationJobTypes(types?: GenerationJobType[]): GenerationJob
|
||||
return Array.from(new Set((types || []).filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function isGenerationJobType(value: unknown): value is GenerationJobType {
|
||||
return value === 'image' || value === 'video' || value === 'reverse-prompt';
|
||||
}
|
||||
|
||||
function hashAuthToken(authToken: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < authToken.length; index += 1) {
|
||||
@@ -91,6 +104,108 @@ function getActiveJobsRequestKey(types: GenerationJobType[], authToken: string):
|
||||
return `${hashAuthToken(authToken)}:${types.join(',') || 'all'}`;
|
||||
}
|
||||
|
||||
function getAuthStorageIdentity(authToken = getAuthToken()): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem('miaojing_auth');
|
||||
const userId = raw ? (JSON.parse(raw) as { user?: { id?: string } }).user?.id : '';
|
||||
if (typeof userId === 'string' && userId.trim()) return userId.trim();
|
||||
} catch {
|
||||
// Fall back to the token hash below.
|
||||
}
|
||||
return authToken ? hashAuthToken(authToken) : null;
|
||||
}
|
||||
|
||||
function getPendingGenerationJobsStorageKey(authToken = getAuthToken()): string | null {
|
||||
const identity = getAuthStorageIdentity(authToken);
|
||||
return identity ? `${PENDING_GENERATION_JOBS_STORAGE_PREFIX}${identity}` : null;
|
||||
}
|
||||
|
||||
function normalizePendingGenerationJobs(value: unknown): PendingGenerationJob[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const cutoff = Date.now() - PENDING_GENERATION_JOBS_MAX_AGE_MS;
|
||||
const seen = new Set<string>();
|
||||
const jobs: PendingGenerationJob[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const record = item as Partial<PendingGenerationJob>;
|
||||
const jobId = typeof record.jobId === 'string' ? record.jobId.trim() : '';
|
||||
if (!jobId || seen.has(jobId) || !isGenerationJobType(record.type)) continue;
|
||||
const createdAt = Number(record.createdAt);
|
||||
if (!Number.isFinite(createdAt) || createdAt < cutoff) continue;
|
||||
seen.add(jobId);
|
||||
jobs.push({
|
||||
jobId,
|
||||
type: record.type,
|
||||
createdAt,
|
||||
clientRequestId: typeof record.clientRequestId === 'string' && record.clientRequestId.trim()
|
||||
? record.clientRequestId.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return jobs.sort((a, b) => b.createdAt - a.createdAt).slice(0, 100);
|
||||
}
|
||||
|
||||
function readPendingGenerationJobs(authToken = getAuthToken()): PendingGenerationJob[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const key = getPendingGenerationJobsStorageKey(authToken);
|
||||
if (!key) return [];
|
||||
try {
|
||||
return normalizePendingGenerationJobs(JSON.parse(window.localStorage.getItem(key) || '[]'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingGenerationJobs(jobs: PendingGenerationJob[], authToken = getAuthToken()) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const key = getPendingGenerationJobsStorageKey(authToken);
|
||||
if (!key) return;
|
||||
const normalized = normalizePendingGenerationJobs(jobs);
|
||||
try {
|
||||
if (normalized.length === 0) {
|
||||
window.localStorage.removeItem(key);
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(normalized));
|
||||
}
|
||||
} catch {
|
||||
// localStorage quota failures should not block generation.
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberPendingGenerationJob(
|
||||
type: GenerationJobType,
|
||||
jobId: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) {
|
||||
const id = jobId.trim();
|
||||
if (!id) return;
|
||||
const authToken = getAuthToken();
|
||||
if (!authToken) return;
|
||||
const existing = readPendingGenerationJobs(authToken).filter(job => job.jobId !== id);
|
||||
const clientRequestId = typeof payload?.clientRequestId === 'string' && payload.clientRequestId.trim()
|
||||
? payload.clientRequestId.trim()
|
||||
: undefined;
|
||||
writePendingGenerationJobs([
|
||||
{
|
||||
jobId: id,
|
||||
type,
|
||||
createdAt: Date.now(),
|
||||
clientRequestId,
|
||||
},
|
||||
...existing,
|
||||
], authToken);
|
||||
}
|
||||
|
||||
export function forgetPendingGenerationJob(jobId: string) {
|
||||
const id = jobId.trim();
|
||||
if (!id) return;
|
||||
const authToken = getAuthToken();
|
||||
if (!authToken) return;
|
||||
const next = readPendingGenerationJobs(authToken).filter(job => job.jobId !== id);
|
||||
writePendingGenerationJobs(next, authToken);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -117,24 +232,20 @@ async function pollGenerationJob<T extends Record<string, unknown>>(
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await sleep(intervalMs);
|
||||
|
||||
const authHeaders = getAuthHeaders();
|
||||
const statusRes = await fetch(`/api/generation-jobs/${encodeURIComponent(options.jobId)}`, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
const statusData = await statusRes.json().catch(() => ({}));
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
||||
}
|
||||
const statusData = await fetchGenerationJobStatus(options.jobId);
|
||||
options.onStatus?.(statusData as GenerationJobStatus);
|
||||
lastStatus = statusData as GenerationJobStatus;
|
||||
|
||||
if (statusData.status === 'succeeded') {
|
||||
forgetPendingGenerationJob(options.jobId);
|
||||
return (statusData.result || {}) as T;
|
||||
}
|
||||
if (statusData.status === 'failed') {
|
||||
forgetPendingGenerationJob(options.jobId);
|
||||
throw new Error(statusData.error || '生成任务失败');
|
||||
}
|
||||
if (statusData.status === 'cancelled') {
|
||||
forgetPendingGenerationJob(options.jobId);
|
||||
throw new GenerationJobCancelledError(statusData as GenerationJobStatus);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +278,20 @@ export async function continueGenerationJobUntilSettled<T extends Record<string,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGenerationJobStatus(
|
||||
jobId: string,
|
||||
authToken = getAuthToken(),
|
||||
): Promise<GenerationJobStatus> {
|
||||
const res = await fetch(`/api/generation-jobs/${encodeURIComponent(jobId)}`, {
|
||||
headers: getAuthHeaders(authToken),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `任务查询失败 (${res.status})`);
|
||||
}
|
||||
return data as GenerationJobStatus;
|
||||
}
|
||||
|
||||
export async function fetchActiveGenerationJobs(types?: GenerationJobType[]): Promise<GenerationJobStatus[]> {
|
||||
const authToken = getAuthToken();
|
||||
if (!authToken) return [];
|
||||
@@ -202,6 +327,49 @@ export async function fetchActiveGenerationJobs(types?: GenerationJobType[]): Pr
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function fetchRecoverableGenerationJobs(types?: GenerationJobType[]): Promise<GenerationJobStatus[]> {
|
||||
const authToken = getAuthToken();
|
||||
if (!authToken) return [];
|
||||
const normalizedTypes = normalizeGenerationJobTypes(types);
|
||||
const allowedTypes = normalizedTypes.length > 0 ? new Set<GenerationJobType>(normalizedTypes) : null;
|
||||
const activeJobs = await fetchActiveGenerationJobs(types);
|
||||
const jobsById = new Map<string, GenerationJobStatus>();
|
||||
for (const job of activeJobs) {
|
||||
const jobId = String(job.jobId || job.id || '');
|
||||
if (jobId) jobsById.set(jobId, job);
|
||||
}
|
||||
|
||||
const pendingJobs = readPendingGenerationJobs(authToken)
|
||||
.filter(job => !allowedTypes || allowedTypes.has(job.type));
|
||||
await Promise.all(pendingJobs.map(async pending => {
|
||||
if (jobsById.has(pending.jobId)) return;
|
||||
try {
|
||||
const status = await fetchGenerationJobStatus(pending.jobId, authToken);
|
||||
const statusType = isGenerationJobType(status.type) ? status.type : pending.type;
|
||||
if (allowedTypes && !allowedTypes.has(statusType)) return;
|
||||
jobsById.set(pending.jobId, {
|
||||
...status,
|
||||
type: statusType,
|
||||
jobId: status.jobId || status.id || pending.jobId,
|
||||
payload: {
|
||||
...(status.payload || {}),
|
||||
...(pending.clientRequestId && !status.payload?.clientRequestId ? { clientRequestId: pending.clientRequestId } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /任务不存在|404/.test(error.message)) {
|
||||
forgetPendingGenerationJob(pending.jobId);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return Array.from(jobsById.values()).sort((a, b) => {
|
||||
const aTime = Date.parse(a.created_at || a.updated_at || '') || 0;
|
||||
const bTime = Date.parse(b.created_at || b.updated_at || '') || 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelGenerationJob(jobId: string): Promise<GenerationJobStatus> {
|
||||
const authHeaders = getAuthHeaders();
|
||||
const res = await fetch(`/api/generation-jobs/${encodeURIComponent(jobId)}`, {
|
||||
@@ -216,6 +384,7 @@ export async function cancelGenerationJob(jobId: string): Promise<GenerationJobS
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `取消任务失败 (${res.status})`);
|
||||
}
|
||||
forgetPendingGenerationJob(jobId);
|
||||
return data as GenerationJobStatus;
|
||||
}
|
||||
|
||||
@@ -238,6 +407,7 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
||||
if (!createRes.ok || !createData.jobId) {
|
||||
throw new Error(createData.error || `任务创建失败 (${createRes.status})`);
|
||||
}
|
||||
rememberPendingGenerationJob(type, createData.jobId, payload);
|
||||
options.onStatus?.({
|
||||
...createData,
|
||||
status: 'queued',
|
||||
|
||||
Reference in New Issue
Block a user