@umituz/react-native-ai-fal-provider 2.0.19 → 2.0.21
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 +6 -6
- package/src/domain/constants/default-models.constants.ts +8 -2
- package/src/infrastructure/services/fal-provider-subscription.ts +6 -1
- package/src/infrastructure/services/fal-provider.ts +1 -0
- package/src/infrastructure/services/request-store.ts +5 -9
- package/src/infrastructure/utils/collection-filters.util.ts +171 -0
- package/src/infrastructure/utils/cost-tracker.ts +7 -6
- package/src/infrastructure/utils/fal-storage.util.ts +4 -2
- package/src/infrastructure/utils/image-helpers.util.ts +11 -1
- package/src/infrastructure/utils/input-preprocessor.util.ts +13 -1
- package/src/infrastructure/utils/job-metadata/job-metadata-queries.util.ts +16 -2
- package/src/infrastructure/utils/prompt-helpers.util.ts +8 -2
- package/src/infrastructure/utils/type-guards.util.ts +5 -6
- package/src/presentation/hooks/use-fal-generation.ts +22 -3
- package/src/presentation/hooks/use-models.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-fal-provider",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.21",
|
|
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",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@fal-ai/client": ">=0.6.0",
|
|
31
|
-
"react": ">=
|
|
32
|
-
"react-native": ">=0.
|
|
31
|
+
"react": ">=19.0.0",
|
|
32
|
+
"react-native": ">=0.81.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@fal-ai/client": ">=0.6.0"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@tanstack/query-async-storage-persister": "^5.66.7",
|
|
47
47
|
"@tanstack/react-query": "^5.66.7",
|
|
48
48
|
"@tanstack/react-query-persist-client": "^5.66.7",
|
|
49
|
-
"@types/react": "~
|
|
49
|
+
"@types/react": "~19.1.0",
|
|
50
50
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
51
51
|
"@typescript-eslint/parser": "^7.0.0",
|
|
52
52
|
"@umituz/react-native-auth": "*",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"expo-video": "^3.0.15",
|
|
78
78
|
"expo-web-browser": "^12.0.0",
|
|
79
79
|
"firebase": "^12.7.0",
|
|
80
|
-
"react": "
|
|
81
|
-
"react-native": "0.
|
|
80
|
+
"react": "19.1.0",
|
|
81
|
+
"react-native": "0.81.5",
|
|
82
82
|
"react-native-gesture-handler": "^2.30.0",
|
|
83
83
|
"react-native-purchases": "^9.7.5",
|
|
84
84
|
"react-native-reanimated": "^4.2.1",
|
|
@@ -80,10 +80,16 @@ export function getDefaultModelsByType(type: FalModelType): FalModelConfig[] {
|
|
|
80
80
|
return DEFAULT_IMAGE_TO_VIDEO_MODELS;
|
|
81
81
|
case "text-to-text":
|
|
82
82
|
return DEFAULT_TEXT_TO_TEXT_MODELS;
|
|
83
|
-
case "image-to-image":
|
|
83
|
+
case "image-to-image": {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.warn('Model type "image-to-image" not supported yet');
|
|
84
86
|
return [];
|
|
85
|
-
|
|
87
|
+
}
|
|
88
|
+
default: {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn('Unknown model type:', type);
|
|
86
91
|
return [];
|
|
92
|
+
}
|
|
87
93
|
}
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -123,9 +123,14 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const userMessage = parseFalError(error);
|
|
126
|
+
if (!userMessage || userMessage.trim().length === 0) {
|
|
127
|
+
throw new Error("An unknown error occurred. Please try again.");
|
|
128
|
+
}
|
|
126
129
|
throw new Error(userMessage);
|
|
127
130
|
} finally {
|
|
128
|
-
if (timeoutId)
|
|
131
|
+
if (timeoutId) {
|
|
132
|
+
clearTimeout(timeoutId);
|
|
133
|
+
}
|
|
129
134
|
if (listenerAdded && abortHandler && signal) {
|
|
130
135
|
signal.removeEventListener("abort", abortHandler);
|
|
131
136
|
}
|
|
@@ -134,6 +134,7 @@ export class FalProvider implements IAIProvider {
|
|
|
134
134
|
getRequestId: (res) => res.requestId ?? undefined,
|
|
135
135
|
}).then((res) => res.result).finally(() => removeRequest(key));
|
|
136
136
|
|
|
137
|
+
// Store promise immediately to prevent race condition
|
|
137
138
|
storeRequest(key, { promise, abortController });
|
|
138
139
|
return promise;
|
|
139
140
|
}
|
|
@@ -19,10 +19,8 @@ export function getRequestStore(): RequestStore {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Create a
|
|
23
|
-
*
|
|
24
|
-
* - Input hash (for quick comparison)
|
|
25
|
-
* - Unique ID (guarantees uniqueness)
|
|
22
|
+
* Create a deterministic request key using model and input hash
|
|
23
|
+
* Same model + input will always produce the same key for deduplication
|
|
26
24
|
*/
|
|
27
25
|
export function createRequestKey(model: string, input: Record<string, unknown>): string {
|
|
28
26
|
const inputStr = JSON.stringify(input, Object.keys(input).sort());
|
|
@@ -32,11 +30,9 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
|
|
|
32
30
|
const char = inputStr.charCodeAt(i);
|
|
33
31
|
hash = ((hash << 5) - hash + char) | 0;
|
|
34
32
|
}
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
39
|
-
return `${model}:${hash.toString(36)}:${uniqueId}`;
|
|
33
|
+
// Return deterministic key without unique ID
|
|
34
|
+
// This allows proper deduplication: same model + input = same key
|
|
35
|
+
return `${model}:${hash.toString(36)}`;
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection Filter Utilities
|
|
3
|
+
* Common filter operations for arrays of objects
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filter array by property value
|
|
8
|
+
*/
|
|
9
|
+
export function filterByProperty<T>(
|
|
10
|
+
items: readonly T[],
|
|
11
|
+
property: keyof T,
|
|
12
|
+
value: unknown
|
|
13
|
+
): T[] {
|
|
14
|
+
return items.filter((item) => item[property] === value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Filter array by predicate function
|
|
19
|
+
*/
|
|
20
|
+
export function filterByPredicate<T>(
|
|
21
|
+
items: readonly T[],
|
|
22
|
+
predicate: (item: T) => boolean
|
|
23
|
+
): T[] {
|
|
24
|
+
return items.filter(predicate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filter array by time range (timestamp property)
|
|
29
|
+
*/
|
|
30
|
+
export function filterByTimeRange<T>(
|
|
31
|
+
items: readonly T[],
|
|
32
|
+
timestampProperty: keyof T,
|
|
33
|
+
startTime: number,
|
|
34
|
+
endTime: number
|
|
35
|
+
): T[] {
|
|
36
|
+
return items.filter((item) => {
|
|
37
|
+
const timestamp = item[timestampProperty] as unknown as number;
|
|
38
|
+
return timestamp >= startTime && timestamp <= endTime;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filter array by multiple property values (OR logic)
|
|
44
|
+
*/
|
|
45
|
+
export function filterByAnyProperty<T>(
|
|
46
|
+
items: readonly T[],
|
|
47
|
+
property: keyof T,
|
|
48
|
+
values: readonly unknown[]
|
|
49
|
+
): T[] {
|
|
50
|
+
const valueSet = new Set(values);
|
|
51
|
+
return items.filter((item) => valueSet.has(item[property]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sort array by date property (descending - newest first)
|
|
56
|
+
*/
|
|
57
|
+
export function sortByDateDescending<T>(
|
|
58
|
+
items: readonly T[],
|
|
59
|
+
dateProperty: keyof T
|
|
60
|
+
): T[] {
|
|
61
|
+
return [...items].sort((a, b) => {
|
|
62
|
+
const timeA = new Date(a[dateProperty] as unknown as string).getTime();
|
|
63
|
+
const timeB = new Date(b[dateProperty] as unknown as string).getTime();
|
|
64
|
+
return timeB - timeA;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sort array by date property (ascending - oldest first)
|
|
70
|
+
*/
|
|
71
|
+
export function sortByDateAscending<T>(
|
|
72
|
+
items: readonly T[],
|
|
73
|
+
dateProperty: keyof T
|
|
74
|
+
): T[] {
|
|
75
|
+
return [...items].sort((a, b) => {
|
|
76
|
+
const timeA = new Date(a[dateProperty] as unknown as string).getTime();
|
|
77
|
+
const timeB = new Date(b[dateProperty] as unknown as string).getTime();
|
|
78
|
+
return timeA - timeB;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sort array by number property (descending)
|
|
84
|
+
*/
|
|
85
|
+
export function sortByNumberDescending<T>(
|
|
86
|
+
items: readonly T[],
|
|
87
|
+
numberProperty: keyof T
|
|
88
|
+
): T[] {
|
|
89
|
+
return [...items].sort((a, b) => {
|
|
90
|
+
const numA = a[numberProperty] as unknown as number;
|
|
91
|
+
const numB = b[numberProperty] as unknown as number;
|
|
92
|
+
return numB - numA;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sort array by number property (ascending)
|
|
98
|
+
*/
|
|
99
|
+
export function sortByNumberAscending<T>(
|
|
100
|
+
items: readonly T[],
|
|
101
|
+
numberProperty: keyof T
|
|
102
|
+
): T[] {
|
|
103
|
+
return [...items].sort((a, b) => {
|
|
104
|
+
const numA = a[numberProperty] as unknown as number;
|
|
105
|
+
const numB = b[numberProperty] as unknown as number;
|
|
106
|
+
return numA - numB;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reduce array to sum of number property
|
|
112
|
+
*/
|
|
113
|
+
export function sumByProperty<T>(
|
|
114
|
+
items: readonly T[],
|
|
115
|
+
numberProperty: keyof T
|
|
116
|
+
): number {
|
|
117
|
+
return items.reduce((sum, item) => {
|
|
118
|
+
const value = item[numberProperty] as unknown as number;
|
|
119
|
+
return sum + (typeof value === "number" ? value : 0);
|
|
120
|
+
}, 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Group array by property value
|
|
125
|
+
*/
|
|
126
|
+
export function groupByProperty<T>(
|
|
127
|
+
items: readonly T[],
|
|
128
|
+
property: keyof T
|
|
129
|
+
): Map<unknown, T[]> {
|
|
130
|
+
const groups = new Map<unknown, T[]>();
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const key = item[property];
|
|
133
|
+
const existing = groups.get(key);
|
|
134
|
+
if (existing) {
|
|
135
|
+
existing.push(item);
|
|
136
|
+
} else {
|
|
137
|
+
groups.set(key, [item]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return groups;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Chunk array into smaller arrays of specified size
|
|
145
|
+
*/
|
|
146
|
+
export function chunkArray<T>(items: readonly T[], chunkSize: number): T[][] {
|
|
147
|
+
const result: T[][] = [];
|
|
148
|
+
for (let i = 0; i < items.length; i += chunkSize) {
|
|
149
|
+
result.push(items.slice(i, i + chunkSize) as T[]);
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get distinct values of a property from array
|
|
156
|
+
*/
|
|
157
|
+
export function distinctByProperty<T>(
|
|
158
|
+
items: readonly T[],
|
|
159
|
+
property: keyof T
|
|
160
|
+
): unknown[] {
|
|
161
|
+
const seen = new Set<unknown>();
|
|
162
|
+
const result: unknown[] = [];
|
|
163
|
+
for (const item of items) {
|
|
164
|
+
const value = item[property];
|
|
165
|
+
if (!seen.has(value)) {
|
|
166
|
+
seen.add(value);
|
|
167
|
+
result.push(value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
@@ -45,7 +45,6 @@ export class CostTracker {
|
|
|
45
45
|
private config: Required<CostTrackerConfig>;
|
|
46
46
|
private costHistory: GenerationCost[] = [];
|
|
47
47
|
private currentOperationCosts: Map<string, number> = new Map();
|
|
48
|
-
private operationCounter = 0;
|
|
49
48
|
|
|
50
49
|
constructor(config?: CostTrackerConfig) {
|
|
51
50
|
this.config = {
|
|
@@ -84,11 +83,14 @@ export class CostTracker {
|
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
startOperation(modelId: string, operation: string): string {
|
|
87
|
-
// Use
|
|
88
|
-
const
|
|
86
|
+
// Use crypto.randomUUID() for guaranteed uniqueness without overflow
|
|
87
|
+
const uniqueId = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
88
|
+
? crypto.randomUUID()
|
|
89
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${operation}`;
|
|
90
|
+
|
|
89
91
|
const estimatedCost = this.calculateEstimatedCost(modelId);
|
|
90
92
|
|
|
91
|
-
this.currentOperationCosts.set(
|
|
93
|
+
this.currentOperationCosts.set(uniqueId, estimatedCost);
|
|
92
94
|
|
|
93
95
|
if (this.config.trackEstimatedCost) {
|
|
94
96
|
const cost: GenerationCost = {
|
|
@@ -104,7 +106,7 @@ export class CostTracker {
|
|
|
104
106
|
this.config.onCostUpdate(cost);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
return
|
|
109
|
+
return uniqueId;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
completeOperation(
|
|
@@ -156,7 +158,6 @@ export class CostTracker {
|
|
|
156
158
|
clearHistory(): void {
|
|
157
159
|
this.costHistory = [];
|
|
158
160
|
this.currentOperationCosts.clear();
|
|
159
|
-
this.operationCounter = 0;
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
getCostsByModel(modelId: string): GenerationCost[] {
|
|
@@ -27,8 +27,10 @@ export async function uploadToFalStorage(base64: string): Promise<string> {
|
|
|
27
27
|
try {
|
|
28
28
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
29
29
|
await deleteTempFile(tempUri);
|
|
30
|
-
} catch {
|
|
31
|
-
//
|
|
30
|
+
} catch (cleanupError) {
|
|
31
|
+
// Log cleanup failure but don't throw
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.warn(`Failed to cleanup temp file ${tempUri}:`, cleanupError);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -20,8 +20,18 @@ export function extractBase64(dataUri: string): string {
|
|
|
20
20
|
if (!dataUri.startsWith("data:")) {
|
|
21
21
|
return dataUri;
|
|
22
22
|
}
|
|
23
|
+
|
|
23
24
|
const parts = dataUri.split(",");
|
|
24
|
-
|
|
25
|
+
if (parts.length < 2) {
|
|
26
|
+
throw new Error(`Invalid data URI format: ${dataUri}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const base64Part = parts[1];
|
|
30
|
+
if (!base64Part || base64Part.length === 0) {
|
|
31
|
+
throw new Error(`Empty base64 data in URI: ${dataUri}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return base64Part;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -51,9 +51,16 @@ export async function preprocessInput(
|
|
|
51
51
|
if (Array.isArray(result.image_urls) && result.image_urls.length > 0) {
|
|
52
52
|
const imageUrls = result.image_urls as unknown[];
|
|
53
53
|
const processedUrls: string[] = [];
|
|
54
|
+
const errors: string[] = [];
|
|
54
55
|
|
|
55
56
|
for (let i = 0; i < imageUrls.length; i++) {
|
|
56
57
|
const imageUrl = imageUrls[i];
|
|
58
|
+
|
|
59
|
+
if (!imageUrl) {
|
|
60
|
+
errors.push(`image_urls[${i}] is null or undefined`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
if (isBase64DataUri(imageUrl)) {
|
|
58
65
|
const index = i;
|
|
59
66
|
const uploadPromise = uploadToFalStorage(imageUrl)
|
|
@@ -61,6 +68,7 @@ export async function preprocessInput(
|
|
|
61
68
|
processedUrls[index] = url;
|
|
62
69
|
})
|
|
63
70
|
.catch((error) => {
|
|
71
|
+
errors.push(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
64
72
|
throw new Error(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
65
73
|
});
|
|
66
74
|
|
|
@@ -68,10 +76,14 @@ export async function preprocessInput(
|
|
|
68
76
|
} else if (typeof imageUrl === "string") {
|
|
69
77
|
processedUrls[i] = imageUrl;
|
|
70
78
|
} else {
|
|
71
|
-
|
|
79
|
+
errors.push(`image_urls[${i}] has invalid type: ${typeof imageUrl}`);
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
if (errors.length > 0) {
|
|
84
|
+
throw new Error(`Image URL validation failed:\n${errors.join('\n')}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
result.image_urls = processedUrls;
|
|
76
88
|
}
|
|
77
89
|
|
|
@@ -18,8 +18,22 @@ export function serializeJobMetadata(metadata: FalJobMetadata): string {
|
|
|
18
18
|
*/
|
|
19
19
|
export function deserializeJobMetadata(data: string): FalJobMetadata | null {
|
|
20
20
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const parsed = JSON.parse(data) as Record<string, unknown>;
|
|
22
|
+
// Validate structure
|
|
23
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.warn('Invalid job metadata: not an object', data);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (!parsed.requestId || !parsed.model || !parsed.status) {
|
|
29
|
+
// eslint-disable-next-line no-console
|
|
30
|
+
console.warn('Invalid job metadata: missing required fields', data);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return parsed as unknown as FalJobMetadata;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.error('Failed to deserialize job metadata:', error, 'Data:', data);
|
|
23
37
|
return null;
|
|
24
38
|
}
|
|
25
39
|
}
|
|
@@ -14,8 +14,14 @@ export function truncatePrompt(prompt: string, maxLength: number = 5000): string
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Sanitize prompt by removing excessive whitespace
|
|
17
|
+
* Sanitize prompt by removing excessive whitespace and control characters
|
|
18
18
|
*/
|
|
19
19
|
export function sanitizePrompt(prompt: string): string {
|
|
20
|
-
return prompt
|
|
20
|
+
return prompt
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
// Remove control characters except tab, newline, carriage return
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '')
|
|
26
|
+
.slice(0, 5000);
|
|
21
27
|
}
|
|
@@ -87,15 +87,14 @@ export function isValidApiKey(value: unknown): boolean {
|
|
|
87
87
|
/**
|
|
88
88
|
* Validate model ID format
|
|
89
89
|
*/
|
|
90
|
+
const MODEL_ID_PATTERN = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
|
|
91
|
+
|
|
90
92
|
export function isValidModelId(value: unknown): boolean {
|
|
91
93
|
if (typeof value !== "string") {
|
|
92
94
|
return false;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
// Allow uppercase, dots, underscores, hyphens
|
|
97
|
-
const modelIdPattern = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
|
|
98
|
-
return modelIdPattern.test(value) && value.length >= 3;
|
|
97
|
+
return MODEL_ID_PATTERN.test(value) && value.length >= 3;
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
/**
|
|
@@ -109,12 +108,12 @@ export function isValidPrompt(value: unknown): boolean {
|
|
|
109
108
|
* Validate timeout value
|
|
110
109
|
*/
|
|
111
110
|
export function isValidTimeout(value: unknown): boolean {
|
|
112
|
-
return typeof value === "number" && value > 0 && value <= 600000; // Max 10 minutes
|
|
111
|
+
return typeof value === "number" && !isNaN(value) && isFinite(value) && value > 0 && value <= 600000; // Max 10 minutes
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
/**
|
|
116
115
|
* Validate retry count
|
|
117
116
|
*/
|
|
118
117
|
export function isValidRetryCount(value: unknown): boolean {
|
|
119
|
-
return typeof value === "number" && value >= 0 && value <= 10;
|
|
118
|
+
return typeof value === "number" && !isNaN(value) && isFinite(value) && Number.isInteger(value) && value >= 0 && value <= 10;
|
|
120
119
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* React hook for FAL AI generation operations
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useState, useCallback, useRef } from "react";
|
|
6
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
7
7
|
import { falProvider } from "../../infrastructure/services/fal-provider";
|
|
8
8
|
import { mapFalError } from "../../infrastructure/utils/error-mapper";
|
|
9
9
|
import type { FalJobInput, FalQueueStatus, FalLogEntry } from "../../domain/entities/fal.types";
|
|
@@ -38,9 +38,23 @@ export function useFalGeneration<T = unknown>(
|
|
|
38
38
|
|
|
39
39
|
const lastRequestRef = useRef<{ endpoint: string; input: FalJobInput } | null>(null);
|
|
40
40
|
const currentRequestIdRef = useRef<string | null>(null);
|
|
41
|
+
const isMountedRef = useRef(true);
|
|
42
|
+
|
|
43
|
+
// Cleanup on unmount
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
isMountedRef.current = true;
|
|
46
|
+
return () => {
|
|
47
|
+
isMountedRef.current = false;
|
|
48
|
+
if (falProvider.hasRunningRequest()) {
|
|
49
|
+
falProvider.cancelCurrentRequest();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
41
53
|
|
|
42
54
|
const generate = useCallback(
|
|
43
55
|
async (modelEndpoint: string, input: FalJobInput): Promise<T | null> => {
|
|
56
|
+
if (!isMountedRef.current) return null;
|
|
57
|
+
|
|
44
58
|
lastRequestRef.current = { endpoint: modelEndpoint, input };
|
|
45
59
|
setIsLoading(true);
|
|
46
60
|
setError(null);
|
|
@@ -52,6 +66,7 @@ export function useFalGeneration<T = unknown>(
|
|
|
52
66
|
const result = await falProvider.subscribe<T>(modelEndpoint, input, {
|
|
53
67
|
timeoutMs: options?.timeoutMs,
|
|
54
68
|
onQueueUpdate: (status) => {
|
|
69
|
+
if (!isMountedRef.current) return;
|
|
55
70
|
if (status.requestId) {
|
|
56
71
|
currentRequestIdRef.current = status.requestId;
|
|
57
72
|
}
|
|
@@ -68,16 +83,20 @@ export function useFalGeneration<T = unknown>(
|
|
|
68
83
|
},
|
|
69
84
|
});
|
|
70
85
|
|
|
86
|
+
if (!isMountedRef.current) return null;
|
|
71
87
|
setData(result);
|
|
72
88
|
return result;
|
|
73
89
|
} catch (err) {
|
|
90
|
+
if (!isMountedRef.current) return null;
|
|
74
91
|
const errorInfo = mapFalError(err);
|
|
75
92
|
setError(errorInfo);
|
|
76
93
|
options?.onError?.(errorInfo);
|
|
77
94
|
return null;
|
|
78
95
|
} finally {
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
if (isMountedRef.current) {
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
setIsCancelling(false);
|
|
99
|
+
}
|
|
81
100
|
}
|
|
82
101
|
},
|
|
83
102
|
[options]
|
|
@@ -68,6 +68,9 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
|
|
|
68
68
|
const model = models.find((m) => m.id === modelId);
|
|
69
69
|
if (model) {
|
|
70
70
|
setSelectedModel(model);
|
|
71
|
+
} else {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.warn(`Model not found: ${modelId}. Available models:`, models.map(m => m.id));
|
|
71
74
|
}
|
|
72
75
|
},
|
|
73
76
|
[models],
|