@umituz/react-native-ai-fal-provider 1.0.59 → 1.0.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +16 -0
- package/src/infrastructure/services/fal-provider.ts +49 -5
- package/src/infrastructure/services/index.ts +16 -0
- package/src/infrastructure/utils/index.ts +32 -0
- package/src/infrastructure/utils/job-metadata.util.ts +176 -0
- package/src/infrastructure/utils/job-storage.util.ts +187 -0
- package/src/presentation/hooks/use-fal-generation.ts +34 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-fal-provider",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.61",
|
|
4
4
|
"description": "FAL AI provider for React Native - implements IAIProvider interface for unified AI generation",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,9 @@ export type { FeatureModelConfig } from "./domain/constants/feature-models.const
|
|
|
26
26
|
export {
|
|
27
27
|
FalProvider, falProvider, falModelsService,
|
|
28
28
|
getImageFeatureModel, getVideoFeatureModel, NSFWContentError,
|
|
29
|
+
cancelCurrentFalRequest, hasRunningFalRequest,
|
|
29
30
|
} from "./infrastructure/services";
|
|
31
|
+
export type { FalProviderType } from "./infrastructure/services";
|
|
30
32
|
|
|
31
33
|
export {
|
|
32
34
|
categorizeFalError, falErrorMapper, mapFalError, isFalErrorRetryable,
|
|
@@ -48,6 +50,20 @@ export {
|
|
|
48
50
|
buildErrorMessage, isDefined, removeNullish, debounce, throttle,
|
|
49
51
|
} from "./infrastructure/utils";
|
|
50
52
|
|
|
53
|
+
export {
|
|
54
|
+
createJobMetadata, updateJobMetadata, isJobCompleted, isJobRunning,
|
|
55
|
+
isJobStale, getJobDuration, formatJobDuration, calculateJobProgress,
|
|
56
|
+
serializeJobMetadata, deserializeJobMetadata, filterValidJobs,
|
|
57
|
+
sortJobsByCreation, getActiveJobs, getCompletedJobs,
|
|
58
|
+
} from "./infrastructure/utils";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
saveJobMetadata, loadJobMetadata, deleteJobMetadata, loadAllJobs,
|
|
62
|
+
cleanupOldJobs, getJobsByModel, getJobsByStatus, updateJobStatus,
|
|
63
|
+
} from "./infrastructure/utils";
|
|
64
|
+
|
|
65
|
+
export type { FalJobMetadata, IJobStorage, InMemoryJobStorage } from "./infrastructure/utils";
|
|
66
|
+
|
|
51
67
|
export type {
|
|
52
68
|
UpscaleOptions, PhotoRestoreOptions, FaceSwapOptions, ImageToImagePromptConfig,
|
|
53
69
|
RemoveBackgroundOptions, RemoveObjectOptions, ReplaceBackgroundOptions,
|
|
@@ -33,6 +33,7 @@ export class FalProvider implements IAIProvider {
|
|
|
33
33
|
private apiKey: string | null = null;
|
|
34
34
|
private config: AIProviderConfig | null = null;
|
|
35
35
|
private initialized = false;
|
|
36
|
+
private currentAbortController: AbortController | null = null;
|
|
36
37
|
|
|
37
38
|
initialize(configData: AIProviderConfig): void {
|
|
38
39
|
this.apiKey = configData.apiKey;
|
|
@@ -104,8 +105,17 @@ export class FalProvider implements IAIProvider {
|
|
|
104
105
|
options?: SubscribeOptions<T>,
|
|
105
106
|
): Promise<T> {
|
|
106
107
|
this.validateInitialization();
|
|
108
|
+
|
|
109
|
+
// Cancel previous request if exists
|
|
110
|
+
this.cancelCurrentRequest();
|
|
111
|
+
|
|
112
|
+
// Create new abort controller
|
|
113
|
+
this.currentAbortController = new AbortController();
|
|
114
|
+
const { signal } = this.currentAbortController;
|
|
115
|
+
|
|
107
116
|
const timeoutMs = options?.timeoutMs ?? this.config?.defaultTimeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
|
|
108
117
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
let currentRequestId: string | null = null;
|
|
109
119
|
|
|
110
120
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
111
121
|
console.log("[FalProvider] Subscribe started:", { model, timeoutMs });
|
|
@@ -119,24 +129,36 @@ export class FalProvider implements IAIProvider {
|
|
|
119
129
|
input,
|
|
120
130
|
logs: false,
|
|
121
131
|
pollInterval: DEFAULT_FAL_CONFIG.pollInterval,
|
|
122
|
-
onQueueUpdate: (update: { status: string; logs?: unknown[] }) => {
|
|
123
|
-
|
|
132
|
+
onQueueUpdate: (update: { status: string; logs?: unknown[]; request_id?: string }) => {
|
|
133
|
+
currentRequestId = update.request_id ?? null;
|
|
134
|
+
const jobStatus = mapFalStatusToJobStatus({
|
|
135
|
+
...update as unknown as FalQueueStatus,
|
|
136
|
+
requestId: currentRequestId ?? "",
|
|
137
|
+
});
|
|
124
138
|
if (jobStatus.status !== lastStatus) {
|
|
125
139
|
lastStatus = jobStatus.status;
|
|
126
140
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
127
|
-
console.log("[FalProvider] Status:", jobStatus.status);
|
|
141
|
+
console.log("[FalProvider] Status:", jobStatus.status, "RequestId:", currentRequestId);
|
|
128
142
|
}
|
|
129
143
|
}
|
|
130
144
|
options?.onQueueUpdate?.(jobStatus);
|
|
131
145
|
},
|
|
132
146
|
}),
|
|
133
147
|
new Promise<never>((_, reject) => {
|
|
134
|
-
timeoutId = setTimeout(() =>
|
|
148
|
+
timeoutId = setTimeout(() => {
|
|
149
|
+
reject(new Error("FAL subscription timeout"));
|
|
150
|
+
}, timeoutMs);
|
|
151
|
+
}),
|
|
152
|
+
// Abort promise
|
|
153
|
+
new Promise<never>((_, reject) => {
|
|
154
|
+
signal.addEventListener("abort", () => {
|
|
155
|
+
reject(new Error("Request cancelled by user"));
|
|
156
|
+
});
|
|
135
157
|
}),
|
|
136
158
|
]);
|
|
137
159
|
|
|
138
160
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
139
|
-
console.log("[FalProvider] Subscribe completed:", { model });
|
|
161
|
+
console.log("[FalProvider] Subscribe completed:", { model, requestId: currentRequestId });
|
|
140
162
|
}
|
|
141
163
|
|
|
142
164
|
validateNSFWContent(result as Record<string, unknown>);
|
|
@@ -145,6 +167,7 @@ export class FalProvider implements IAIProvider {
|
|
|
145
167
|
return result as T;
|
|
146
168
|
} finally {
|
|
147
169
|
if (timeoutId) clearTimeout(timeoutId);
|
|
170
|
+
this.currentAbortController = null;
|
|
148
171
|
}
|
|
149
172
|
}
|
|
150
173
|
|
|
@@ -171,11 +194,32 @@ export class FalProvider implements IAIProvider {
|
|
|
171
194
|
}
|
|
172
195
|
|
|
173
196
|
reset(): void {
|
|
197
|
+
this.cancelCurrentRequest();
|
|
174
198
|
this.apiKey = null;
|
|
175
199
|
this.config = null;
|
|
176
200
|
this.initialized = false;
|
|
177
201
|
}
|
|
178
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Cancel the current running request
|
|
205
|
+
*/
|
|
206
|
+
cancelCurrentRequest(): void {
|
|
207
|
+
if (this.currentAbortController) {
|
|
208
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
209
|
+
console.log("[FalProvider] Cancelling current request");
|
|
210
|
+
}
|
|
211
|
+
this.currentAbortController.abort();
|
|
212
|
+
this.currentAbortController = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if there's a running request
|
|
218
|
+
*/
|
|
219
|
+
hasRunningRequest(): boolean {
|
|
220
|
+
return this.currentAbortController !== null;
|
|
221
|
+
}
|
|
222
|
+
|
|
179
223
|
getImageFeatureModel(feature: ImageFeatureType): string {
|
|
180
224
|
return FAL_IMAGE_FEATURE_MODELS[feature];
|
|
181
225
|
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { FAL_IMAGE_FEATURE_MODELS, FAL_VIDEO_FEATURE_MODELS } from "../../domain/constants/feature-models.constants";
|
|
6
6
|
import type { ImageFeatureType, VideoFeatureType } from "@umituz/react-native-ai-generation-content";
|
|
7
|
+
import { falProvider } from "./fal-provider";
|
|
7
8
|
|
|
8
9
|
export { FalProvider, falProvider } from "./fal-provider";
|
|
10
|
+
export type { FalProvider as FalProviderType } from "./fal-provider";
|
|
9
11
|
export { falModelsService, type FalModelConfig } from "./fal-models.service";
|
|
10
12
|
export { NSFWContentError } from "./nsfw-content-error";
|
|
11
13
|
|
|
@@ -16,3 +18,17 @@ export function getImageFeatureModel(feature: ImageFeatureType): string {
|
|
|
16
18
|
export function getVideoFeatureModel(feature: VideoFeatureType): string {
|
|
17
19
|
return FAL_VIDEO_FEATURE_MODELS[feature];
|
|
18
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cancel the current running FAL request
|
|
24
|
+
*/
|
|
25
|
+
export function cancelCurrentFalRequest(): void {
|
|
26
|
+
falProvider.cancelCurrentRequest();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if there's a running FAL request
|
|
31
|
+
*/
|
|
32
|
+
export function hasRunningFalRequest(): boolean {
|
|
33
|
+
return falProvider.hasRunningRequest();
|
|
34
|
+
}
|
|
@@ -46,3 +46,35 @@ export {
|
|
|
46
46
|
debounce,
|
|
47
47
|
throttle,
|
|
48
48
|
} from "./helpers.util";
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
createJobMetadata,
|
|
52
|
+
updateJobMetadata,
|
|
53
|
+
isJobCompleted,
|
|
54
|
+
isJobRunning,
|
|
55
|
+
isJobStale,
|
|
56
|
+
getJobDuration,
|
|
57
|
+
formatJobDuration,
|
|
58
|
+
calculateJobProgress,
|
|
59
|
+
serializeJobMetadata,
|
|
60
|
+
deserializeJobMetadata,
|
|
61
|
+
filterValidJobs,
|
|
62
|
+
sortJobsByCreation,
|
|
63
|
+
getActiveJobs,
|
|
64
|
+
getCompletedJobs,
|
|
65
|
+
} from "./job-metadata.util";
|
|
66
|
+
|
|
67
|
+
export type { FalJobMetadata } from "./job-metadata.util";
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
saveJobMetadata,
|
|
71
|
+
loadJobMetadata,
|
|
72
|
+
deleteJobMetadata,
|
|
73
|
+
loadAllJobs,
|
|
74
|
+
cleanupOldJobs,
|
|
75
|
+
getJobsByModel,
|
|
76
|
+
getJobsByStatus,
|
|
77
|
+
updateJobStatus,
|
|
78
|
+
} from "./job-storage.util";
|
|
79
|
+
|
|
80
|
+
export type { IJobStorage, InMemoryJobStorage } from "./job-storage.util";
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Metadata Utilities
|
|
3
|
+
* Helper functions for job metadata and management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { JobStatus } from "@umituz/react-native-ai-generation-content";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Job metadata for tracking and persistence
|
|
10
|
+
*/
|
|
11
|
+
export interface FalJobMetadata {
|
|
12
|
+
readonly requestId: string;
|
|
13
|
+
readonly model: string;
|
|
14
|
+
readonly status: JobStatus["status"];
|
|
15
|
+
readonly createdAt: string;
|
|
16
|
+
readonly updatedAt: string;
|
|
17
|
+
readonly completedAt?: string;
|
|
18
|
+
readonly timeout?: number;
|
|
19
|
+
readonly error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create job metadata
|
|
24
|
+
*/
|
|
25
|
+
export function createJobMetadata(
|
|
26
|
+
requestId: string,
|
|
27
|
+
model: string,
|
|
28
|
+
timeout?: number
|
|
29
|
+
): FalJobMetadata {
|
|
30
|
+
const now = new Date().toISOString();
|
|
31
|
+
return {
|
|
32
|
+
requestId,
|
|
33
|
+
model,
|
|
34
|
+
status: "IN_QUEUE",
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
timeout,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Update job metadata status
|
|
43
|
+
*/
|
|
44
|
+
export function updateJobMetadata(
|
|
45
|
+
metadata: FalJobMetadata,
|
|
46
|
+
status: JobStatus["status"],
|
|
47
|
+
error?: string
|
|
48
|
+
): FalJobMetadata {
|
|
49
|
+
return {
|
|
50
|
+
...metadata,
|
|
51
|
+
status,
|
|
52
|
+
updatedAt: new Date().toISOString(),
|
|
53
|
+
...(status === "COMPLETED" || status === "FAILED" ? { completedAt: new Date().toISOString() } : {}),
|
|
54
|
+
...(error ? { error } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if job is completed (success or failure)
|
|
60
|
+
*/
|
|
61
|
+
export function isJobCompleted(metadata: FalJobMetadata): boolean {
|
|
62
|
+
return metadata.status === "COMPLETED" || metadata.status === "FAILED";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if job is running
|
|
67
|
+
*/
|
|
68
|
+
export function isJobRunning(metadata: FalJobMetadata): boolean {
|
|
69
|
+
return metadata.status === "IN_QUEUE" || metadata.status === "IN_PROGRESS";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if job is stale (older than specified minutes)
|
|
74
|
+
*/
|
|
75
|
+
export function isJobStale(metadata: FalJobMetadata, maxAgeMinutes: number = 60): boolean {
|
|
76
|
+
const age = Date.now() - new Date(metadata.createdAt).getTime();
|
|
77
|
+
const maxAgeMs = maxAgeMinutes * 60 * 1000;
|
|
78
|
+
return age > maxAgeMs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get job duration in milliseconds
|
|
83
|
+
*/
|
|
84
|
+
export function getJobDuration(metadata: FalJobMetadata): number | null {
|
|
85
|
+
if (!metadata.completedAt) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return new Date(metadata.completedAt).getTime() - new Date(metadata.createdAt).getTime();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format job duration for display
|
|
93
|
+
*/
|
|
94
|
+
export function formatJobDuration(metadata: FalJobMetadata): string {
|
|
95
|
+
const duration = getJobDuration(metadata);
|
|
96
|
+
if (!duration) {
|
|
97
|
+
return "In progress";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const seconds = Math.floor(duration / 1000);
|
|
101
|
+
if (seconds < 60) {
|
|
102
|
+
return `${seconds}s`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const minutes = Math.floor(seconds / 60);
|
|
106
|
+
const remainingSeconds = seconds % 60;
|
|
107
|
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Calculate job progress percentage
|
|
112
|
+
*/
|
|
113
|
+
export function calculateJobProgress(metadata: FalJobMetadata): number {
|
|
114
|
+
switch (metadata.status) {
|
|
115
|
+
case "IN_QUEUE":
|
|
116
|
+
return 10;
|
|
117
|
+
case "IN_PROGRESS":
|
|
118
|
+
return 50;
|
|
119
|
+
case "COMPLETED":
|
|
120
|
+
return 100;
|
|
121
|
+
case "FAILED":
|
|
122
|
+
return 0;
|
|
123
|
+
default:
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Serialize job metadata for storage
|
|
130
|
+
*/
|
|
131
|
+
export function serializeJobMetadata(metadata: FalJobMetadata): string {
|
|
132
|
+
return JSON.stringify(metadata);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Deserialize job metadata from storage
|
|
137
|
+
*/
|
|
138
|
+
export function deserializeJobMetadata(data: string): FalJobMetadata | null {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(data) as FalJobMetadata;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Filter valid job metadata from array
|
|
148
|
+
*/
|
|
149
|
+
export function filterValidJobs(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
150
|
+
return jobs.filter((job) => !isJobStale(job));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Sort jobs by creation time (newest first)
|
|
155
|
+
*/
|
|
156
|
+
export function sortJobsByCreation(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
157
|
+
return [...jobs].sort((a, b) => {
|
|
158
|
+
const timeA = new Date(a.createdAt).getTime();
|
|
159
|
+
const timeB = new Date(b.createdAt).getTime();
|
|
160
|
+
return timeB - timeA;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get active jobs (not completed and not stale)
|
|
166
|
+
*/
|
|
167
|
+
export function getActiveJobs(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
168
|
+
return jobs.filter((job) => isJobRunning(job) && !isJobStale(job));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get completed jobs
|
|
173
|
+
*/
|
|
174
|
+
export function getCompletedJobs(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
175
|
+
return jobs.filter((job) => isJobCompleted(job));
|
|
176
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Storage Utilities
|
|
3
|
+
* Helper functions for job persistence and storage integration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FalJobMetadata } from "./job-metadata.util";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generic storage interface for job persistence
|
|
10
|
+
* Implement this interface with your preferred storage backend
|
|
11
|
+
*/
|
|
12
|
+
export interface IJobStorage {
|
|
13
|
+
setItem(key: string, value: string): Promise<void>;
|
|
14
|
+
getItem(key: string): Promise<string | null>;
|
|
15
|
+
removeItem(key: string): Promise<void>;
|
|
16
|
+
getAllKeys?(): Promise<readonly string[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Save job metadata to storage
|
|
21
|
+
*/
|
|
22
|
+
export async function saveJobMetadata(
|
|
23
|
+
storage: IJobStorage,
|
|
24
|
+
metadata: FalJobMetadata
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const key = `fal_job:${metadata.requestId}`;
|
|
27
|
+
const value = JSON.stringify(metadata);
|
|
28
|
+
await storage.setItem(key, value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load job metadata from storage
|
|
33
|
+
*/
|
|
34
|
+
export async function loadJobMetadata(
|
|
35
|
+
storage: IJobStorage,
|
|
36
|
+
requestId: string
|
|
37
|
+
): Promise<FalJobMetadata | null> {
|
|
38
|
+
const key = `fal_job:${requestId}`;
|
|
39
|
+
const value = await storage.getItem(key);
|
|
40
|
+
if (!value) return null;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(value) as FalJobMetadata;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Delete job metadata from storage
|
|
51
|
+
*/
|
|
52
|
+
export async function deleteJobMetadata(
|
|
53
|
+
storage: IJobStorage,
|
|
54
|
+
requestId: string
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const key = `fal_job:${requestId}`;
|
|
57
|
+
await storage.removeItem(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load all jobs from storage
|
|
62
|
+
*/
|
|
63
|
+
export async function loadAllJobs(
|
|
64
|
+
storage: IJobStorage
|
|
65
|
+
): Promise<FalJobMetadata[]> {
|
|
66
|
+
if (!storage.getAllKeys) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const keys = await storage.getAllKeys();
|
|
71
|
+
const jobKeys = keys.filter((key) => key.startsWith("fal_job:"));
|
|
72
|
+
|
|
73
|
+
const jobs: FalJobMetadata[] = [];
|
|
74
|
+
for (const key of jobKeys) {
|
|
75
|
+
const value = await storage.getItem(key);
|
|
76
|
+
if (value) {
|
|
77
|
+
try {
|
|
78
|
+
const metadata = JSON.parse(value) as FalJobMetadata;
|
|
79
|
+
jobs.push(metadata);
|
|
80
|
+
} catch {
|
|
81
|
+
// Skip invalid entries
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return jobs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clean up old jobs from storage
|
|
92
|
+
*/
|
|
93
|
+
export async function cleanupOldJobs(
|
|
94
|
+
storage: IJobStorage,
|
|
95
|
+
maxAgeMinutes: number = 60
|
|
96
|
+
): Promise<number> {
|
|
97
|
+
const jobs = await loadAllJobs(storage);
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const maxAgeMs = maxAgeMinutes * 60 * 1000;
|
|
100
|
+
let cleanedCount = 0;
|
|
101
|
+
|
|
102
|
+
for (const job of jobs) {
|
|
103
|
+
const jobAge = now - new Date(job.createdAt).getTime();
|
|
104
|
+
if (jobAge > maxAgeMs) {
|
|
105
|
+
await deleteJobMetadata(storage, job.requestId);
|
|
106
|
+
cleanedCount++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return cleanedCount;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get jobs by model
|
|
115
|
+
*/
|
|
116
|
+
export async function getJobsByModel(
|
|
117
|
+
storage: IJobStorage,
|
|
118
|
+
model: string
|
|
119
|
+
): Promise<FalJobMetadata[]> {
|
|
120
|
+
const jobs = await loadAllJobs(storage);
|
|
121
|
+
return jobs.filter((job) => job.model === model);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get jobs by status
|
|
126
|
+
*/
|
|
127
|
+
export async function getJobsByStatus(
|
|
128
|
+
storage: IJobStorage,
|
|
129
|
+
status: FalJobMetadata["status"]
|
|
130
|
+
): Promise<FalJobMetadata[]> {
|
|
131
|
+
const jobs = await loadAllJobs(storage);
|
|
132
|
+
return jobs.filter((job) => job.status === status);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Update job status in storage
|
|
137
|
+
*/
|
|
138
|
+
export async function updateJobStatus(
|
|
139
|
+
storage: IJobStorage,
|
|
140
|
+
requestId: string,
|
|
141
|
+
status: FalJobMetadata["status"],
|
|
142
|
+
error?: string
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const metadata = await loadJobMetadata(storage, requestId);
|
|
145
|
+
if (!metadata) {
|
|
146
|
+
throw new Error(`Job not found: ${requestId}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const updated: FalJobMetadata = {
|
|
150
|
+
...metadata,
|
|
151
|
+
status,
|
|
152
|
+
updatedAt: new Date().toISOString(),
|
|
153
|
+
...(status === "COMPLETED" || status === "FAILED" ? { completedAt: new Date().toISOString() } : {}),
|
|
154
|
+
...(error ? { error } : {}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await saveJobMetadata(storage, updated);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a simple in-memory storage for testing
|
|
162
|
+
*/
|
|
163
|
+
export class InMemoryJobStorage implements IJobStorage {
|
|
164
|
+
private store = new Map<string, string>();
|
|
165
|
+
|
|
166
|
+
setItem(key: string, value: string): Promise<void> {
|
|
167
|
+
this.store.set(key, value);
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getItem(key: string): Promise<string | null> {
|
|
172
|
+
return Promise.resolve(this.store.get(key) ?? null);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
removeItem(key: string): Promise<void> {
|
|
176
|
+
this.store.delete(key);
|
|
177
|
+
return Promise.resolve();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getAllKeys(): Promise<readonly string[]> {
|
|
181
|
+
return Promise.resolve(Array.from(this.store.keys()));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
clear(): void {
|
|
185
|
+
this.store.clear();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -20,8 +20,11 @@ export interface UseFalGenerationResult<T> {
|
|
|
20
20
|
error: FalErrorInfo | null;
|
|
21
21
|
isLoading: boolean;
|
|
22
22
|
isRetryable: boolean;
|
|
23
|
+
requestId: string | null;
|
|
24
|
+
isCancelling: boolean;
|
|
23
25
|
generate: (modelEndpoint: string, input: FalJobInput) => Promise<T | null>;
|
|
24
26
|
retry: () => Promise<T | null>;
|
|
27
|
+
cancel: () => void;
|
|
25
28
|
reset: () => void;
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -31,8 +34,11 @@ export function useFalGeneration<T = unknown>(
|
|
|
31
34
|
const [data, setData] = useState<T | null>(null);
|
|
32
35
|
const [error, setError] = useState<FalErrorInfo | null>(null);
|
|
33
36
|
const [isLoading, setIsLoading] = useState(false);
|
|
37
|
+
const [isCancelling, setIsCancelling] = useState(false);
|
|
34
38
|
|
|
35
39
|
const lastRequestRef = useRef<{ endpoint: string; input: FalJobInput } | null>(null);
|
|
40
|
+
const currentRequestIdRef = useRef<string | null>(null);
|
|
41
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
36
42
|
|
|
37
43
|
const generate = useCallback(
|
|
38
44
|
async (modelEndpoint: string, input: FalJobInput): Promise<T | null> => {
|
|
@@ -40,15 +46,23 @@ export function useFalGeneration<T = unknown>(
|
|
|
40
46
|
setIsLoading(true);
|
|
41
47
|
setError(null);
|
|
42
48
|
setData(null);
|
|
49
|
+
currentRequestIdRef.current = null;
|
|
50
|
+
setIsCancelling(false);
|
|
51
|
+
|
|
52
|
+
// Create abort controller for this request
|
|
53
|
+
abortControllerRef.current = new AbortController();
|
|
43
54
|
|
|
44
55
|
try {
|
|
45
56
|
const result = await falProvider.subscribe<T>(modelEndpoint, input, {
|
|
46
57
|
timeoutMs: options?.timeoutMs,
|
|
47
58
|
onQueueUpdate: (status) => {
|
|
59
|
+
// Note: requestId is tracked internally by falProvider subscribe
|
|
60
|
+
// and exposed via the requestId ref, not from status object
|
|
61
|
+
const currentRequestId = currentRequestIdRef.current ?? "";
|
|
48
62
|
// Map JobStatus to FalQueueStatus for backward compatibility
|
|
49
63
|
options?.onProgress?.({
|
|
50
64
|
status: status.status,
|
|
51
|
-
requestId:
|
|
65
|
+
requestId: currentRequestId,
|
|
52
66
|
logs: status.logs?.map((log: FalLogEntry) => ({
|
|
53
67
|
message: log.message,
|
|
54
68
|
level: log.level,
|
|
@@ -68,6 +82,8 @@ export function useFalGeneration<T = unknown>(
|
|
|
68
82
|
return null;
|
|
69
83
|
} finally {
|
|
70
84
|
setIsLoading(false);
|
|
85
|
+
setIsCancelling(false);
|
|
86
|
+
abortControllerRef.current = null;
|
|
71
87
|
}
|
|
72
88
|
},
|
|
73
89
|
[options]
|
|
@@ -79,20 +95,36 @@ export function useFalGeneration<T = unknown>(
|
|
|
79
95
|
return generate(endpoint, input);
|
|
80
96
|
}, [generate]);
|
|
81
97
|
|
|
98
|
+
const cancel = useCallback(() => {
|
|
99
|
+
if (abortControllerRef.current) {
|
|
100
|
+
setIsCancelling(true);
|
|
101
|
+
abortControllerRef.current.abort();
|
|
102
|
+
abortControllerRef.current = null;
|
|
103
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
104
|
+
console.log("[useFalGeneration] Request cancelled");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
82
109
|
const reset = useCallback(() => {
|
|
110
|
+
cancel();
|
|
83
111
|
setData(null);
|
|
84
112
|
setError(null);
|
|
85
113
|
setIsLoading(false);
|
|
86
114
|
lastRequestRef.current = null;
|
|
87
|
-
|
|
115
|
+
currentRequestIdRef.current = null;
|
|
116
|
+
}, [cancel]);
|
|
88
117
|
|
|
89
118
|
return {
|
|
90
119
|
data,
|
|
91
120
|
error,
|
|
92
121
|
isLoading,
|
|
93
122
|
isRetryable: error ? isFalErrorRetryable(error.originalError) : false,
|
|
123
|
+
requestId: currentRequestIdRef.current,
|
|
124
|
+
isCancelling,
|
|
94
125
|
generate,
|
|
95
126
|
retry,
|
|
127
|
+
cancel,
|
|
96
128
|
reset,
|
|
97
129
|
};
|
|
98
130
|
}
|