@umituz/react-native-ai-generation-content 1.5.0 → 1.7.0
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 -2
- package/src/domain/entities/index.ts +1 -0
- package/src/domain/entities/job.types.ts +57 -0
- package/src/index.ts +25 -3
- package/src/presentation/components/GenerationProgressModal.tsx +47 -2
- package/src/presentation/components/PendingJobCard.tsx +223 -0
- package/src/presentation/components/index.ts +9 -0
- package/src/presentation/hooks/index.ts +12 -0
- package/src/presentation/hooks/use-background-generation.ts +164 -0
- package/src/presentation/hooks/use-pending-jobs.ts +123 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-generation-content",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Provider-agnostic AI generation orchestration for React Native",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"orchestration",
|
|
20
20
|
"fal",
|
|
21
21
|
"gemini",
|
|
22
|
-
"provider-agnostic"
|
|
22
|
+
"provider-agnostic",
|
|
23
|
+
"background-jobs",
|
|
24
|
+
"queue"
|
|
23
25
|
],
|
|
24
26
|
"author": "umituz",
|
|
25
27
|
"license": "MIT",
|
|
@@ -28,10 +30,12 @@
|
|
|
28
30
|
"url": "git+https://github.com/umituz/react-native-ai-generation-content.git"
|
|
29
31
|
},
|
|
30
32
|
"peerDependencies": {
|
|
33
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
31
34
|
"react": ">=18.0.0",
|
|
32
35
|
"react-native": ">=0.74.0"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
38
|
+
"@tanstack/react-query": "^5.0.0",
|
|
35
39
|
"@types/react": "^19.0.0",
|
|
36
40
|
"@types/react-native": "^0.73.0",
|
|
37
41
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Job Types
|
|
3
|
+
* Generic job management for AI generation tasks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type BackgroundJobStatus =
|
|
7
|
+
| "queued"
|
|
8
|
+
| "processing"
|
|
9
|
+
| "uploading"
|
|
10
|
+
| "completed"
|
|
11
|
+
| "failed";
|
|
12
|
+
|
|
13
|
+
export interface BackgroundJob<TInput = unknown, TResult = unknown> {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly input: TInput;
|
|
16
|
+
readonly type: string;
|
|
17
|
+
readonly status: BackgroundJobStatus;
|
|
18
|
+
readonly progress: number;
|
|
19
|
+
readonly result?: TResult;
|
|
20
|
+
readonly error?: string;
|
|
21
|
+
readonly createdAt: Date;
|
|
22
|
+
readonly completedAt?: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AddJobInput<TInput = unknown> {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly input: TInput;
|
|
28
|
+
readonly type: string;
|
|
29
|
+
readonly status?: BackgroundJobStatus;
|
|
30
|
+
readonly progress?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UpdateJobInput {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly updates: Partial<Omit<BackgroundJob, "id" | "createdAt">>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JobExecutorConfig<TInput = unknown, TResult = unknown> {
|
|
39
|
+
readonly execute: (input: TInput, onProgress?: (progress: number) => void) => Promise<TResult>;
|
|
40
|
+
readonly onComplete?: (job: BackgroundJob<TInput, TResult>) => Promise<void>;
|
|
41
|
+
readonly onError?: (job: BackgroundJob<TInput, TResult>, error: Error) => Promise<void>;
|
|
42
|
+
readonly timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BackgroundQueueConfig {
|
|
46
|
+
readonly maxConcurrent?: number;
|
|
47
|
+
readonly retryCount?: number;
|
|
48
|
+
readonly retryDelayMs?: number;
|
|
49
|
+
readonly queryKey?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_QUEUE_CONFIG: Required<BackgroundQueueConfig> = {
|
|
53
|
+
maxConcurrent: 1,
|
|
54
|
+
retryCount: 2,
|
|
55
|
+
retryDelayMs: 2000,
|
|
56
|
+
queryKey: ["ai", "background-jobs"],
|
|
57
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -48,9 +48,16 @@ export type {
|
|
|
48
48
|
AfterGenerateHook,
|
|
49
49
|
GenerationMiddleware,
|
|
50
50
|
MiddlewareChain,
|
|
51
|
+
// Background Job Types
|
|
52
|
+
BackgroundJobStatus,
|
|
53
|
+
BackgroundJob,
|
|
54
|
+
AddJobInput,
|
|
55
|
+
UpdateJobInput,
|
|
56
|
+
JobExecutorConfig,
|
|
57
|
+
BackgroundQueueConfig,
|
|
51
58
|
} from "./domain/entities";
|
|
52
59
|
|
|
53
|
-
export { DEFAULT_POLLING_CONFIG, DEFAULT_PROGRESS_STAGES } from "./domain/entities";
|
|
60
|
+
export { DEFAULT_POLLING_CONFIG, DEFAULT_PROGRESS_STAGES, DEFAULT_QUEUE_CONFIG } from "./domain/entities";
|
|
54
61
|
|
|
55
62
|
// =============================================================================
|
|
56
63
|
// INFRASTRUCTURE LAYER - Services
|
|
@@ -126,20 +133,35 @@ export type {
|
|
|
126
133
|
// PRESENTATION LAYER - Hooks
|
|
127
134
|
// =============================================================================
|
|
128
135
|
|
|
129
|
-
export {
|
|
136
|
+
export {
|
|
137
|
+
useGeneration,
|
|
138
|
+
usePendingJobs,
|
|
139
|
+
useBackgroundGeneration,
|
|
140
|
+
} from "./presentation/hooks";
|
|
130
141
|
|
|
131
142
|
export type {
|
|
132
143
|
UseGenerationOptions,
|
|
133
144
|
UseGenerationReturn,
|
|
145
|
+
UsePendingJobsOptions,
|
|
146
|
+
UsePendingJobsReturn,
|
|
147
|
+
UseBackgroundGenerationOptions,
|
|
148
|
+
UseBackgroundGenerationReturn,
|
|
134
149
|
} from "./presentation/hooks";
|
|
135
150
|
|
|
136
151
|
// =============================================================================
|
|
137
152
|
// PRESENTATION LAYER - Components
|
|
138
153
|
// =============================================================================
|
|
139
154
|
|
|
140
|
-
export {
|
|
155
|
+
export {
|
|
156
|
+
GenerationProgressModal,
|
|
157
|
+
PendingJobCard,
|
|
158
|
+
} from "./presentation/components";
|
|
141
159
|
|
|
142
160
|
export type {
|
|
143
161
|
GenerationProgressModalProps,
|
|
144
162
|
GenerationProgressRenderProps,
|
|
163
|
+
PendingJobCardProps,
|
|
164
|
+
PendingJobCardStyles,
|
|
165
|
+
PendingJobCardColors,
|
|
166
|
+
StatusLabels,
|
|
145
167
|
} from "./presentation/components";
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React from "react";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
Modal,
|
|
9
|
+
View,
|
|
10
|
+
Text,
|
|
11
|
+
TouchableOpacity,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
} from "react-native";
|
|
8
14
|
|
|
9
15
|
export interface GenerationProgressModalProps {
|
|
10
16
|
visible: boolean;
|
|
@@ -12,11 +18,14 @@ export interface GenerationProgressModalProps {
|
|
|
12
18
|
title?: string;
|
|
13
19
|
message?: string;
|
|
14
20
|
hint?: string;
|
|
21
|
+
dismissLabel?: string;
|
|
22
|
+
onDismiss?: () => void;
|
|
15
23
|
overlayColor?: string;
|
|
16
24
|
modalBackgroundColor?: string;
|
|
17
25
|
textColor?: string;
|
|
18
26
|
progressColor?: string;
|
|
19
27
|
progressBackgroundColor?: string;
|
|
28
|
+
dismissButtonColor?: string;
|
|
20
29
|
renderContent?: (props: GenerationProgressRenderProps) => React.ReactNode;
|
|
21
30
|
}
|
|
22
31
|
|
|
@@ -25,6 +34,7 @@ export interface GenerationProgressRenderProps {
|
|
|
25
34
|
title?: string;
|
|
26
35
|
message?: string;
|
|
27
36
|
hint?: string;
|
|
37
|
+
onDismiss?: () => void;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export const GenerationProgressModal: React.FC<
|
|
@@ -35,11 +45,14 @@ export const GenerationProgressModal: React.FC<
|
|
|
35
45
|
title,
|
|
36
46
|
message,
|
|
37
47
|
hint,
|
|
48
|
+
dismissLabel = "Got it",
|
|
49
|
+
onDismiss,
|
|
38
50
|
overlayColor = "rgba(0, 0, 0, 0.7)",
|
|
39
51
|
modalBackgroundColor = "#1C1C1E",
|
|
40
52
|
textColor = "#FFFFFF",
|
|
41
53
|
progressColor = "#007AFF",
|
|
42
54
|
progressBackgroundColor = "#3A3A3C",
|
|
55
|
+
dismissButtonColor = "#007AFF",
|
|
43
56
|
renderContent,
|
|
44
57
|
}) => {
|
|
45
58
|
const clampedProgress = Math.max(0, Math.min(100, progress));
|
|
@@ -53,7 +66,13 @@ export const GenerationProgressModal: React.FC<
|
|
|
53
66
|
statusBarTranslucent
|
|
54
67
|
>
|
|
55
68
|
<View style={[styles.overlay, { backgroundColor: overlayColor }]}>
|
|
56
|
-
{renderContent({
|
|
69
|
+
{renderContent({
|
|
70
|
+
progress: clampedProgress,
|
|
71
|
+
title,
|
|
72
|
+
message,
|
|
73
|
+
hint,
|
|
74
|
+
onDismiss,
|
|
75
|
+
})}
|
|
57
76
|
</View>
|
|
58
77
|
</Modal>
|
|
59
78
|
);
|
|
@@ -105,6 +124,18 @@ export const GenerationProgressModal: React.FC<
|
|
|
105
124
|
{hint && (
|
|
106
125
|
<Text style={[styles.hint, { color: textColor }]}>{hint}</Text>
|
|
107
126
|
)}
|
|
127
|
+
|
|
128
|
+
{onDismiss && (
|
|
129
|
+
<TouchableOpacity
|
|
130
|
+
style={[
|
|
131
|
+
styles.dismissButton,
|
|
132
|
+
{ backgroundColor: dismissButtonColor },
|
|
133
|
+
]}
|
|
134
|
+
onPress={onDismiss}
|
|
135
|
+
>
|
|
136
|
+
<Text style={styles.dismissText}>{dismissLabel}</Text>
|
|
137
|
+
</TouchableOpacity>
|
|
138
|
+
)}
|
|
108
139
|
</View>
|
|
109
140
|
</View>
|
|
110
141
|
</Modal>
|
|
@@ -162,5 +193,19 @@ const styles = StyleSheet.create({
|
|
|
162
193
|
textAlign: "center",
|
|
163
194
|
fontStyle: "italic",
|
|
164
195
|
opacity: 0.6,
|
|
196
|
+
marginBottom: 16,
|
|
197
|
+
},
|
|
198
|
+
dismissButton: {
|
|
199
|
+
marginTop: 8,
|
|
200
|
+
paddingVertical: 14,
|
|
201
|
+
paddingHorizontal: 32,
|
|
202
|
+
borderRadius: 12,
|
|
203
|
+
minWidth: 140,
|
|
204
|
+
alignItems: "center",
|
|
205
|
+
},
|
|
206
|
+
dismissText: {
|
|
207
|
+
color: "#FFFFFF",
|
|
208
|
+
fontSize: 16,
|
|
209
|
+
fontWeight: "600",
|
|
165
210
|
},
|
|
166
211
|
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PendingJobCard Component
|
|
3
|
+
* Displays a pending background job with progress
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import {
|
|
8
|
+
View,
|
|
9
|
+
Text,
|
|
10
|
+
TouchableOpacity,
|
|
11
|
+
ActivityIndicator,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
} from "react-native";
|
|
14
|
+
import type { BackgroundJob } from "../../domain/entities/job.types";
|
|
15
|
+
|
|
16
|
+
export interface PendingJobCardStyles {
|
|
17
|
+
readonly card?: object;
|
|
18
|
+
readonly content?: object;
|
|
19
|
+
readonly header?: object;
|
|
20
|
+
readonly typeText?: object;
|
|
21
|
+
readonly statusText?: object;
|
|
22
|
+
readonly progressBar?: object;
|
|
23
|
+
readonly progressFill?: object;
|
|
24
|
+
readonly actions?: object;
|
|
25
|
+
readonly actionButton?: object;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PendingJobCardColors {
|
|
29
|
+
readonly background?: string;
|
|
30
|
+
readonly text?: string;
|
|
31
|
+
readonly textSecondary?: string;
|
|
32
|
+
readonly error?: string;
|
|
33
|
+
readonly progressBackground?: string;
|
|
34
|
+
readonly progressFill?: string;
|
|
35
|
+
readonly actionBackground?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StatusLabels {
|
|
39
|
+
readonly queued?: string;
|
|
40
|
+
readonly processing?: string;
|
|
41
|
+
readonly uploading?: string;
|
|
42
|
+
readonly completed?: string;
|
|
43
|
+
readonly failed?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PendingJobCardProps<TInput = unknown, TResult = unknown> {
|
|
47
|
+
readonly job: BackgroundJob<TInput, TResult>;
|
|
48
|
+
readonly onCancel?: (id: string) => void;
|
|
49
|
+
readonly onRetry?: (id: string) => void;
|
|
50
|
+
readonly typeLabel?: string;
|
|
51
|
+
readonly statusLabels?: StatusLabels;
|
|
52
|
+
readonly colors?: PendingJobCardColors;
|
|
53
|
+
readonly styles?: PendingJobCardStyles;
|
|
54
|
+
readonly renderThumbnail?: (job: BackgroundJob<TInput, TResult>) => React.ReactNode;
|
|
55
|
+
readonly renderActions?: (job: BackgroundJob<TInput, TResult>) => React.ReactNode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_STATUS_LABELS: StatusLabels = {
|
|
59
|
+
queued: "Waiting in queue...",
|
|
60
|
+
processing: "Processing...",
|
|
61
|
+
uploading: "Uploading...",
|
|
62
|
+
completed: "Completed",
|
|
63
|
+
failed: "Failed",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const DEFAULT_COLORS: Required<PendingJobCardColors> = {
|
|
67
|
+
background: "#1C1C1E",
|
|
68
|
+
text: "#FFFFFF",
|
|
69
|
+
textSecondary: "#8E8E93",
|
|
70
|
+
error: "#FF453A",
|
|
71
|
+
progressBackground: "#3A3A3C",
|
|
72
|
+
progressFill: "#007AFF",
|
|
73
|
+
actionBackground: "#2C2C2E",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function PendingJobCard<TInput = unknown, TResult = unknown>({
|
|
77
|
+
job,
|
|
78
|
+
onCancel,
|
|
79
|
+
onRetry,
|
|
80
|
+
typeLabel,
|
|
81
|
+
statusLabels = DEFAULT_STATUS_LABELS,
|
|
82
|
+
colors = {},
|
|
83
|
+
styles: customStyles = {},
|
|
84
|
+
renderThumbnail,
|
|
85
|
+
renderActions,
|
|
86
|
+
}: PendingJobCardProps<TInput, TResult>): React.ReactElement {
|
|
87
|
+
const mergedColors = { ...DEFAULT_COLORS, ...colors };
|
|
88
|
+
const isFailed = job.status === "failed";
|
|
89
|
+
|
|
90
|
+
const statusText =
|
|
91
|
+
job.status === "failed"
|
|
92
|
+
? job.error || statusLabels.failed
|
|
93
|
+
: statusLabels[job.status] || statusLabels.processing;
|
|
94
|
+
|
|
95
|
+
const styles = StyleSheet.create({
|
|
96
|
+
card: {
|
|
97
|
+
flexDirection: "row",
|
|
98
|
+
backgroundColor: mergedColors.background,
|
|
99
|
+
borderRadius: 16,
|
|
100
|
+
overflow: "hidden",
|
|
101
|
+
opacity: isFailed ? 0.7 : 1,
|
|
102
|
+
...((customStyles.card as object) || {}),
|
|
103
|
+
},
|
|
104
|
+
content: {
|
|
105
|
+
flex: 1,
|
|
106
|
+
padding: 12,
|
|
107
|
+
justifyContent: "space-between",
|
|
108
|
+
...((customStyles.content as object) || {}),
|
|
109
|
+
},
|
|
110
|
+
header: {
|
|
111
|
+
flexDirection: "row",
|
|
112
|
+
alignItems: "center",
|
|
113
|
+
gap: 8,
|
|
114
|
+
...((customStyles.header as object) || {}),
|
|
115
|
+
},
|
|
116
|
+
typeText: {
|
|
117
|
+
fontSize: 14,
|
|
118
|
+
fontWeight: "600",
|
|
119
|
+
color: mergedColors.text,
|
|
120
|
+
...((customStyles.typeText as object) || {}),
|
|
121
|
+
},
|
|
122
|
+
statusText: {
|
|
123
|
+
fontSize: 12,
|
|
124
|
+
color: isFailed ? mergedColors.error : mergedColors.textSecondary,
|
|
125
|
+
marginTop: 4,
|
|
126
|
+
...((customStyles.statusText as object) || {}),
|
|
127
|
+
},
|
|
128
|
+
progressBar: {
|
|
129
|
+
height: 4,
|
|
130
|
+
backgroundColor: mergedColors.progressBackground,
|
|
131
|
+
borderRadius: 2,
|
|
132
|
+
marginTop: 8,
|
|
133
|
+
overflow: "hidden",
|
|
134
|
+
...((customStyles.progressBar as object) || {}),
|
|
135
|
+
},
|
|
136
|
+
progressFill: {
|
|
137
|
+
height: "100%",
|
|
138
|
+
backgroundColor: mergedColors.progressFill,
|
|
139
|
+
borderRadius: 2,
|
|
140
|
+
width: `${job.progress}%`,
|
|
141
|
+
...((customStyles.progressFill as object) || {}),
|
|
142
|
+
},
|
|
143
|
+
actions: {
|
|
144
|
+
flexDirection: "row",
|
|
145
|
+
gap: 8,
|
|
146
|
+
marginTop: 8,
|
|
147
|
+
...((customStyles.actions as object) || {}),
|
|
148
|
+
},
|
|
149
|
+
actionButton: {
|
|
150
|
+
width: 32,
|
|
151
|
+
height: 32,
|
|
152
|
+
borderRadius: 16,
|
|
153
|
+
backgroundColor: mergedColors.actionBackground,
|
|
154
|
+
justifyContent: "center",
|
|
155
|
+
alignItems: "center",
|
|
156
|
+
...((customStyles.actionButton as object) || {}),
|
|
157
|
+
},
|
|
158
|
+
thumbnail: {
|
|
159
|
+
width: 80,
|
|
160
|
+
height: 80,
|
|
161
|
+
justifyContent: "center",
|
|
162
|
+
alignItems: "center",
|
|
163
|
+
backgroundColor: mergedColors.progressBackground,
|
|
164
|
+
},
|
|
165
|
+
loader: {
|
|
166
|
+
position: "absolute",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<View style={styles.card}>
|
|
172
|
+
{renderThumbnail && (
|
|
173
|
+
<View style={styles.thumbnail}>
|
|
174
|
+
{renderThumbnail(job)}
|
|
175
|
+
{!isFailed && (
|
|
176
|
+
<ActivityIndicator
|
|
177
|
+
color={mergedColors.text}
|
|
178
|
+
size="small"
|
|
179
|
+
style={styles.loader}
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
</View>
|
|
183
|
+
)}
|
|
184
|
+
<View style={styles.content}>
|
|
185
|
+
<View>
|
|
186
|
+
<View style={styles.header}>
|
|
187
|
+
{typeLabel && <Text style={styles.typeText}>{typeLabel}</Text>}
|
|
188
|
+
</View>
|
|
189
|
+
<Text style={styles.statusText} numberOfLines={1}>
|
|
190
|
+
{statusText}
|
|
191
|
+
</Text>
|
|
192
|
+
{!isFailed && (
|
|
193
|
+
<View style={styles.progressBar}>
|
|
194
|
+
<View style={styles.progressFill} />
|
|
195
|
+
</View>
|
|
196
|
+
)}
|
|
197
|
+
</View>
|
|
198
|
+
{renderActions ? (
|
|
199
|
+
renderActions(job)
|
|
200
|
+
) : (
|
|
201
|
+
<View style={styles.actions}>
|
|
202
|
+
{isFailed && onRetry && (
|
|
203
|
+
<TouchableOpacity
|
|
204
|
+
style={styles.actionButton}
|
|
205
|
+
onPress={() => onRetry(job.id)}
|
|
206
|
+
>
|
|
207
|
+
<Text style={{ color: mergedColors.text }}>↻</Text>
|
|
208
|
+
</TouchableOpacity>
|
|
209
|
+
)}
|
|
210
|
+
{onCancel && (
|
|
211
|
+
<TouchableOpacity
|
|
212
|
+
style={styles.actionButton}
|
|
213
|
+
onPress={() => onCancel(job.id)}
|
|
214
|
+
>
|
|
215
|
+
<Text style={{ color: mergedColors.error }}>✕</Text>
|
|
216
|
+
</TouchableOpacity>
|
|
217
|
+
)}
|
|
218
|
+
</View>
|
|
219
|
+
)}
|
|
220
|
+
</View>
|
|
221
|
+
</View>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -10,3 +10,12 @@ export type {
|
|
|
10
10
|
GenerationProgressModalProps,
|
|
11
11
|
GenerationProgressRenderProps,
|
|
12
12
|
} from "./GenerationProgressModal";
|
|
13
|
+
|
|
14
|
+
export { PendingJobCard } from "./PendingJobCard";
|
|
15
|
+
|
|
16
|
+
export type {
|
|
17
|
+
PendingJobCardProps,
|
|
18
|
+
PendingJobCardStyles,
|
|
19
|
+
PendingJobCardColors,
|
|
20
|
+
StatusLabels,
|
|
21
|
+
} from "./PendingJobCard";
|
|
@@ -7,3 +7,15 @@ export type {
|
|
|
7
7
|
UseGenerationOptions,
|
|
8
8
|
UseGenerationReturn,
|
|
9
9
|
} from "./use-generation";
|
|
10
|
+
|
|
11
|
+
export { usePendingJobs } from "./use-pending-jobs";
|
|
12
|
+
export type {
|
|
13
|
+
UsePendingJobsOptions,
|
|
14
|
+
UsePendingJobsReturn,
|
|
15
|
+
} from "./use-pending-jobs";
|
|
16
|
+
|
|
17
|
+
export { useBackgroundGeneration } from "./use-background-generation";
|
|
18
|
+
export type {
|
|
19
|
+
UseBackgroundGenerationOptions,
|
|
20
|
+
UseBackgroundGenerationReturn,
|
|
21
|
+
} from "./use-background-generation";
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useBackgroundGeneration Hook
|
|
3
|
+
* Executes AI generation tasks in the background with queue management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useRef } from "react";
|
|
7
|
+
import { usePendingJobs } from "./use-pending-jobs";
|
|
8
|
+
import type {
|
|
9
|
+
BackgroundJob,
|
|
10
|
+
JobExecutorConfig,
|
|
11
|
+
BackgroundQueueConfig,
|
|
12
|
+
} from "../../domain/entities/job.types";
|
|
13
|
+
import { DEFAULT_QUEUE_CONFIG } from "../../domain/entities/job.types";
|
|
14
|
+
|
|
15
|
+
export interface UseBackgroundGenerationOptions<TInput, TResult>
|
|
16
|
+
extends Partial<BackgroundQueueConfig> {
|
|
17
|
+
readonly executor: JobExecutorConfig<TInput, TResult>;
|
|
18
|
+
readonly onJobComplete?: (job: BackgroundJob<TInput, TResult>) => void;
|
|
19
|
+
readonly onJobError?: (job: BackgroundJob<TInput, TResult>) => void;
|
|
20
|
+
readonly onAllComplete?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseBackgroundGenerationReturn<TInput, TResult> {
|
|
24
|
+
readonly startJob: (input: TInput, type: string) => Promise<string>;
|
|
25
|
+
readonly cancelJob: (id: string) => void;
|
|
26
|
+
readonly retryJob: (id: string) => void;
|
|
27
|
+
readonly pendingJobs: BackgroundJob<TInput, TResult>[];
|
|
28
|
+
readonly activeJobCount: number;
|
|
29
|
+
readonly hasActiveJobs: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useBackgroundGeneration<TInput = unknown, TResult = unknown>(
|
|
33
|
+
options: UseBackgroundGenerationOptions<TInput, TResult>,
|
|
34
|
+
): UseBackgroundGenerationReturn<TInput, TResult> {
|
|
35
|
+
const config = { ...DEFAULT_QUEUE_CONFIG, ...options };
|
|
36
|
+
const activeJobsRef = useRef<Set<string>>(new Set());
|
|
37
|
+
const jobInputsRef = useRef<Map<string, { input: TInput; type: string }>>(
|
|
38
|
+
new Map(),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
jobs,
|
|
43
|
+
addJobAsync,
|
|
44
|
+
updateJob,
|
|
45
|
+
removeJob,
|
|
46
|
+
getJob,
|
|
47
|
+
} = usePendingJobs<TInput, TResult>({
|
|
48
|
+
queryKey: config.queryKey,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const executeJob = useCallback(
|
|
52
|
+
async (jobId: string, input: TInput) => {
|
|
53
|
+
const { executor } = options;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
updateJob({
|
|
57
|
+
id: jobId,
|
|
58
|
+
updates: { status: "processing", progress: 10 },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await executor.execute(input, (progress) => {
|
|
62
|
+
updateJob({ id: jobId, updates: { progress } });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
updateJob({
|
|
66
|
+
id: jobId,
|
|
67
|
+
updates: {
|
|
68
|
+
status: "completed",
|
|
69
|
+
progress: 100,
|
|
70
|
+
result,
|
|
71
|
+
completedAt: new Date(),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const completedJob = getJob(jobId);
|
|
76
|
+
if (completedJob) {
|
|
77
|
+
await executor.onComplete?.(completedJob);
|
|
78
|
+
options.onJobComplete?.(completedJob);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
removeJob(jobId);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
84
|
+
|
|
85
|
+
updateJob({
|
|
86
|
+
id: jobId,
|
|
87
|
+
updates: {
|
|
88
|
+
status: "failed",
|
|
89
|
+
error: errorMsg,
|
|
90
|
+
progress: 0,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const failedJob = getJob(jobId);
|
|
95
|
+
if (failedJob) {
|
|
96
|
+
await executor.onError?.(
|
|
97
|
+
failedJob,
|
|
98
|
+
error instanceof Error ? error : new Error(errorMsg),
|
|
99
|
+
);
|
|
100
|
+
options.onJobError?.(failedJob);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
activeJobsRef.current.delete(jobId);
|
|
104
|
+
|
|
105
|
+
if (activeJobsRef.current.size === 0) {
|
|
106
|
+
options.onAllComplete?.();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[options, updateJob, removeJob, getJob],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const startJob = useCallback(
|
|
114
|
+
async (input: TInput, type: string): Promise<string> => {
|
|
115
|
+
const jobId = `job-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
116
|
+
|
|
117
|
+
jobInputsRef.current.set(jobId, { input, type });
|
|
118
|
+
|
|
119
|
+
await addJobAsync({
|
|
120
|
+
id: jobId,
|
|
121
|
+
input,
|
|
122
|
+
type,
|
|
123
|
+
status: "queued",
|
|
124
|
+
progress: 0,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
activeJobsRef.current.add(jobId);
|
|
128
|
+
|
|
129
|
+
executeJob(jobId, input);
|
|
130
|
+
|
|
131
|
+
return jobId;
|
|
132
|
+
},
|
|
133
|
+
[addJobAsync, executeJob],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const cancelJob = useCallback(
|
|
137
|
+
(id: string) => {
|
|
138
|
+
activeJobsRef.current.delete(id);
|
|
139
|
+
jobInputsRef.current.delete(id);
|
|
140
|
+
removeJob(id);
|
|
141
|
+
},
|
|
142
|
+
[removeJob],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const retryJob = useCallback(
|
|
146
|
+
(id: string) => {
|
|
147
|
+
const jobData = jobInputsRef.current.get(id);
|
|
148
|
+
if (!jobData) return;
|
|
149
|
+
|
|
150
|
+
removeJob(id);
|
|
151
|
+
startJob(jobData.input, jobData.type);
|
|
152
|
+
},
|
|
153
|
+
[removeJob, startJob],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
startJob,
|
|
158
|
+
cancelJob,
|
|
159
|
+
retryJob,
|
|
160
|
+
pendingJobs: jobs,
|
|
161
|
+
activeJobCount: activeJobsRef.current.size,
|
|
162
|
+
hasActiveJobs: activeJobsRef.current.size > 0,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePendingJobs Hook
|
|
3
|
+
* Generic pending job management with TanStack Query
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import type {
|
|
8
|
+
BackgroundJob,
|
|
9
|
+
AddJobInput,
|
|
10
|
+
UpdateJobInput,
|
|
11
|
+
} from "../../domain/entities/job.types";
|
|
12
|
+
import { DEFAULT_QUEUE_CONFIG } from "../../domain/entities/job.types";
|
|
13
|
+
|
|
14
|
+
export interface UsePendingJobsOptions {
|
|
15
|
+
readonly queryKey?: readonly string[];
|
|
16
|
+
readonly enabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsePendingJobsReturn<TInput = unknown, TResult = unknown> {
|
|
20
|
+
readonly jobs: BackgroundJob<TInput, TResult>[];
|
|
21
|
+
readonly hasJobs: boolean;
|
|
22
|
+
readonly addJob: (input: AddJobInput<TInput>) => void;
|
|
23
|
+
readonly addJobAsync: (input: AddJobInput<TInput>) => Promise<BackgroundJob<TInput, TResult>>;
|
|
24
|
+
readonly updateJob: (input: UpdateJobInput) => void;
|
|
25
|
+
readonly removeJob: (id: string) => void;
|
|
26
|
+
readonly clearCompleted: () => void;
|
|
27
|
+
readonly clearFailed: () => void;
|
|
28
|
+
readonly getJob: (id: string) => BackgroundJob<TInput, TResult> | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function usePendingJobs<TInput = unknown, TResult = unknown>(
|
|
32
|
+
options: UsePendingJobsOptions = {},
|
|
33
|
+
): UsePendingJobsReturn<TInput, TResult> {
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
const queryKey = options.queryKey ?? DEFAULT_QUEUE_CONFIG.queryKey;
|
|
36
|
+
|
|
37
|
+
const { data: jobs = [] } = useQuery<BackgroundJob<TInput, TResult>[]>({
|
|
38
|
+
queryKey,
|
|
39
|
+
queryFn: () => [],
|
|
40
|
+
staleTime: Infinity,
|
|
41
|
+
gcTime: 30 * 60 * 1000,
|
|
42
|
+
enabled: options.enabled !== false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const addJobMutation = useMutation({
|
|
46
|
+
mutationFn: async (input: AddJobInput<TInput>) => {
|
|
47
|
+
const newJob: BackgroundJob<TInput, TResult> = {
|
|
48
|
+
id: input.id,
|
|
49
|
+
input: input.input,
|
|
50
|
+
type: input.type,
|
|
51
|
+
status: input.status ?? "queued",
|
|
52
|
+
progress: input.progress ?? 0,
|
|
53
|
+
createdAt: new Date(),
|
|
54
|
+
};
|
|
55
|
+
return newJob;
|
|
56
|
+
},
|
|
57
|
+
onSuccess: (newJob) => {
|
|
58
|
+
queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
|
|
59
|
+
queryKey,
|
|
60
|
+
(old) => [newJob, ...(old ?? [])],
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const updateJobMutation = useMutation({
|
|
66
|
+
mutationFn: async ({ id, updates }: UpdateJobInput) => ({ id, updates }),
|
|
67
|
+
onSuccess: ({ id, updates }) => {
|
|
68
|
+
queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
|
|
69
|
+
queryKey,
|
|
70
|
+
(old) => {
|
|
71
|
+
if (!old) return [];
|
|
72
|
+
return old.map((job) =>
|
|
73
|
+
job.id === id ? ({ ...job, ...updates } as BackgroundJob<TInput, TResult>) : job,
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const removeJobMutation = useMutation({
|
|
81
|
+
mutationFn: async (id: string) => id,
|
|
82
|
+
onSuccess: (id) => {
|
|
83
|
+
queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
|
|
84
|
+
queryKey,
|
|
85
|
+
(old) => old?.filter((job) => job.id !== id) ?? [],
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const clearCompletedMutation = useMutation({
|
|
91
|
+
mutationFn: async () => null,
|
|
92
|
+
onSuccess: () => {
|
|
93
|
+
queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
|
|
94
|
+
queryKey,
|
|
95
|
+
(old) => old?.filter((job) => job.status !== "completed") ?? [],
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const clearFailedMutation = useMutation({
|
|
101
|
+
mutationFn: async () => null,
|
|
102
|
+
onSuccess: () => {
|
|
103
|
+
queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
|
|
104
|
+
queryKey,
|
|
105
|
+
(old) => old?.filter((job) => job.status !== "failed") ?? [],
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const getJob = (id: string) => jobs.find((job) => job.id === id);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
jobs,
|
|
114
|
+
hasJobs: jobs.length > 0,
|
|
115
|
+
addJob: addJobMutation.mutate,
|
|
116
|
+
addJobAsync: addJobMutation.mutateAsync,
|
|
117
|
+
updateJob: updateJobMutation.mutate,
|
|
118
|
+
removeJob: removeJobMutation.mutate,
|
|
119
|
+
clearCompleted: clearCompletedMutation.mutate,
|
|
120
|
+
clearFailed: clearFailedMutation.mutate,
|
|
121
|
+
getJob,
|
|
122
|
+
};
|
|
123
|
+
}
|