@umituz/react-native-ai-pruna-provider 1.0.9 → 1.0.10
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/infrastructure/services/pruna-api-client.ts +47 -23
- package/src/infrastructure/services/pruna-input-builder.ts +6 -7
- package/src/infrastructure/services/pruna-provider-subscription.ts +5 -2
- package/src/infrastructure/services/pruna-provider.ts +16 -5
- package/src/infrastructure/services/request-store.ts +2 -2
- package/src/infrastructure/utils/constants/index.ts +1 -0
- package/src/infrastructure/utils/constants/mime.constants.ts +3 -1
- package/src/infrastructure/utils/mime-detection.util.ts +2 -1
- package/src/infrastructure/utils/pruna-error-handler.util.ts +5 -0
- package/src/init/createAiProviderInitModule.ts +3 -3
- package/src/presentation/hooks/use-pruna-generation.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-pruna-provider",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "Pruna AI provider for React Native - implements IAIProvider interface for unified AI generation",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { PrunaModelId, PrunaPredictionResponse, PrunaFileUploadResponse } from "../../domain/entities/pruna.types";
|
|
15
|
-
import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL } from "./pruna-provider.constants";
|
|
15
|
+
import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL, UPLOAD_CONFIG } from "./pruna-provider.constants";
|
|
16
16
|
import { generationLogCollector } from "../utils/log-collector";
|
|
17
17
|
import { detectMimeType } from "../utils/mime-detection.util";
|
|
18
18
|
import { getExtensionForMime } from "../utils/constants/mime.constants";
|
|
@@ -65,26 +65,41 @@ export async function uploadFileToStorage(
|
|
|
65
65
|
|
|
66
66
|
const startTime = Date.now();
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
body: formData,
|
|
72
|
-
});
|
|
68
|
+
// Apply timeout to prevent indefinite hangs
|
|
69
|
+
const uploadController = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => uploadController.abort(), UPLOAD_CONFIG.timeoutMs);
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(PRUNA_FILES_URL, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'apikey': apiKey },
|
|
76
|
+
body: formData,
|
|
77
|
+
signal: uploadController.signal,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const err = await response.json().catch(() => ({ message: response.statusText }));
|
|
82
|
+
const errorMessage = (err as { message?: string }).message || `File upload error: ${response.status}`;
|
|
83
|
+
generationLogCollector.error(sessionId, TAG, `File upload failed: ${errorMessage}`);
|
|
84
|
+
throw new Error(errorMessage);
|
|
85
|
+
}
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
const data: PrunaFileUploadResponse = await response.json();
|
|
88
|
+
const fileUrl = data.urls?.get || `${PRUNA_FILES_URL}/${data.id}`;
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
const elapsed = Date.now() - startTime;
|
|
91
|
+
generationLogCollector.log(sessionId, TAG, `File upload completed in ${elapsed}ms → ${fileUrl}`);
|
|
86
92
|
|
|
87
|
-
|
|
93
|
+
return fileUrl;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
96
|
+
generationLogCollector.error(sessionId, TAG, `File upload timed out after ${UPLOAD_CONFIG.timeoutMs}ms`);
|
|
97
|
+
throw new Error(`File upload timed out after ${UPLOAD_CONFIG.timeoutMs}ms`);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timeoutId);
|
|
102
|
+
}
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
/**
|
|
@@ -127,8 +142,14 @@ export async function submitPrediction(
|
|
|
127
142
|
});
|
|
128
143
|
|
|
129
144
|
if (!response.ok) {
|
|
130
|
-
const
|
|
131
|
-
|
|
145
|
+
const rawBody = await response.text().catch(() => '');
|
|
146
|
+
let errorMessage = `API error: ${response.status}`;
|
|
147
|
+
try {
|
|
148
|
+
const errObj = JSON.parse(rawBody) as Record<string, unknown>;
|
|
149
|
+
errorMessage = String(errObj.message || errObj.detail || errObj.error || rawBody) || errorMessage;
|
|
150
|
+
} catch {
|
|
151
|
+
if (rawBody) errorMessage = rawBody;
|
|
152
|
+
}
|
|
132
153
|
|
|
133
154
|
generationLogCollector.error(sessionId, TAG, `Prediction failed (${response.status}): ${errorMessage}`);
|
|
134
155
|
|
|
@@ -171,10 +192,13 @@ export async function pollForResult(
|
|
|
171
192
|
throw new Error("Request cancelled by user");
|
|
172
193
|
}
|
|
173
194
|
|
|
174
|
-
|
|
195
|
+
// Wait between polls — skip delay on first attempt for faster response
|
|
196
|
+
if (i > 0) {
|
|
197
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
175
198
|
|
|
176
|
-
|
|
177
|
-
|
|
199
|
+
if (signal?.aborted) {
|
|
200
|
+
throw new Error("Request cancelled by user");
|
|
201
|
+
}
|
|
178
202
|
}
|
|
179
203
|
|
|
180
204
|
try {
|
|
@@ -231,9 +255,9 @@ export function extractUri(data: PrunaPredictionResponse): string | null {
|
|
|
231
255
|
data.generation_url ||
|
|
232
256
|
(data.output && typeof data.output === 'object' && !Array.isArray(data.output) ? (data.output as { url: string }).url : null) ||
|
|
233
257
|
(typeof data.output === 'string' ? data.output : null) ||
|
|
234
|
-
data.data ||
|
|
235
258
|
data.video_url ||
|
|
236
259
|
(Array.isArray(data.output) ? data.output[0] : null) ||
|
|
260
|
+
data.data ||
|
|
237
261
|
null
|
|
238
262
|
);
|
|
239
263
|
}
|
|
@@ -68,11 +68,15 @@ function buildImageEditInput(
|
|
|
68
68
|
input: Record<string, unknown>,
|
|
69
69
|
sessionId: string,
|
|
70
70
|
): Record<string, unknown> {
|
|
71
|
-
// p-image-edit expects images array
|
|
71
|
+
// p-image-edit expects images array (base64 or HTTPS URLs — file URIs resolved by ai-generation-content)
|
|
72
72
|
let images: string[];
|
|
73
73
|
|
|
74
74
|
if (Array.isArray(input.images)) {
|
|
75
|
-
|
|
75
|
+
const validImages = (input.images as unknown[]).filter((img): img is string => typeof img === 'string');
|
|
76
|
+
if (validImages.length === 0) {
|
|
77
|
+
throw new Error("Image array is empty or contains no valid strings for p-image-edit.");
|
|
78
|
+
}
|
|
79
|
+
images = validImages.map(stripBase64Prefix);
|
|
76
80
|
} else if (typeof input.image === 'string') {
|
|
77
81
|
images = [stripBase64Prefix(input.image as string)];
|
|
78
82
|
} else if (typeof input.image_url === 'string') {
|
|
@@ -92,11 +96,6 @@ function buildImageEditInput(
|
|
|
92
96
|
if (input.width !== undefined) payload.width = input.width;
|
|
93
97
|
if (input.height !== undefined) payload.height = input.height;
|
|
94
98
|
|
|
95
|
-
// reference_image: designates the primary/main image in multi-image edits
|
|
96
|
-
if (typeof input.reference_image === 'string') {
|
|
97
|
-
payload.reference_image = stripBase64Prefix(input.reference_image);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
99
|
return payload;
|
|
101
100
|
}
|
|
102
101
|
|
|
@@ -112,15 +112,18 @@ async function singleSubscribeAttempt<T = unknown>(
|
|
|
112
112
|
predictionPromise.finally(() => signal.removeEventListener("abort", handler));
|
|
113
113
|
});
|
|
114
114
|
promises.push(abortPromise);
|
|
115
|
+
// Prevent unhandled rejection if abort loses the race
|
|
116
|
+
abortPromise.catch(() => {});
|
|
115
117
|
|
|
116
118
|
if (signal.aborted) {
|
|
117
119
|
throw new Error("Request cancelled by user");
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
// Prevent unhandled
|
|
122
|
-
// (timeout
|
|
123
|
+
// Prevent unhandled rejections for promises that lose the race
|
|
124
|
+
// (e.g. timeout fires after abort wins → would cause React Native red screen)
|
|
123
125
|
predictionPromise.catch(() => {});
|
|
126
|
+
timeoutPromise.catch(() => {});
|
|
124
127
|
|
|
125
128
|
const resultUrl = await Promise.race(promises) as string;
|
|
126
129
|
const requestId = `pruna_${model}_${Date.now()}`;
|
|
@@ -86,7 +86,11 @@ export class PrunaProvider implements IAIProvider {
|
|
|
86
86
|
const prunaModel = this.validateModel(model);
|
|
87
87
|
const sessionId = generationLogCollector.startSession();
|
|
88
88
|
generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() for model: ${model}`);
|
|
89
|
-
|
|
89
|
+
try {
|
|
90
|
+
return await queueOps.submitJob(prunaModel, input, apiKey, sessionId);
|
|
91
|
+
} finally {
|
|
92
|
+
generationLogCollector.endSession(sessionId);
|
|
93
|
+
}
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
async getJobStatus(model: string, requestId: string): Promise<JobStatus> {
|
|
@@ -119,6 +123,7 @@ export class PrunaProvider implements IAIProvider {
|
|
|
119
123
|
const existing = getExistingRequest<T>(key);
|
|
120
124
|
if (existing) {
|
|
121
125
|
generationLogCollector.log(sessionId, TAG, `Dedup hit — returning existing request`);
|
|
126
|
+
generationLogCollector.endSession(sessionId); // Clean up unused session
|
|
122
127
|
return existing.promise;
|
|
123
128
|
}
|
|
124
129
|
|
|
@@ -147,6 +152,7 @@ export class PrunaProvider implements IAIProvider {
|
|
|
147
152
|
.catch((error) => {
|
|
148
153
|
const totalElapsed = Date.now() - totalStart;
|
|
149
154
|
generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
generationLogCollector.endSession(sessionId); // Clean up session on error
|
|
150
156
|
rejectPromise(error);
|
|
151
157
|
})
|
|
152
158
|
.finally(() => {
|
|
@@ -169,14 +175,19 @@ export class PrunaProvider implements IAIProvider {
|
|
|
169
175
|
|
|
170
176
|
const signal = options?.signal;
|
|
171
177
|
if (signal?.aborted) {
|
|
178
|
+
generationLogCollector.endSession(sessionId);
|
|
172
179
|
throw new Error("Request cancelled by user");
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
try {
|
|
183
|
+
const result = await handlePrunaRun<T>(prunaModel, input, apiKey, sessionId, options);
|
|
184
|
+
if (result && typeof result === 'object') {
|
|
185
|
+
Object.defineProperty(result, '__providerSessionId', { value: sessionId, enumerable: false });
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
} finally {
|
|
189
|
+
generationLogCollector.endSession(sessionId);
|
|
178
190
|
}
|
|
179
|
-
return result;
|
|
180
191
|
}
|
|
181
192
|
|
|
182
193
|
reset(): void {
|
|
@@ -16,8 +16,8 @@ const STORE_KEY = "__PRUNA_PROVIDER_REQUESTS__";
|
|
|
16
16
|
const TIMER_KEY = "__PRUNA_PROVIDER_CLEANUP_TIMER__";
|
|
17
17
|
type RequestStore = Map<string, ActiveRequest>;
|
|
18
18
|
|
|
19
|
-
const CLEANUP_INTERVAL =
|
|
20
|
-
const MAX_REQUEST_AGE =
|
|
19
|
+
const CLEANUP_INTERVAL = 60_000;
|
|
20
|
+
const MAX_REQUEST_AGE = 3_660_000; // 61 min — must exceed max allowed timeout (1 hour)
|
|
21
21
|
|
|
22
22
|
function getCleanupTimer(): ReturnType<typeof setInterval> | null {
|
|
23
23
|
const globalObj = globalThis as Record<string, unknown>;
|
|
@@ -15,7 +15,8 @@ export const MIME_AUDIO_FLAC = 'audio/flac' as const;
|
|
|
15
15
|
export const MIME_AUDIO_MP4 = 'audio/mp4' as const;
|
|
16
16
|
|
|
17
17
|
// ── Fallback ────────────────────────────────────────────────
|
|
18
|
-
export const
|
|
18
|
+
export const MIME_APPLICATION_OCTET = 'application/octet-stream' as const;
|
|
19
|
+
export const MIME_DEFAULT = MIME_APPLICATION_OCTET;
|
|
19
20
|
|
|
20
21
|
/** Maps MIME type → file extension for upload naming */
|
|
21
22
|
export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
|
|
@@ -26,6 +27,7 @@ export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
|
|
|
26
27
|
[MIME_AUDIO_WAV]: 'wav',
|
|
27
28
|
[MIME_AUDIO_FLAC]: 'flac',
|
|
28
29
|
[MIME_AUDIO_MP4]: 'm4a',
|
|
30
|
+
[MIME_APPLICATION_OCTET]: 'bin',
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -48,7 +48,8 @@ export function detectMimeType(bytes: Uint8Array): string {
|
|
|
48
48
|
// WEBP at offset 8
|
|
49
49
|
if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return MIME_IMAGE_WEBP;
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
// Unknown RIFF subtype (AVI, AIFF, etc.) or insufficient bytes — don't assume WebP
|
|
52
|
+
return MIME_DEFAULT;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// ── Audio formats ───────────────────────────────────────
|
|
@@ -15,6 +15,11 @@ export function mapPrunaError(error: unknown): PrunaErrorInfo {
|
|
|
15
15
|
const stack = error instanceof Error ? error.stack : undefined;
|
|
16
16
|
const statusCode = (error as Error & { statusCode?: number }).statusCode;
|
|
17
17
|
|
|
18
|
+
// AbortError from signal.abort() during fetch — treat as user cancellation
|
|
19
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
20
|
+
return buildErrorInfo(PrunaErrorType.UNKNOWN, "error.pruna.cancelled", false, originalError, originalErrorName, stack);
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
// HTTP status code mapping
|
|
19
24
|
if (statusCode !== undefined) {
|
|
20
25
|
return mapStatusCode(statusCode, originalError, originalErrorName, stack);
|
|
@@ -66,12 +66,12 @@ export function createAiProviderInitModule(
|
|
|
66
66
|
name: 'aiProviders',
|
|
67
67
|
critical,
|
|
68
68
|
dependsOn,
|
|
69
|
-
init: () => {
|
|
69
|
+
init: async () => {
|
|
70
70
|
try {
|
|
71
71
|
const apiKey = getApiKey();
|
|
72
72
|
|
|
73
73
|
if (!apiKey) {
|
|
74
|
-
return
|
|
74
|
+
return false;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
prunaProvider.initialize({ apiKey });
|
|
@@ -87,7 +87,7 @@ export function createAiProviderInitModule(
|
|
|
87
87
|
onInitialized();
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
return
|
|
90
|
+
return true;
|
|
91
91
|
} catch (error) {
|
|
92
92
|
console.error('[AiProviderInitModule] Pruna initialization failed:', error);
|
|
93
93
|
throw error;
|
|
@@ -75,11 +75,13 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
75
75
|
stateManagerRef.current = null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
// Cancel
|
|
78
|
+
// Cancel this hook's active request on unmount
|
|
79
79
|
if (abortControllerRef.current) {
|
|
80
80
|
abortControllerRef.current.abort();
|
|
81
81
|
abortControllerRef.current = null;
|
|
82
82
|
}
|
|
83
|
+
// Also cancel the provider's internal request
|
|
84
|
+
prunaProvider.cancelCurrentRequest();
|
|
83
85
|
};
|
|
84
86
|
}, []);
|
|
85
87
|
|
|
@@ -159,6 +161,8 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
159
161
|
abortControllerRef.current.abort();
|
|
160
162
|
abortControllerRef.current = null;
|
|
161
163
|
}
|
|
164
|
+
// Propagate cancel to the provider's internal AbortController
|
|
165
|
+
prunaProvider.cancelCurrentRequest();
|
|
162
166
|
}, []);
|
|
163
167
|
|
|
164
168
|
const reset = useCallback(() => {
|