@umituz/react-native-ai-pruna-provider 1.0.64 → 1.0.66
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/application/dto/pruna.dto.ts +58 -0
- package/src/application/services/pruna-service.ts +71 -0
- package/src/application/use-cases/generate-image-edit.use-case.ts +165 -0
- package/src/application/use-cases/generate-image.use-case.ts +125 -0
- package/src/application/use-cases/generate-video.use-case.ts +147 -0
- package/src/domain/entities/error.types.ts +1 -0
- package/src/domain/services/error-mapper.domain-service.ts +201 -0
- package/src/domain/services/validation.domain-service.ts +93 -0
- package/src/domain/value-objects/api-key.value.ts +25 -0
- package/src/domain/value-objects/model-id.value.ts +29 -0
- package/src/domain/value-objects/session-id.value.ts +18 -0
- package/src/index.ts +1 -1
- package/src/infrastructure/api/http-client.ts +111 -0
- package/src/infrastructure/logging/pruna-logger.ts +89 -0
- package/src/infrastructure/services/pruna-api-client.ts +34 -16
- package/src/infrastructure/services/pruna-provider.ts +0 -21
- package/src/infrastructure/services/request-store.ts +36 -33
- package/src/infrastructure/storage/file-storage.ts +97 -0
- package/src/infrastructure/utils/constants/mime.constants.ts +2 -3
- package/src/infrastructure/utils/log-collector.ts +10 -12
- package/src/init/createAiProviderInitModule.ts +4 -1
- package/src/init/initializePrunaProvider.ts +4 -1
- package/src/presentation/hooks/use-pruna-generation.ts +1 -5
|
@@ -57,19 +57,16 @@ function getRequestKeyCache(): RequestKeyCache {
|
|
|
57
57
|
return globalObj[REQUEST_KEY_CACHE_KEY] as RequestKeyCache;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function sortKeys(obj: unknown): unknown {
|
|
61
|
-
if (obj === null || typeof obj !== "object") return obj;
|
|
62
|
-
if (Array.isArray(obj)) return obj.map(sortKeys);
|
|
63
|
-
const sorted: Record<string, unknown> = {};
|
|
64
|
-
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
|
|
65
|
-
sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
|
|
66
|
-
}
|
|
67
|
-
return sorted;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
60
|
function generateCacheKey(model: string, input: Record<string, unknown>): string {
|
|
71
|
-
// Fast hash using
|
|
72
|
-
|
|
61
|
+
// Fast deterministic hash using sorted property names
|
|
62
|
+
// Much faster than deep object sorting + JSON.stringify
|
|
63
|
+
const keys = Object.keys(input).sort();
|
|
64
|
+
let hash = model;
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
const value = input[key];
|
|
67
|
+
hash += `|${key}:${typeof value === 'object' ? JSON.stringify(value) : String(value)}`;
|
|
68
|
+
}
|
|
69
|
+
return hash;
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
export function createRequestKey(model: string, input: Record<string, unknown>): string {
|
|
@@ -83,20 +80,11 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
|
|
|
83
80
|
return cached.key;
|
|
84
81
|
}
|
|
85
82
|
|
|
86
|
-
// Cache miss or expired - generate new key
|
|
87
|
-
// Use
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
// Use base64 encoding for safer string representation
|
|
92
|
-
// This eliminates collision risk entirely while maintaining readability
|
|
93
|
-
const safeInputStr = inputStr.replace(/[^a-zA-Z0-9]/g, '_');
|
|
94
|
-
|
|
95
|
-
// Use first 64 chars to keep key length manageable while maintaining uniqueness
|
|
96
|
-
const prefix = safeInputStr.substring(0, 64);
|
|
97
|
-
const suffix = safeInputStr.length > 64 ? safeInputStr.slice(-64) : '';
|
|
98
|
-
|
|
99
|
-
const requestKey = `${model}:${prefix}${suffix ? '...' + suffix : ''}`;
|
|
83
|
+
// Cache miss or expired - generate new key using simple hash
|
|
84
|
+
// Use timestamp + random suffix for uniqueness (crypto-friendly)
|
|
85
|
+
const timestamp = Date.now();
|
|
86
|
+
const randomSuffix = Math.random().toString(36).substring(2, 10);
|
|
87
|
+
const requestKey = `${model}_${cacheKey.substring(0, 16)}_${timestamp}_${randomSuffix}`;
|
|
100
88
|
|
|
101
89
|
// Store in cache with LRU eviction
|
|
102
90
|
cache.set(cacheKey, { key: requestKey, timestamp: now });
|
|
@@ -112,13 +100,6 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
|
|
|
112
100
|
return requestKey;
|
|
113
101
|
}
|
|
114
102
|
|
|
115
|
-
export function clearRequestKeyCache(): void {
|
|
116
|
-
const globalObj = globalThis as Record<string, unknown>;
|
|
117
|
-
if (globalObj[REQUEST_KEY_CACHE_KEY]) {
|
|
118
|
-
(globalObj[REQUEST_KEY_CACHE_KEY] as RequestKeyCache).clear();
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
103
|
export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
|
|
123
104
|
return getRequestStore().get(key) as ActiveRequest<T> | undefined;
|
|
124
105
|
}
|
|
@@ -172,6 +153,9 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
|
|
|
172
153
|
stopCleanupTimer();
|
|
173
154
|
}
|
|
174
155
|
|
|
156
|
+
// Also cleanup orphaned requestId mappings
|
|
157
|
+
cleanupRequestIdMappings(maxAge);
|
|
158
|
+
|
|
175
159
|
return cleanedCount;
|
|
176
160
|
}
|
|
177
161
|
|
|
@@ -229,6 +213,25 @@ export function removeRequestIdMapping(requestId: string): void {
|
|
|
229
213
|
getRequestIdMap().delete(requestId);
|
|
230
214
|
}
|
|
231
215
|
|
|
216
|
+
/** Cleanup old requestId mappings to prevent unbounded memory growth */
|
|
217
|
+
export function cleanupRequestIdMappings(maxAge: number = MAX_REQUEST_AGE): number {
|
|
218
|
+
const requestStore = getRequestStore();
|
|
219
|
+
const requestIdMap = getRequestIdMap();
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
let cleanedCount = 0;
|
|
222
|
+
|
|
223
|
+
// Remove mappings for requests that no longer exist or are too old
|
|
224
|
+
for (const [requestId] of requestIdMap.entries()) {
|
|
225
|
+
const request = requestStore.get(requestId);
|
|
226
|
+
if (!request || (now - request.createdAt > maxAge)) {
|
|
227
|
+
requestIdMap.delete(requestId);
|
|
228
|
+
cleanedCount++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return cleanedCount;
|
|
233
|
+
}
|
|
234
|
+
|
|
232
235
|
// Clear any leftover timer on module load (hot reload safety)
|
|
233
236
|
if (typeof globalThis !== "undefined") {
|
|
234
237
|
const existingTimer = getCleanupTimer();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Storage Infrastructure
|
|
3
|
+
* Handles file uploads to Pruna's storage service
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { httpClient } from "../api/http-client";
|
|
7
|
+
import { logger } from "../logging/pruna-logger";
|
|
8
|
+
import { PRUNA_FILES_URL, UPLOAD_CONFIG } from "../services/pruna-provider.constants";
|
|
9
|
+
|
|
10
|
+
export class FileStorageService {
|
|
11
|
+
async uploadFile(
|
|
12
|
+
base64Data: string,
|
|
13
|
+
apiKey: string,
|
|
14
|
+
sessionId: string
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
const log = logger;
|
|
17
|
+
|
|
18
|
+
// Validation
|
|
19
|
+
if (!base64Data?.trim()) {
|
|
20
|
+
log.error(sessionId, 'file-storage', 'Empty file data');
|
|
21
|
+
throw new Error("File data is empty. Provide a base64 string or URL.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Already a URL
|
|
25
|
+
if (base64Data.startsWith('http')) {
|
|
26
|
+
log.info(sessionId, 'file-storage', 'File already a URL', {
|
|
27
|
+
url: base64Data.substring(0, 80) + '...',
|
|
28
|
+
});
|
|
29
|
+
return base64Data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Process base64
|
|
33
|
+
const rawBase64 = this.extractBase64(base64Data);
|
|
34
|
+
const dataUri = this.createDataUri(rawBase64);
|
|
35
|
+
const formData = this.createFormData(dataUri);
|
|
36
|
+
|
|
37
|
+
log.info(sessionId, 'file-storage', 'Uploading file', {
|
|
38
|
+
size: Math.round(rawBase64.length / 1024) + 'KB',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const response = await httpClient.request<{ urls?: { get?: string }; id?: string }>(
|
|
43
|
+
{
|
|
44
|
+
url: PRUNA_FILES_URL,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { apikey: apiKey },
|
|
47
|
+
body: formData,
|
|
48
|
+
timeout: UPLOAD_CONFIG.timeoutMs,
|
|
49
|
+
},
|
|
50
|
+
sessionId,
|
|
51
|
+
'file-storage'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const fileUrl = response.data.urls?.get ||
|
|
55
|
+
(response.data.id ? `${PRUNA_FILES_URL}/${response.data.id}` : PRUNA_FILES_URL);
|
|
56
|
+
|
|
57
|
+
log.info(sessionId, 'file-storage', 'Upload complete', {
|
|
58
|
+
url: fileUrl.substring(0, 80) + '...',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return fileUrl;
|
|
62
|
+
|
|
63
|
+
} catch (error) {
|
|
64
|
+
log.error(sessionId, 'file-storage', 'Upload failed', {
|
|
65
|
+
error: error instanceof Error ? error.message : String(error),
|
|
66
|
+
});
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private extractBase64(data: string): string {
|
|
72
|
+
const base64Index = data.indexOf('base64,');
|
|
73
|
+
return base64Index !== -1 ? data.substring(base64Index + 7) : data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private createDataUri(base64: string): string {
|
|
77
|
+
return `data:image/jpeg;base64,${base64}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private createFormData(dataUri: string): FormData {
|
|
81
|
+
const formData = new FormData();
|
|
82
|
+
const timestamp = Date.now();
|
|
83
|
+
const randomId = Math.random().toString(36).substring(2, 8);
|
|
84
|
+
const fileName = `vivoim_${timestamp}_${randomId}.jpg`;
|
|
85
|
+
|
|
86
|
+
(formData as unknown as { append: (name: string, value: { uri: string; type: string; name: string }) => void })
|
|
87
|
+
.append('content', {
|
|
88
|
+
uri: dataUri,
|
|
89
|
+
type: 'image/jpeg',
|
|
90
|
+
name: fileName,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return formData;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const fileStorageService = new FileStorageService();
|
|
@@ -15,8 +15,7 @@ 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
|
|
19
|
-
export const MIME_DEFAULT = MIME_APPLICATION_OCTET;
|
|
18
|
+
export const MIME_DEFAULT = 'application/octet-stream' as const;
|
|
20
19
|
|
|
21
20
|
/** Maps MIME type → file extension for upload naming */
|
|
22
21
|
export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
|
|
@@ -27,7 +26,7 @@ export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
|
|
|
27
26
|
[MIME_AUDIO_WAV]: 'wav',
|
|
28
27
|
[MIME_AUDIO_FLAC]: 'flac',
|
|
29
28
|
[MIME_AUDIO_MP4]: 'm4a',
|
|
30
|
-
[
|
|
29
|
+
[MIME_DEFAULT]: 'bin',
|
|
31
30
|
};
|
|
32
31
|
|
|
33
32
|
/**
|
|
@@ -30,20 +30,21 @@ const MAX_SESSIONS = 50;
|
|
|
30
30
|
|
|
31
31
|
class GenerationLogCollector {
|
|
32
32
|
private sessions = new Map<string, Session>();
|
|
33
|
-
private sessionQueue
|
|
33
|
+
private sessionQueue = new Set<string>(); // O(1) lookup and deletion
|
|
34
34
|
|
|
35
35
|
startSession(): string {
|
|
36
|
-
// Evict oldest session if limit exceeded
|
|
37
|
-
if (this.sessionQueue.
|
|
38
|
-
const oldestKey = this.sessionQueue.
|
|
36
|
+
// Evict oldest session if limit exceeded
|
|
37
|
+
if (this.sessionQueue.size >= MAX_SESSIONS) {
|
|
38
|
+
const oldestKey = this.sessionQueue.keys().next().value;
|
|
39
39
|
if (oldestKey) {
|
|
40
40
|
this.sessions.delete(oldestKey);
|
|
41
|
+
this.sessionQueue.delete(oldestKey);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const id = `pruna_session_${++sessionCounter}_${Date.now()}`;
|
|
45
46
|
this.sessions.set(id, { startTime: Date.now(), entries: [] });
|
|
46
|
-
this.sessionQueue.
|
|
47
|
+
this.sessionQueue.add(id);
|
|
47
48
|
return id;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -60,20 +61,17 @@ class GenerationLogCollector {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
getEntries(sessionId: string): LogEntry[] {
|
|
63
|
-
return
|
|
64
|
+
return this.sessions.get(sessionId)?.entries ?? [];
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
endSession(sessionId: string): LogEntry[] {
|
|
67
68
|
const session = this.sessions.get(sessionId);
|
|
68
69
|
if (!session) return [];
|
|
69
|
-
const entries =
|
|
70
|
+
const entries = session.entries;
|
|
70
71
|
this.sessions.delete(sessionId);
|
|
71
72
|
|
|
72
|
-
// Remove from queue as well
|
|
73
|
-
|
|
74
|
-
if (queueIndex !== -1) {
|
|
75
|
-
this.sessionQueue.splice(queueIndex, 1);
|
|
76
|
-
}
|
|
73
|
+
// Remove from queue as well (O(1) with Set)
|
|
74
|
+
this.sessionQueue.delete(sessionId);
|
|
77
75
|
|
|
78
76
|
return entries;
|
|
79
77
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { providerRegistry } from '@umituz/react-native-ai-generation-content';
|
|
7
7
|
import { prunaProvider } from '../infrastructure/services/pruna-provider';
|
|
8
|
+
import { logger } from '../infrastructure/logging/pruna-logger';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* InitModule interface (from @umituz/react-native-design-system)
|
|
@@ -89,7 +90,9 @@ export function createAiProviderInitModule(
|
|
|
89
90
|
|
|
90
91
|
return true;
|
|
91
92
|
} catch (error) {
|
|
92
|
-
|
|
93
|
+
logger.error('app-init', 'ai-provider', 'Pruna initialization failed', {
|
|
94
|
+
error: error instanceof Error ? error.message : String(error),
|
|
95
|
+
});
|
|
93
96
|
throw error;
|
|
94
97
|
}
|
|
95
98
|
},
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { providerRegistry } from '@umituz/react-native-ai-generation-content';
|
|
7
7
|
import { prunaProvider } from '../infrastructure/services/pruna-provider';
|
|
8
|
+
import { logger } from '../infrastructure/logging/pruna-logger';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Initializes Pruna provider and registers it with providerRegistry in one call.
|
|
@@ -33,7 +34,9 @@ export function initializePrunaProvider(config: {
|
|
|
33
34
|
|
|
34
35
|
return true;
|
|
35
36
|
} catch (error) {
|
|
36
|
-
|
|
37
|
+
logger.error('app-init', 'pruna-provider', 'Initialization failed', {
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
});
|
|
37
40
|
throw error;
|
|
38
41
|
}
|
|
39
42
|
}
|
|
@@ -34,11 +34,7 @@ function convertJobStatusToPrunaQueueStatus(status: JobStatus, currentRequestId:
|
|
|
34
34
|
return {
|
|
35
35
|
status: status.status as PrunaQueueStatus["status"],
|
|
36
36
|
requestId: status.requestId ?? currentRequestId ?? "",
|
|
37
|
-
logs: status.logs
|
|
38
|
-
message: log.message,
|
|
39
|
-
level: log.level,
|
|
40
|
-
timestamp: log.timestamp,
|
|
41
|
-
})),
|
|
37
|
+
logs: status.logs as PrunaQueueStatus["logs"], // Type-safe cast, no array copy
|
|
42
38
|
};
|
|
43
39
|
}
|
|
44
40
|
|