@umituz/react-native-ai-generation-content 1.4.0 → 1.6.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 +10 -3
- package/src/domain/entities/index.ts +1 -0
- package/src/domain/entities/job.types.ts +57 -0
- package/src/index.ts +35 -2
- package/src/presentation/components/GenerationProgressModal.tsx +166 -0
- package/src/presentation/components/PendingJobCard.tsx +223 -0
- package/src/presentation/components/index.ts +21 -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.6.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,13 +30,18 @@
|
|
|
28
30
|
"url": "git+https://github.com/umituz/react-native-ai-generation-content.git"
|
|
29
31
|
},
|
|
30
32
|
"peerDependencies": {
|
|
31
|
-
"react": ">=
|
|
33
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
34
|
+
"react": ">=18.0.0",
|
|
35
|
+
"react-native": ">=0.74.0"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
38
|
+
"@tanstack/react-query": "^5.0.0",
|
|
34
39
|
"@types/react": "^19.0.0",
|
|
40
|
+
"@types/react-native": "^0.73.0",
|
|
35
41
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
36
42
|
"@typescript-eslint/parser": "^7.0.0",
|
|
37
43
|
"eslint": "^8.57.0",
|
|
44
|
+
"react-native": "^0.76.0",
|
|
38
45
|
"typescript": "^5.3.0"
|
|
39
46
|
},
|
|
40
47
|
"publishConfig": {
|
|
@@ -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,9 +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";
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// PRESENTATION LAYER - Components
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
GenerationProgressModal,
|
|
157
|
+
PendingJobCard,
|
|
158
|
+
} from "./presentation/components";
|
|
159
|
+
|
|
160
|
+
export type {
|
|
161
|
+
GenerationProgressModalProps,
|
|
162
|
+
GenerationProgressRenderProps,
|
|
163
|
+
PendingJobCardProps,
|
|
164
|
+
PendingJobCardStyles,
|
|
165
|
+
PendingJobCardColors,
|
|
166
|
+
StatusLabels,
|
|
167
|
+
} from "./presentation/components";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GenerationProgressModal
|
|
3
|
+
* Generic AI generation progress modal with customizable rendering
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Modal, View, Text, StyleSheet } from "react-native";
|
|
8
|
+
|
|
9
|
+
export interface GenerationProgressModalProps {
|
|
10
|
+
visible: boolean;
|
|
11
|
+
progress: number;
|
|
12
|
+
title?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
overlayColor?: string;
|
|
16
|
+
modalBackgroundColor?: string;
|
|
17
|
+
textColor?: string;
|
|
18
|
+
progressColor?: string;
|
|
19
|
+
progressBackgroundColor?: string;
|
|
20
|
+
renderContent?: (props: GenerationProgressRenderProps) => React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GenerationProgressRenderProps {
|
|
24
|
+
progress: number;
|
|
25
|
+
title?: string;
|
|
26
|
+
message?: string;
|
|
27
|
+
hint?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const GenerationProgressModal: React.FC<
|
|
31
|
+
GenerationProgressModalProps
|
|
32
|
+
> = ({
|
|
33
|
+
visible,
|
|
34
|
+
progress,
|
|
35
|
+
title,
|
|
36
|
+
message,
|
|
37
|
+
hint,
|
|
38
|
+
overlayColor = "rgba(0, 0, 0, 0.7)",
|
|
39
|
+
modalBackgroundColor = "#1C1C1E",
|
|
40
|
+
textColor = "#FFFFFF",
|
|
41
|
+
progressColor = "#007AFF",
|
|
42
|
+
progressBackgroundColor = "#3A3A3C",
|
|
43
|
+
renderContent,
|
|
44
|
+
}) => {
|
|
45
|
+
const clampedProgress = Math.max(0, Math.min(100, progress));
|
|
46
|
+
|
|
47
|
+
if (renderContent) {
|
|
48
|
+
return (
|
|
49
|
+
<Modal
|
|
50
|
+
visible={visible}
|
|
51
|
+
transparent
|
|
52
|
+
animationType="fade"
|
|
53
|
+
statusBarTranslucent
|
|
54
|
+
>
|
|
55
|
+
<View style={[styles.overlay, { backgroundColor: overlayColor }]}>
|
|
56
|
+
{renderContent({ progress: clampedProgress, title, message, hint })}
|
|
57
|
+
</View>
|
|
58
|
+
</Modal>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Modal
|
|
64
|
+
visible={visible}
|
|
65
|
+
transparent
|
|
66
|
+
animationType="fade"
|
|
67
|
+
statusBarTranslucent
|
|
68
|
+
>
|
|
69
|
+
<View style={[styles.overlay, { backgroundColor: overlayColor }]}>
|
|
70
|
+
<View
|
|
71
|
+
style={[styles.modal, { backgroundColor: modalBackgroundColor }]}
|
|
72
|
+
>
|
|
73
|
+
{title && (
|
|
74
|
+
<Text style={[styles.title, { color: textColor }]}>{title}</Text>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{message && (
|
|
78
|
+
<Text style={[styles.message, { color: textColor }]}>
|
|
79
|
+
{message}
|
|
80
|
+
</Text>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
<View style={styles.progressContainer}>
|
|
84
|
+
<View
|
|
85
|
+
style={[
|
|
86
|
+
styles.progressBackground,
|
|
87
|
+
{ backgroundColor: progressBackgroundColor },
|
|
88
|
+
]}
|
|
89
|
+
>
|
|
90
|
+
<View
|
|
91
|
+
style={[
|
|
92
|
+
styles.progressFill,
|
|
93
|
+
{
|
|
94
|
+
backgroundColor: progressColor,
|
|
95
|
+
width: `${clampedProgress}%`,
|
|
96
|
+
},
|
|
97
|
+
]}
|
|
98
|
+
/>
|
|
99
|
+
</View>
|
|
100
|
+
<Text style={[styles.progressText, { color: textColor }]}>
|
|
101
|
+
{Math.round(clampedProgress)}%
|
|
102
|
+
</Text>
|
|
103
|
+
</View>
|
|
104
|
+
|
|
105
|
+
{hint && (
|
|
106
|
+
<Text style={[styles.hint, { color: textColor }]}>{hint}</Text>
|
|
107
|
+
)}
|
|
108
|
+
</View>
|
|
109
|
+
</View>
|
|
110
|
+
</Modal>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const styles = StyleSheet.create({
|
|
115
|
+
overlay: {
|
|
116
|
+
flex: 1,
|
|
117
|
+
justifyContent: "center",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
padding: 20,
|
|
120
|
+
},
|
|
121
|
+
modal: {
|
|
122
|
+
width: "100%",
|
|
123
|
+
maxWidth: 400,
|
|
124
|
+
borderRadius: 24,
|
|
125
|
+
padding: 32,
|
|
126
|
+
alignItems: "center",
|
|
127
|
+
},
|
|
128
|
+
title: {
|
|
129
|
+
fontSize: 20,
|
|
130
|
+
fontWeight: "700",
|
|
131
|
+
marginBottom: 12,
|
|
132
|
+
textAlign: "center",
|
|
133
|
+
},
|
|
134
|
+
message: {
|
|
135
|
+
fontSize: 16,
|
|
136
|
+
marginBottom: 24,
|
|
137
|
+
textAlign: "center",
|
|
138
|
+
opacity: 0.8,
|
|
139
|
+
},
|
|
140
|
+
progressContainer: {
|
|
141
|
+
width: "100%",
|
|
142
|
+
marginBottom: 16,
|
|
143
|
+
alignItems: "center",
|
|
144
|
+
},
|
|
145
|
+
progressBackground: {
|
|
146
|
+
width: "100%",
|
|
147
|
+
height: 8,
|
|
148
|
+
borderRadius: 4,
|
|
149
|
+
overflow: "hidden",
|
|
150
|
+
},
|
|
151
|
+
progressFill: {
|
|
152
|
+
height: "100%",
|
|
153
|
+
borderRadius: 4,
|
|
154
|
+
},
|
|
155
|
+
progressText: {
|
|
156
|
+
fontSize: 14,
|
|
157
|
+
fontWeight: "600",
|
|
158
|
+
marginTop: 8,
|
|
159
|
+
},
|
|
160
|
+
hint: {
|
|
161
|
+
fontSize: 14,
|
|
162
|
+
textAlign: "center",
|
|
163
|
+
fontStyle: "italic",
|
|
164
|
+
opacity: 0.6,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation Components
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
GenerationProgressModal,
|
|
7
|
+
} from "./GenerationProgressModal";
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
GenerationProgressModalProps,
|
|
11
|
+
GenerationProgressRenderProps,
|
|
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
|
+
}
|