@umituz/react-native-ai-pruna-provider 1.0.8 → 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/domain/entities/error.types.ts +0 -21
- package/src/exports/domain.ts +0 -2
- package/src/infrastructure/services/pruna-api-client.ts +55 -26
- package/src/infrastructure/services/pruna-input-builder.ts +6 -7
- package/src/infrastructure/services/pruna-provider-subscription.ts +11 -3
- package/src/infrastructure/services/pruna-provider.ts +16 -5
- package/src/infrastructure/services/pruna-queue-operations.ts +7 -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/log-collector.ts +9 -0
- package/src/infrastructure/utils/mime-detection.util.ts +2 -1
- package/src/infrastructure/utils/pruna-error-handler.util.ts +5 -0
- package/src/infrastructure/utils/type-guards/index.ts +1 -1
- package/src/init/createAiProviderInitModule.ts +14 -4
- package/src/presentation/hooks/use-pruna-generation.ts +31 -10
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",
|
|
@@ -19,12 +19,6 @@ export enum PrunaErrorType {
|
|
|
19
19
|
UNKNOWN = "unknown",
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export interface PrunaErrorCategory {
|
|
23
|
-
readonly type: PrunaErrorType;
|
|
24
|
-
readonly messageKey: string;
|
|
25
|
-
readonly retryable: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
22
|
export interface PrunaErrorInfo {
|
|
29
23
|
readonly type: PrunaErrorType;
|
|
30
24
|
readonly messageKey: string;
|
|
@@ -35,18 +29,3 @@ export interface PrunaErrorInfo {
|
|
|
35
29
|
readonly statusCode?: number;
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
export interface PrunaErrorMessages {
|
|
39
|
-
network?: string;
|
|
40
|
-
timeout?: string;
|
|
41
|
-
api_error?: string;
|
|
42
|
-
validation?: string;
|
|
43
|
-
content_policy?: string;
|
|
44
|
-
rate_limit?: string;
|
|
45
|
-
authentication?: string;
|
|
46
|
-
quota_exceeded?: string;
|
|
47
|
-
model_not_found?: string;
|
|
48
|
-
file_upload?: string;
|
|
49
|
-
polling_timeout?: string;
|
|
50
|
-
invalid_image?: string;
|
|
51
|
-
unknown?: string;
|
|
52
|
-
}
|
package/src/exports/domain.ts
CHANGED
|
@@ -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";
|
|
@@ -29,6 +29,11 @@ export async function uploadFileToStorage(
|
|
|
29
29
|
apiKey: string,
|
|
30
30
|
sessionId: string,
|
|
31
31
|
): Promise<string> {
|
|
32
|
+
// Guard: empty or whitespace-only input
|
|
33
|
+
if (!base64Data || !base64Data.trim()) {
|
|
34
|
+
throw new Error("File data is empty. Provide a base64 string or URL.");
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
// Already a URL — return as-is
|
|
33
38
|
if (base64Data.startsWith('http')) {
|
|
34
39
|
generationLogCollector.log(sessionId, TAG, 'File already a URL, skipping upload');
|
|
@@ -60,31 +65,43 @@ export async function uploadFileToStorage(
|
|
|
60
65
|
|
|
61
66
|
const startTime = Date.now();
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
body: formData,
|
|
67
|
-
});
|
|
68
|
+
// Apply timeout to prevent indefinite hangs
|
|
69
|
+
const uploadController = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => uploadController.abort(), UPLOAD_CONFIG.timeoutMs);
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
86
|
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
const data: PrunaFileUploadResponse = await response.json();
|
|
88
|
+
const fileUrl = data.urls?.get || `${PRUNA_FILES_URL}/${data.id}`;
|
|
78
89
|
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
const elapsed = Date.now() - startTime;
|
|
91
|
+
generationLogCollector.log(sessionId, TAG, `File upload completed in ${elapsed}ms → ${fileUrl}`);
|
|
81
92
|
|
|
82
|
-
|
|
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
|
+
}
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
/** @deprecated Use uploadFileToStorage instead */
|
|
86
|
-
export const uploadImageToFiles = uploadFileToStorage;
|
|
87
|
-
|
|
88
105
|
/**
|
|
89
106
|
* Strip base64 data URI prefix, returning raw base64 string.
|
|
90
107
|
* If input is already a URL, returns it unchanged.
|
|
@@ -104,6 +121,7 @@ export async function submitPrediction(
|
|
|
104
121
|
input: Record<string, unknown>,
|
|
105
122
|
apiKey: string,
|
|
106
123
|
sessionId: string,
|
|
124
|
+
signal?: AbortSignal,
|
|
107
125
|
): Promise<PrunaPredictionResponse> {
|
|
108
126
|
generationLogCollector.log(sessionId, TAG, `Submitting prediction for model: ${model}`, {
|
|
109
127
|
inputKeys: Object.keys(input),
|
|
@@ -120,11 +138,18 @@ export async function submitPrediction(
|
|
|
120
138
|
'Content-Type': 'application/json',
|
|
121
139
|
},
|
|
122
140
|
body: JSON.stringify({ input }),
|
|
141
|
+
signal,
|
|
123
142
|
});
|
|
124
143
|
|
|
125
144
|
if (!response.ok) {
|
|
126
|
-
const
|
|
127
|
-
|
|
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
|
+
}
|
|
128
153
|
|
|
129
154
|
generationLogCollector.error(sessionId, TAG, `Prediction failed (${response.status}): ${errorMessage}`);
|
|
130
155
|
|
|
@@ -167,15 +192,19 @@ export async function pollForResult(
|
|
|
167
192
|
throw new Error("Request cancelled by user");
|
|
168
193
|
}
|
|
169
194
|
|
|
170
|
-
|
|
195
|
+
// Wait between polls — skip delay on first attempt for faster response
|
|
196
|
+
if (i > 0) {
|
|
197
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
171
198
|
|
|
172
|
-
|
|
173
|
-
|
|
199
|
+
if (signal?.aborted) {
|
|
200
|
+
throw new Error("Request cancelled by user");
|
|
201
|
+
}
|
|
174
202
|
}
|
|
175
203
|
|
|
176
204
|
try {
|
|
177
205
|
const statusRes = await fetch(fullPollUrl, {
|
|
178
206
|
headers: { 'apikey': apiKey },
|
|
207
|
+
signal,
|
|
179
208
|
});
|
|
180
209
|
|
|
181
210
|
if (!statusRes.ok) {
|
|
@@ -226,9 +255,9 @@ export function extractUri(data: PrunaPredictionResponse): string | null {
|
|
|
226
255
|
data.generation_url ||
|
|
227
256
|
(data.output && typeof data.output === 'object' && !Array.isArray(data.output) ? (data.output as { url: string }).url : null) ||
|
|
228
257
|
(typeof data.output === 'string' ? data.output : null) ||
|
|
229
|
-
data.data ||
|
|
230
258
|
data.video_url ||
|
|
231
259
|
(Array.isArray(data.output) ? data.output[0] : null) ||
|
|
260
|
+
data.data ||
|
|
232
261
|
null
|
|
233
262
|
);
|
|
234
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
|
|
|
@@ -64,7 +64,7 @@ async function singleSubscribeAttempt<T = unknown>(
|
|
|
64
64
|
// Notify progress: IN_PROGRESS
|
|
65
65
|
options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" });
|
|
66
66
|
|
|
67
|
-
const response = await submitPrediction(model, modelInput, apiKey, sessionId);
|
|
67
|
+
const response = await submitPrediction(model, modelInput, apiKey, sessionId, signal);
|
|
68
68
|
let uri = extractUri(response);
|
|
69
69
|
|
|
70
70
|
// If no immediate result, poll for async result
|
|
@@ -112,12 +112,19 @@ 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
|
|
|
123
|
+
// Prevent unhandled rejections for promises that lose the race
|
|
124
|
+
// (e.g. timeout fires after abort wins → would cause React Native red screen)
|
|
125
|
+
predictionPromise.catch(() => {});
|
|
126
|
+
timeoutPromise.catch(() => {});
|
|
127
|
+
|
|
121
128
|
const resultUrl = await Promise.race(promises) as string;
|
|
122
129
|
const requestId = `pruna_${model}_${Date.now()}`;
|
|
123
130
|
|
|
@@ -205,7 +212,8 @@ export async function handlePrunaSubscription<T = unknown>(
|
|
|
205
212
|
}
|
|
206
213
|
}
|
|
207
214
|
|
|
208
|
-
|
|
215
|
+
// Unreachable: loop always returns or throws. TypeScript safety net.
|
|
216
|
+
throw lastError instanceof Error ? lastError : new Error("Subscription failed after all retry attempts.");
|
|
209
217
|
}
|
|
210
218
|
|
|
211
219
|
/**
|
|
@@ -226,7 +234,7 @@ export async function handlePrunaRun<T = unknown>(
|
|
|
226
234
|
|
|
227
235
|
try {
|
|
228
236
|
const modelInput = await buildModelInput(model, input, apiKey, sessionId);
|
|
229
|
-
const response = await submitPrediction(model, modelInput, apiKey, sessionId);
|
|
237
|
+
const response = await submitPrediction(model, modelInput, apiKey, sessionId, options?.signal);
|
|
230
238
|
|
|
231
239
|
let uri = extractUri(response);
|
|
232
240
|
|
|
@@ -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 {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import type { PrunaModelId } from "../../domain/entities/pruna.types";
|
|
7
7
|
import type { JobSubmission, JobStatus } from "../../domain/types";
|
|
8
|
-
import { submitPrediction, extractUri } from "./pruna-api-client";
|
|
8
|
+
import { submitPrediction, extractUri, resolveUri } from "./pruna-api-client";
|
|
9
|
+
import { PRUNA_BASE_URL } from "./pruna-provider.constants";
|
|
9
10
|
import { buildModelInput } from "./pruna-input-builder";
|
|
10
11
|
import { generationLogCollector } from "../utils/log-collector";
|
|
11
12
|
|
|
@@ -62,7 +63,7 @@ export async function getJobStatus(
|
|
|
62
63
|
statusUrl: string,
|
|
63
64
|
apiKey: string,
|
|
64
65
|
): Promise<JobStatus> {
|
|
65
|
-
const fullUrl = statusUrl.startsWith('http') ? statusUrl :
|
|
66
|
+
const fullUrl = statusUrl.startsWith('http') ? statusUrl : `${PRUNA_BASE_URL}${statusUrl}`;
|
|
66
67
|
|
|
67
68
|
const response = await fetch(fullUrl, {
|
|
68
69
|
headers: { 'apikey': apiKey },
|
|
@@ -83,9 +84,11 @@ export async function getJobStatus(
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
if (typedData.status === 'failed') {
|
|
87
|
+
const errorMessage = typedData.error || "Generation failed during processing.";
|
|
86
88
|
return {
|
|
87
89
|
status: "FAILED",
|
|
88
90
|
requestId: statusUrl,
|
|
91
|
+
logs: [{ message: errorMessage, level: "error" }],
|
|
89
92
|
};
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -104,7 +107,7 @@ export async function getJobResult<T = unknown>(
|
|
|
104
107
|
statusUrl: string,
|
|
105
108
|
apiKey: string,
|
|
106
109
|
): Promise<T> {
|
|
107
|
-
const fullUrl = statusUrl.startsWith('http') ? statusUrl :
|
|
110
|
+
const fullUrl = statusUrl.startsWith('http') ? statusUrl : `${PRUNA_BASE_URL}${statusUrl}`;
|
|
108
111
|
|
|
109
112
|
const response = await fetch(fullUrl, {
|
|
110
113
|
headers: { 'apikey': apiKey },
|
|
@@ -126,6 +129,5 @@ export async function getJobResult<T = unknown>(
|
|
|
126
129
|
throw new Error("Result not ready or extraction failed.");
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
|
|
130
|
-
return { url: resolvedUri } as T;
|
|
132
|
+
return { url: resolveUri(uri) } as T;
|
|
131
133
|
}
|
|
@@ -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
|
/**
|
|
@@ -25,10 +25,19 @@ interface Session {
|
|
|
25
25
|
|
|
26
26
|
let sessionCounter = 0;
|
|
27
27
|
|
|
28
|
+
/** Max concurrent sessions before auto-evicting oldest */
|
|
29
|
+
const MAX_SESSIONS = 50;
|
|
30
|
+
|
|
28
31
|
class GenerationLogCollector {
|
|
29
32
|
private sessions = new Map<string, Session>();
|
|
30
33
|
|
|
31
34
|
startSession(): string {
|
|
35
|
+
// Evict oldest sessions if limit exceeded
|
|
36
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
37
|
+
const oldestKey = this.sessions.keys().next().value;
|
|
38
|
+
if (oldestKey) this.sessions.delete(oldestKey);
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
const id = `pruna_session_${++sessionCounter}_${Date.now()}`;
|
|
33
42
|
this.sessions.set(id, { startTime: Date.now(), entries: [] });
|
|
34
43
|
return id;
|
|
@@ -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);
|
|
@@ -27,5 +27,5 @@ export function isValidPrompt(value: unknown): value is string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function isValidTimeout(value: unknown): value is number {
|
|
30
|
-
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <=
|
|
30
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 3600000;
|
|
31
31
|
}
|
|
@@ -35,6 +35,13 @@ export interface AiProviderInitModuleConfig {
|
|
|
35
35
|
*/
|
|
36
36
|
dependsOn?: string[];
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Whether to set Pruna as the active provider after initialization.
|
|
40
|
+
* When false, registers the provider but doesn't make it active.
|
|
41
|
+
* @default true
|
|
42
|
+
*/
|
|
43
|
+
setAsActive?: boolean;
|
|
44
|
+
|
|
38
45
|
/**
|
|
39
46
|
* Optional callback called after provider is initialized
|
|
40
47
|
*/
|
|
@@ -51,6 +58,7 @@ export function createAiProviderInitModule(
|
|
|
51
58
|
getApiKey,
|
|
52
59
|
critical = false,
|
|
53
60
|
dependsOn = ['firebase'],
|
|
61
|
+
setAsActive = true,
|
|
54
62
|
onInitialized,
|
|
55
63
|
} = config;
|
|
56
64
|
|
|
@@ -58,12 +66,12 @@ export function createAiProviderInitModule(
|
|
|
58
66
|
name: 'aiProviders',
|
|
59
67
|
critical,
|
|
60
68
|
dependsOn,
|
|
61
|
-
init: () => {
|
|
69
|
+
init: async () => {
|
|
62
70
|
try {
|
|
63
71
|
const apiKey = getApiKey();
|
|
64
72
|
|
|
65
73
|
if (!apiKey) {
|
|
66
|
-
return
|
|
74
|
+
return false;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
prunaProvider.initialize({ apiKey });
|
|
@@ -71,13 +79,15 @@ export function createAiProviderInitModule(
|
|
|
71
79
|
if (!providerRegistry.hasProvider(prunaProvider.providerId)) {
|
|
72
80
|
providerRegistry.register(prunaProvider);
|
|
73
81
|
}
|
|
74
|
-
|
|
82
|
+
if (setAsActive) {
|
|
83
|
+
providerRegistry.setActiveProvider(prunaProvider.providerId);
|
|
84
|
+
}
|
|
75
85
|
|
|
76
86
|
if (onInitialized) {
|
|
77
87
|
onInitialized();
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
return
|
|
90
|
+
return true;
|
|
81
91
|
} catch (error) {
|
|
82
92
|
console.error('[AiProviderInitModule] Pruna initialization failed:', error);
|
|
83
93
|
throw error;
|
|
@@ -49,8 +49,10 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
49
49
|
const [error, setError] = useState<PrunaErrorInfo | null>(null);
|
|
50
50
|
const [isLoading, setIsLoading] = useState(false);
|
|
51
51
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
52
|
+
const [requestId, setRequestId] = useState<string | null>(null);
|
|
52
53
|
|
|
53
54
|
const stateManagerRef = useRef<PrunaGenerationStateManager<T> | null>(null);
|
|
55
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
54
56
|
const optionsRef = useRef(options);
|
|
55
57
|
|
|
56
58
|
useEffect(() => {
|
|
@@ -73,13 +75,13 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
73
75
|
stateManagerRef.current = null;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.warn('[usePrunaGeneration] Error cancelling request on unmount:', error);
|
|
81
|
-
}
|
|
78
|
+
// Cancel this hook's active request on unmount
|
|
79
|
+
if (abortControllerRef.current) {
|
|
80
|
+
abortControllerRef.current.abort();
|
|
81
|
+
abortControllerRef.current = null;
|
|
82
82
|
}
|
|
83
|
+
// Also cancel the provider's internal request
|
|
84
|
+
prunaProvider.cancelCurrentRequest();
|
|
83
85
|
};
|
|
84
86
|
}, []);
|
|
85
87
|
|
|
@@ -88,10 +90,18 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
88
90
|
const stateManager = stateManagerRef.current;
|
|
89
91
|
if (!stateManager || !stateManager.checkMounted()) return null;
|
|
90
92
|
|
|
93
|
+
// Cancel any previous in-flight request from this hook
|
|
94
|
+
if (abortControllerRef.current) {
|
|
95
|
+
abortControllerRef.current.abort();
|
|
96
|
+
}
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
abortControllerRef.current = controller;
|
|
99
|
+
|
|
91
100
|
stateManager.setLastRequest(model, input);
|
|
92
101
|
setIsLoading(true);
|
|
93
102
|
setError(null);
|
|
94
103
|
setData(null);
|
|
104
|
+
setRequestId(null);
|
|
95
105
|
stateManager.setCurrentRequestId(null);
|
|
96
106
|
setIsCancelling(false);
|
|
97
107
|
|
|
@@ -104,6 +114,11 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
104
114
|
stateManager.getCurrentRequestId()
|
|
105
115
|
);
|
|
106
116
|
stateManager.handleQueueUpdate(prunaStatus);
|
|
117
|
+
|
|
118
|
+
// Update reactive requestId from queue status
|
|
119
|
+
if (status.requestId) {
|
|
120
|
+
setRequestId(status.requestId);
|
|
121
|
+
}
|
|
107
122
|
},
|
|
108
123
|
});
|
|
109
124
|
|
|
@@ -121,6 +136,10 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
121
136
|
setIsLoading(false);
|
|
122
137
|
setIsCancelling(false);
|
|
123
138
|
}
|
|
139
|
+
// Clean up controller reference
|
|
140
|
+
if (abortControllerRef.current === controller) {
|
|
141
|
+
abortControllerRef.current = null;
|
|
142
|
+
}
|
|
124
143
|
}
|
|
125
144
|
},
|
|
126
145
|
[]
|
|
@@ -137,10 +156,13 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
137
156
|
}, [generate]);
|
|
138
157
|
|
|
139
158
|
const cancel = useCallback(() => {
|
|
140
|
-
if (
|
|
159
|
+
if (abortControllerRef.current) {
|
|
141
160
|
setIsCancelling(true);
|
|
142
|
-
|
|
161
|
+
abortControllerRef.current.abort();
|
|
162
|
+
abortControllerRef.current = null;
|
|
143
163
|
}
|
|
164
|
+
// Propagate cancel to the provider's internal AbortController
|
|
165
|
+
prunaProvider.cancelCurrentRequest();
|
|
144
166
|
}, []);
|
|
145
167
|
|
|
146
168
|
const reset = useCallback(() => {
|
|
@@ -149,11 +171,10 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
149
171
|
setError(null);
|
|
150
172
|
setIsLoading(false);
|
|
151
173
|
setIsCancelling(false);
|
|
174
|
+
setRequestId(null);
|
|
152
175
|
stateManagerRef.current?.clearLastRequest();
|
|
153
176
|
}, [cancel]);
|
|
154
177
|
|
|
155
|
-
const requestId = stateManagerRef.current?.getCurrentRequestId() ?? null;
|
|
156
|
-
|
|
157
178
|
return {
|
|
158
179
|
data,
|
|
159
180
|
error,
|