@umituz/react-native-ai-pruna-provider 1.0.7 → 1.0.9
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 +12 -41
- package/src/infrastructure/services/pruna-provider-subscription.ts +8 -3
- package/src/infrastructure/services/pruna-queue-operations.ts +7 -5
- package/src/infrastructure/utils/constants/index.ts +12 -0
- package/src/infrastructure/utils/constants/mime.constants.ts +37 -0
- package/src/infrastructure/utils/index.ts +6 -0
- package/src/infrastructure/utils/log-collector.ts +9 -0
- package/src/infrastructure/utils/mime-detection.util.ts +68 -0
- package/src/infrastructure/utils/type-guards/index.ts +1 -1
- package/src/init/createAiProviderInitModule.ts +11 -1
- package/src/presentation/hooks/use-pruna-generation.ts +27 -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.9",
|
|
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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Endpoints:
|
|
6
6
|
* POST /v1/predictions — submit generation (with Try-Sync header for immediate results)
|
|
7
|
-
* POST /v1/files — upload images for p-video (requires file URL, not base64)
|
|
7
|
+
* POST /v1/files — upload files (images, audio) for p-video (requires file URL, not base64)
|
|
8
8
|
* GET {poll_url} — poll async results
|
|
9
9
|
*
|
|
10
10
|
* Authentication: `apikey` header
|
|
@@ -14,45 +14,11 @@
|
|
|
14
14
|
import type { PrunaModelId, PrunaPredictionResponse, PrunaFileUploadResponse } from "../../domain/entities/pruna.types";
|
|
15
15
|
import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL } from "./pruna-provider.constants";
|
|
16
16
|
import { generationLogCollector } from "../utils/log-collector";
|
|
17
|
+
import { detectMimeType } from "../utils/mime-detection.util";
|
|
18
|
+
import { getExtensionForMime } from "../utils/constants/mime.constants";
|
|
17
19
|
|
|
18
20
|
const TAG = 'pruna-api';
|
|
19
21
|
|
|
20
|
-
/**
|
|
21
|
-
* Detect MIME type from raw binary bytes.
|
|
22
|
-
* Supports image (png, jpeg, webp) and audio (mp3, wav, flac, m4a/aac) formats.
|
|
23
|
-
*/
|
|
24
|
-
function detectMimeType(bytes: Uint8Array): string {
|
|
25
|
-
// JPEG
|
|
26
|
-
if (bytes[0] === 0xFF && bytes[1] === 0xD8) return 'image/jpeg';
|
|
27
|
-
// PNG
|
|
28
|
-
if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png';
|
|
29
|
-
// RIFF container — WAV or WebP
|
|
30
|
-
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {
|
|
31
|
-
if (bytes.length > 11 && bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) return 'audio/wav';
|
|
32
|
-
return 'image/webp';
|
|
33
|
-
}
|
|
34
|
-
// MP3 with ID3 tag
|
|
35
|
-
if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return 'audio/mpeg';
|
|
36
|
-
// MP3 sync word (0xFF followed by 0xE0-0xFF)
|
|
37
|
-
if (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0 && bytes[1] !== 0xFF) return 'audio/mpeg';
|
|
38
|
-
// FLAC
|
|
39
|
-
if (bytes[0] === 0x66 && bytes[1] === 0x4C && bytes[2] === 0x61 && bytes[3] === 0x43) return 'audio/flac';
|
|
40
|
-
// M4A/AAC (MP4 container — ftyp box at offset 4)
|
|
41
|
-
if (bytes.length > 7 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) return 'audio/mp4';
|
|
42
|
-
// Default
|
|
43
|
-
return 'image/png';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const MIME_EXTENSION_MAP: Record<string, string> = {
|
|
47
|
-
'image/png': 'png',
|
|
48
|
-
'image/jpeg': 'jpg',
|
|
49
|
-
'image/webp': 'webp',
|
|
50
|
-
'audio/mpeg': 'mp3',
|
|
51
|
-
'audio/wav': 'wav',
|
|
52
|
-
'audio/flac': 'flac',
|
|
53
|
-
'audio/mp4': 'm4a',
|
|
54
|
-
};
|
|
55
|
-
|
|
56
22
|
/**
|
|
57
23
|
* Upload a base64 file (image or audio) to Pruna's file storage.
|
|
58
24
|
* p-video requires file URLs (not raw base64).
|
|
@@ -63,6 +29,11 @@ export async function uploadFileToStorage(
|
|
|
63
29
|
apiKey: string,
|
|
64
30
|
sessionId: string,
|
|
65
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
|
+
|
|
66
37
|
// Already a URL — return as-is
|
|
67
38
|
if (base64Data.startsWith('http')) {
|
|
68
39
|
generationLogCollector.log(sessionId, TAG, 'File already a URL, skipping upload');
|
|
@@ -87,7 +58,7 @@ export async function uploadFileToStorage(
|
|
|
87
58
|
}
|
|
88
59
|
|
|
89
60
|
const mime = detectMimeType(bytes);
|
|
90
|
-
const ext =
|
|
61
|
+
const ext = getExtensionForMime(mime);
|
|
91
62
|
const blob = new Blob([bytes], { type: mime });
|
|
92
63
|
const formData = new FormData();
|
|
93
64
|
formData.append('content', blob, `upload.${ext}`);
|
|
@@ -116,9 +87,6 @@ export async function uploadFileToStorage(
|
|
|
116
87
|
return fileUrl;
|
|
117
88
|
}
|
|
118
89
|
|
|
119
|
-
/** @deprecated Use uploadFileToStorage instead */
|
|
120
|
-
export const uploadImageToFiles = uploadFileToStorage;
|
|
121
|
-
|
|
122
90
|
/**
|
|
123
91
|
* Strip base64 data URI prefix, returning raw base64 string.
|
|
124
92
|
* If input is already a URL, returns it unchanged.
|
|
@@ -138,6 +106,7 @@ export async function submitPrediction(
|
|
|
138
106
|
input: Record<string, unknown>,
|
|
139
107
|
apiKey: string,
|
|
140
108
|
sessionId: string,
|
|
109
|
+
signal?: AbortSignal,
|
|
141
110
|
): Promise<PrunaPredictionResponse> {
|
|
142
111
|
generationLogCollector.log(sessionId, TAG, `Submitting prediction for model: ${model}`, {
|
|
143
112
|
inputKeys: Object.keys(input),
|
|
@@ -154,6 +123,7 @@ export async function submitPrediction(
|
|
|
154
123
|
'Content-Type': 'application/json',
|
|
155
124
|
},
|
|
156
125
|
body: JSON.stringify({ input }),
|
|
126
|
+
signal,
|
|
157
127
|
});
|
|
158
128
|
|
|
159
129
|
if (!response.ok) {
|
|
@@ -210,6 +180,7 @@ export async function pollForResult(
|
|
|
210
180
|
try {
|
|
211
181
|
const statusRes = await fetch(fullPollUrl, {
|
|
212
182
|
headers: { 'apikey': apiKey },
|
|
183
|
+
signal,
|
|
213
184
|
});
|
|
214
185
|
|
|
215
186
|
if (!statusRes.ok) {
|
|
@@ -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
|
|
@@ -118,6 +118,10 @@ async function singleSubscribeAttempt<T = unknown>(
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Prevent unhandled rejection if predictionPromise loses the race
|
|
122
|
+
// (timeout or abort wins → prediction may reject later with no handler)
|
|
123
|
+
predictionPromise.catch(() => {});
|
|
124
|
+
|
|
121
125
|
const resultUrl = await Promise.race(promises) as string;
|
|
122
126
|
const requestId = `pruna_${model}_${Date.now()}`;
|
|
123
127
|
|
|
@@ -205,7 +209,8 @@ export async function handlePrunaSubscription<T = unknown>(
|
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
|
|
208
|
-
|
|
212
|
+
// Unreachable: loop always returns or throws. TypeScript safety net.
|
|
213
|
+
throw lastError instanceof Error ? lastError : new Error("Subscription failed after all retry attempts.");
|
|
209
214
|
}
|
|
210
215
|
|
|
211
216
|
/**
|
|
@@ -226,7 +231,7 @@ export async function handlePrunaRun<T = unknown>(
|
|
|
226
231
|
|
|
227
232
|
try {
|
|
228
233
|
const modelInput = await buildModelInput(model, input, apiKey, sessionId);
|
|
229
|
-
const response = await submitPrediction(model, modelInput, apiKey, sessionId);
|
|
234
|
+
const response = await submitPrediction(model, modelInput, apiKey, sessionId, options?.signal);
|
|
230
235
|
|
|
231
236
|
let uri = extractUri(response);
|
|
232
237
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIME Type Constants
|
|
3
|
+
* Supported media types for Pruna file uploads (images + audio)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Image MIME types ────────────────────────────────────────
|
|
7
|
+
export const MIME_IMAGE_PNG = 'image/png' as const;
|
|
8
|
+
export const MIME_IMAGE_JPEG = 'image/jpeg' as const;
|
|
9
|
+
export const MIME_IMAGE_WEBP = 'image/webp' as const;
|
|
10
|
+
|
|
11
|
+
// ── Audio MIME types (p-video audio input: flac, mp3, wav) ──
|
|
12
|
+
export const MIME_AUDIO_MPEG = 'audio/mpeg' as const;
|
|
13
|
+
export const MIME_AUDIO_WAV = 'audio/wav' as const;
|
|
14
|
+
export const MIME_AUDIO_FLAC = 'audio/flac' as const;
|
|
15
|
+
export const MIME_AUDIO_MP4 = 'audio/mp4' as const;
|
|
16
|
+
|
|
17
|
+
// ── Fallback ────────────────────────────────────────────────
|
|
18
|
+
export const MIME_DEFAULT = MIME_IMAGE_PNG;
|
|
19
|
+
|
|
20
|
+
/** Maps MIME type → file extension for upload naming */
|
|
21
|
+
export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
|
|
22
|
+
[MIME_IMAGE_PNG]: 'png',
|
|
23
|
+
[MIME_IMAGE_JPEG]: 'jpg',
|
|
24
|
+
[MIME_IMAGE_WEBP]: 'webp',
|
|
25
|
+
[MIME_AUDIO_MPEG]: 'mp3',
|
|
26
|
+
[MIME_AUDIO_WAV]: 'wav',
|
|
27
|
+
[MIME_AUDIO_FLAC]: 'flac',
|
|
28
|
+
[MIME_AUDIO_MP4]: 'm4a',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get file extension for a MIME type.
|
|
33
|
+
* Falls back to the subtype (e.g. "png" from "image/png").
|
|
34
|
+
*/
|
|
35
|
+
export function getExtensionForMime(mime: string): string {
|
|
36
|
+
return MIME_TO_EXTENSION[mime] || mime.split('/')[1] || 'bin';
|
|
37
|
+
}
|
|
@@ -28,3 +28,9 @@ export {
|
|
|
28
28
|
|
|
29
29
|
export { generationLogCollector } from "./log-collector";
|
|
30
30
|
export type { LogEntry } from "./log-collector";
|
|
31
|
+
|
|
32
|
+
export { detectMimeType } from "./mime-detection.util";
|
|
33
|
+
export {
|
|
34
|
+
MIME_TO_EXTENSION,
|
|
35
|
+
getExtensionForMime,
|
|
36
|
+
} from "./constants/mime.constants";
|
|
@@ -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;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIME Type Detection Utility
|
|
3
|
+
* Detects file type from binary content using magic byte signatures.
|
|
4
|
+
*
|
|
5
|
+
* Supported formats:
|
|
6
|
+
* Image: PNG, JPEG, WebP
|
|
7
|
+
* Audio: MP3 (ID3 + sync word), WAV (RIFF/WAVE), FLAC, M4A/AAC (MP4 ftyp)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
MIME_IMAGE_PNG,
|
|
12
|
+
MIME_IMAGE_JPEG,
|
|
13
|
+
MIME_IMAGE_WEBP,
|
|
14
|
+
MIME_AUDIO_MPEG,
|
|
15
|
+
MIME_AUDIO_WAV,
|
|
16
|
+
MIME_AUDIO_FLAC,
|
|
17
|
+
MIME_AUDIO_MP4,
|
|
18
|
+
MIME_DEFAULT,
|
|
19
|
+
} from "./constants/mime.constants";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect MIME type from raw binary bytes using magic number signatures.
|
|
23
|
+
*
|
|
24
|
+
* Detection order is intentional:
|
|
25
|
+
* 1. JPEG (0xFF 0xD8) — checked before MP3 sync word to avoid false positives
|
|
26
|
+
* 2. PNG (0x89 0x50)
|
|
27
|
+
* 3. RIFF container → distinguish WAV vs WebP via subformat at offset 8-11
|
|
28
|
+
* 4. MP3 with ID3 tag (0x49 0x44 0x33)
|
|
29
|
+
* 5. MP3 sync word (0xFF 0xE_) — after JPEG to prevent overlap
|
|
30
|
+
* 6. FLAC (fLaC)
|
|
31
|
+
* 7. M4A/AAC (ftyp box at offset 4)
|
|
32
|
+
*/
|
|
33
|
+
export function detectMimeType(bytes: Uint8Array): string {
|
|
34
|
+
if (bytes.length < 4) return MIME_DEFAULT;
|
|
35
|
+
|
|
36
|
+
// ── Image formats ───────────────────────────────────────
|
|
37
|
+
// JPEG: FF D8
|
|
38
|
+
if (bytes[0] === 0xFF && bytes[1] === 0xD8) return MIME_IMAGE_JPEG;
|
|
39
|
+
|
|
40
|
+
// PNG: 89 50 4E 47
|
|
41
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50) return MIME_IMAGE_PNG;
|
|
42
|
+
|
|
43
|
+
// RIFF container — WAV (RIFF....WAVE) or WebP (RIFF....WEBP)
|
|
44
|
+
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {
|
|
45
|
+
if (bytes.length > 11) {
|
|
46
|
+
// WAVE at offset 8
|
|
47
|
+
if (bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) return MIME_AUDIO_WAV;
|
|
48
|
+
// WEBP at offset 8
|
|
49
|
+
if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return MIME_IMAGE_WEBP;
|
|
50
|
+
}
|
|
51
|
+
return MIME_IMAGE_WEBP;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Audio formats ───────────────────────────────────────
|
|
55
|
+
// MP3 with ID3v2 tag: 49 44 33
|
|
56
|
+
if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return MIME_AUDIO_MPEG;
|
|
57
|
+
|
|
58
|
+
// MP3 frame sync word: FF Ex/Fx (but not FF FF)
|
|
59
|
+
if (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0 && bytes[1] !== 0xFF) return MIME_AUDIO_MPEG;
|
|
60
|
+
|
|
61
|
+
// FLAC: 66 4C 61 43 ("fLaC")
|
|
62
|
+
if (bytes[0] === 0x66 && bytes[1] === 0x4C && bytes[2] === 0x61 && bytes[3] === 0x43) return MIME_AUDIO_FLAC;
|
|
63
|
+
|
|
64
|
+
// M4A / AAC in MP4 container: ftyp box at offset 4
|
|
65
|
+
if (bytes.length > 7 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) return MIME_AUDIO_MP4;
|
|
66
|
+
|
|
67
|
+
return MIME_DEFAULT;
|
|
68
|
+
}
|
|
@@ -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
|
|
|
@@ -71,7 +79,9 @@ 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();
|
|
@@ -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,12 +75,10 @@ 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 only this hook's active request on unmount
|
|
79
|
+
if (abortControllerRef.current) {
|
|
80
|
+
abortControllerRef.current.abort();
|
|
81
|
+
abortControllerRef.current = null;
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
84
|
}, []);
|
|
@@ -88,10 +88,18 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
88
88
|
const stateManager = stateManagerRef.current;
|
|
89
89
|
if (!stateManager || !stateManager.checkMounted()) return null;
|
|
90
90
|
|
|
91
|
+
// Cancel any previous in-flight request from this hook
|
|
92
|
+
if (abortControllerRef.current) {
|
|
93
|
+
abortControllerRef.current.abort();
|
|
94
|
+
}
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
abortControllerRef.current = controller;
|
|
97
|
+
|
|
91
98
|
stateManager.setLastRequest(model, input);
|
|
92
99
|
setIsLoading(true);
|
|
93
100
|
setError(null);
|
|
94
101
|
setData(null);
|
|
102
|
+
setRequestId(null);
|
|
95
103
|
stateManager.setCurrentRequestId(null);
|
|
96
104
|
setIsCancelling(false);
|
|
97
105
|
|
|
@@ -104,6 +112,11 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
104
112
|
stateManager.getCurrentRequestId()
|
|
105
113
|
);
|
|
106
114
|
stateManager.handleQueueUpdate(prunaStatus);
|
|
115
|
+
|
|
116
|
+
// Update reactive requestId from queue status
|
|
117
|
+
if (status.requestId) {
|
|
118
|
+
setRequestId(status.requestId);
|
|
119
|
+
}
|
|
107
120
|
},
|
|
108
121
|
});
|
|
109
122
|
|
|
@@ -121,6 +134,10 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
121
134
|
setIsLoading(false);
|
|
122
135
|
setIsCancelling(false);
|
|
123
136
|
}
|
|
137
|
+
// Clean up controller reference
|
|
138
|
+
if (abortControllerRef.current === controller) {
|
|
139
|
+
abortControllerRef.current = null;
|
|
140
|
+
}
|
|
124
141
|
}
|
|
125
142
|
},
|
|
126
143
|
[]
|
|
@@ -137,9 +154,10 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
137
154
|
}, [generate]);
|
|
138
155
|
|
|
139
156
|
const cancel = useCallback(() => {
|
|
140
|
-
if (
|
|
157
|
+
if (abortControllerRef.current) {
|
|
141
158
|
setIsCancelling(true);
|
|
142
|
-
|
|
159
|
+
abortControllerRef.current.abort();
|
|
160
|
+
abortControllerRef.current = null;
|
|
143
161
|
}
|
|
144
162
|
}, []);
|
|
145
163
|
|
|
@@ -149,11 +167,10 @@ export function usePrunaGeneration<T = unknown>(
|
|
|
149
167
|
setError(null);
|
|
150
168
|
setIsLoading(false);
|
|
151
169
|
setIsCancelling(false);
|
|
170
|
+
setRequestId(null);
|
|
152
171
|
stateManagerRef.current?.clearLastRequest();
|
|
153
172
|
}, [cancel]);
|
|
154
173
|
|
|
155
|
-
const requestId = stateManagerRef.current?.getCurrentRequestId() ?? null;
|
|
156
|
-
|
|
157
174
|
return {
|
|
158
175
|
data,
|
|
159
176
|
error,
|