@justin_666/square-couplets-master-skills 1.0.3 → 1.0.5

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.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Executable script for generate-doufang-prompt skill
5
+ * Can be called directly by agents or users
6
+ */
7
+
8
+ import { fileURLToPath } from 'url';
9
+ import { dirname, join, resolve } from 'path';
10
+ import { readFileSync, existsSync, statSync } from 'fs';
11
+ import { execSync } from 'child_process';
12
+ import { createRequire } from 'module';
13
+
14
+ // Load environment variables helper
15
+ async function loadEnvironmentVariables() {
16
+ try {
17
+ const dotenv = await import('dotenv');
18
+ const envLocalPath = join(projectRoot, '.env.local');
19
+ const envPath = join(projectRoot, '.env');
20
+ const cwdEnvLocalPath = join(process.cwd(), '.env.local');
21
+ const cwdEnvPath = join(process.cwd(), '.env');
22
+
23
+ if (existsSync(envLocalPath)) {
24
+ dotenv.config({ path: envLocalPath });
25
+ } else if (existsSync(envPath)) {
26
+ dotenv.config({ path: envPath });
27
+ } else if (existsSync(cwdEnvLocalPath)) {
28
+ dotenv.config({ path: cwdEnvLocalPath });
29
+ } else if (existsSync(cwdEnvPath)) {
30
+ dotenv.config({ path: cwdEnvPath });
31
+ } else {
32
+ dotenv.config();
33
+ }
34
+ } catch (e) {
35
+ // dotenv not available, continue without it (will use environment variables)
36
+ }
37
+ }
38
+
39
+ // Resolve project root and service path
40
+ const __filename = fileURLToPath(import.meta.url);
41
+ const __dirname = dirname(__filename);
42
+ const skillDir = resolve(__dirname);
43
+ const projectRoot = resolve(skillDir, '../..');
44
+
45
+ // Try to find services directory
46
+ function findServicesPath() {
47
+ // Get npm global prefix to find globally installed packages
48
+ let globalPrefix = null;
49
+ try {
50
+ globalPrefix = execSync('npm config get prefix', { encoding: 'utf-8' }).trim();
51
+ } catch (e) {
52
+ // Ignore error, try other methods
53
+ }
54
+
55
+ // Try to resolve package location using createRequire (works in ES modules)
56
+ let packageRoot = null;
57
+ try {
58
+ const require = createRequire(import.meta.url);
59
+ const packageJsonPath = require.resolve('@justin_666/square-couplets-master-skills/package.json');
60
+ packageRoot = dirname(packageJsonPath);
61
+ } catch (e) {
62
+ // Package not found via require.resolve, will try other paths
63
+ }
64
+
65
+ const possiblePaths = [
66
+ // Global npm package (highest priority - most reliable for installed packages)
67
+ ...(globalPrefix ? [
68
+ join(globalPrefix, 'lib', 'node_modules', '@justin_666', 'square-couplets-master-skills', 'services'),
69
+ join(globalPrefix, 'node_modules', '@justin_666', 'square-couplets-master-skills', 'services'),
70
+ ] : []),
71
+ // From resolved package root (if it has services)
72
+ ...(packageRoot ? [join(packageRoot, 'services')] : []),
73
+ // Local project root
74
+ join(projectRoot, 'services'),
75
+ // Local node_modules
76
+ join(projectRoot, 'node_modules', '@justin_666', 'square-couplets-master-skills', 'services'),
77
+ // Current working directory
78
+ join(process.cwd(), 'services'),
79
+ // Current working directory node_modules
80
+ join(process.cwd(), 'node_modules', '@justin_666', 'square-couplets-master-skills', 'services'),
81
+ ];
82
+
83
+ // Debug: log all paths being checked (only in development)
84
+ if (process.env.DEBUG_DOUFANG) {
85
+ console.log('🔍 Checking paths for services directory:');
86
+ for (const path of possiblePaths) {
87
+ console.log(` - ${path} ${existsSync(path) ? '✅' : '❌'}`);
88
+ }
89
+ }
90
+
91
+ for (const path of possiblePaths) {
92
+ try {
93
+ if (statSync(path).isDirectory()) {
94
+ if (process.env.DEBUG_DOUFANG) {
95
+ console.log(`✅ Found services at: ${path}`);
96
+ }
97
+ return path;
98
+ }
99
+ } catch (e) {
100
+ // Path doesn't exist, try next
101
+ }
102
+ }
103
+
104
+ // If not found, provide helpful error message
105
+ if (process.env.DEBUG_DOUFANG) {
106
+ console.log('❌ Services directory not found in any of the checked paths');
107
+ }
108
+ return null;
109
+ }
110
+
111
+
112
+ async function main() {
113
+ try {
114
+ // Load environment variables first
115
+ await loadEnvironmentVariables();
116
+
117
+ // Parse command line arguments
118
+ const args = process.argv.slice(2);
119
+ const keyword = args[0];
120
+ const referenceImagePath = args[1]; // Optional reference image path
121
+
122
+ if (!keyword) {
123
+ console.error('❌ Error: Keyword is required');
124
+ console.log('\nUsage:');
125
+ console.log(' node skills/generate-doufang-prompt/index.js <keyword> [reference-image-path]');
126
+ console.log('\nExample:');
127
+ console.log(' node skills/generate-doufang-prompt/index.js "財富"');
128
+ console.log(' node skills/generate-doufang-prompt/index.js "健康" images/reference.png');
129
+ process.exit(1);
130
+ }
131
+
132
+ // Get API key
133
+ const apiKey = process.env.GEMINI_API_KEY || process.env.API_KEY || process.env.GOOGLE_GENAI_API_KEY;
134
+
135
+ if (!apiKey) {
136
+ console.error('❌ Error: API Key is missing');
137
+ console.log('💡 Set GEMINI_API_KEY in .env file or environment variable');
138
+ process.exit(1);
139
+ }
140
+
141
+ // Try to import service function
142
+ const servicesPath = findServicesPath();
143
+ if (!servicesPath) {
144
+ console.error('❌ Error: Cannot find services directory');
145
+ console.log('💡 Make sure you are running from the project root or have installed the package');
146
+ process.exit(1);
147
+ }
148
+
149
+ // Dynamic import of service (support both .ts and .js)
150
+ let serviceModule;
151
+ try {
152
+ // Try .js first (for compiled npm package)
153
+ serviceModule = await import(`file://${join(servicesPath, 'geminiService.js')}`);
154
+ } catch (e) {
155
+ try {
156
+ // Try .ts (for development or source packages)
157
+ // Node.js cannot directly import .ts files
158
+ console.error('❌ Error: Cannot import TypeScript service module');
159
+ console.error(' The package contains TypeScript source files (.ts) which cannot be directly executed');
160
+ console.error('');
161
+ console.error('💡 Solution: Use the CLI command instead (recommended):');
162
+ console.error(` doufang-prompt "${keyword}"${referenceImagePath ? ` ${referenceImagePath}` : ''}`);
163
+ console.error('');
164
+ console.error(' Or if you need to use the script directly:');
165
+ console.error(' 1. Install tsx: npm install -g tsx');
166
+ console.error(` 2. Run: tsx skills/generate-doufang-prompt/index.js "${keyword}"${referenceImagePath ? ` ${referenceImagePath}` : ''}`);
167
+ process.exit(1);
168
+ } catch (e2) {
169
+ console.error('❌ Error: Cannot import service module');
170
+ console.error(' Tried:', join(servicesPath, 'geminiService.js'));
171
+ console.error(' Tried:', join(servicesPath, 'geminiService.ts'));
172
+ console.error(' 💡 Solution: Use the CLI command instead:');
173
+ console.error(` doufang-prompt "${keyword}"${referenceImagePath ? ` ${referenceImagePath}` : ''}`);
174
+ process.exit(1);
175
+ }
176
+ }
177
+ const { generateDoufangPrompt } = serviceModule;
178
+
179
+ // Load reference image if provided
180
+ let referenceImageDataUrl = null;
181
+ if (referenceImagePath) {
182
+ const fullPath = resolve(process.cwd(), referenceImagePath);
183
+ if (!existsSync(fullPath)) {
184
+ console.error(`❌ Error: Reference image not found: ${fullPath}`);
185
+ process.exit(1);
186
+ }
187
+
188
+ const imageBuffer = readFileSync(fullPath);
189
+ const base64 = imageBuffer.toString('base64');
190
+ const ext = referenceImagePath.split('.').pop()?.toLowerCase();
191
+ const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png';
192
+ referenceImageDataUrl = `data:${mimeType};base64,${base64}`;
193
+ }
194
+
195
+ // Generate prompt
196
+ console.log(`📝 Generating prompt for keyword: "${keyword}"`);
197
+ if (referenceImagePath) {
198
+ console.log(`🖼️ Using reference image: ${referenceImagePath}`);
199
+ }
200
+
201
+ const result = await generateDoufangPrompt(keyword, apiKey, referenceImageDataUrl);
202
+
203
+ // Output as JSON
204
+ console.log(JSON.stringify(result, null, 2));
205
+
206
+ } catch (error) {
207
+ console.error('❌ Error:', error.message);
208
+ if (error.stack) {
209
+ console.error(error.stack);
210
+ }
211
+ process.exit(1);
212
+ }
213
+ }
214
+
215
+ main();
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Executable script for optimize-doufang-prompt skill
5
+ * Optimizes prompts to reduce white margins and improve composition
6
+ */
7
+
8
+ import { fileURLToPath } from 'url';
9
+ import { dirname, join, resolve } from 'path';
10
+ import { existsSync, statSync } from 'fs';
11
+ import { config } from 'dotenv';
12
+
13
+ // Resolve project root
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ const skillDir = resolve(__dirname);
17
+ const projectRoot = resolve(skillDir, '../..');
18
+
19
+ // Load environment variables
20
+ const envLocalPath = join(projectRoot, '.env.local');
21
+ const envPath = join(projectRoot, '.env');
22
+
23
+ if (existsSync(envLocalPath)) {
24
+ config({ path: envLocalPath });
25
+ } else if (existsSync(envPath)) {
26
+ config({ path: envPath });
27
+ } else {
28
+ config();
29
+ }
30
+
31
+ async function optimizePrompt(originalPrompt) {
32
+ // This is a simple optimization - in a real implementation,
33
+ // you might want to use an LLM to optimize the prompt
34
+
35
+ let optimized = originalPrompt;
36
+
37
+ // Replace wide margin descriptions with minimal margin
38
+ optimized = optimized.replace(/wide\s+white\s+margins/gi, 'minimal elegant margins (2-5% of frame width)');
39
+ optimized = optimized.replace(/wide\s+margins/gi, 'minimal margins (2-5%)');
40
+ optimized = optimized.replace(/excessive\s+white\s+space/gi, 'minimal white space');
41
+
42
+ // Ensure Doufang fills 85-95% of frame
43
+ if (!optimized.includes('85-95%')) {
44
+ optimized = optimized.replace(
45
+ /(Composition:.*?)(\.|$)/i,
46
+ (match, p1, p2) => {
47
+ if (!p1.includes('85-95%')) {
48
+ return p1 + ' The Doufang should fill 85-95% of the image area, maximizing visual impact.' + p2;
49
+ }
50
+ return match;
51
+ }
52
+ );
53
+ }
54
+
55
+ // Add explicit margin requirements if not present
56
+ if (!optimized.includes('2-5%')) {
57
+ optimized += '\n\nIMPORTANT: The diamond-shaped Doufang must fill 85-95% of the frame with minimal margins (2-5% of frame width). Avoid excessive white space or wide margins.';
58
+ }
59
+
60
+ return optimized;
61
+ }
62
+
63
+ async function main() {
64
+ try {
65
+ // Parse command line arguments
66
+ const args = process.argv.slice(2);
67
+ const prompt = args[0];
68
+
69
+ if (!prompt) {
70
+ console.error('❌ Error: Prompt is required');
71
+ console.log('\nUsage:');
72
+ console.log(' node skills/optimize-doufang-prompt/index.js <prompt>');
73
+ console.log('\nOr pipe prompt:');
74
+ console.log(' echo "A diamond-shaped..." | node skills/optimize-doufang-prompt/index.js');
75
+ console.log('\nExample:');
76
+ console.log(' node skills/optimize-doufang-prompt/index.js "A diamond-shaped Doufang with wide white margins..."');
77
+ process.exit(1);
78
+ }
79
+
80
+ // Optimize prompt
81
+ console.log('✨ Optimizing prompt...\n');
82
+ const optimized = await optimizePrompt(prompt);
83
+
84
+ // Output optimized prompt
85
+ console.log('✅ Optimized prompt:');
86
+ console.log('─'.repeat(60));
87
+ console.log(optimized);
88
+ console.log('─'.repeat(60));
89
+
90
+ // Also output as JSON for programmatic use
91
+ console.log('\n📋 JSON output:');
92
+ console.log(JSON.stringify({ optimizedPrompt: optimized }, null, 2));
93
+
94
+ } catch (error) {
95
+ console.error('❌ Error:', error.message);
96
+ if (error.stack) {
97
+ console.error(error.stack);
98
+ }
99
+ process.exit(1);
100
+ }
101
+ }
102
+
103
+ main();
package/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ export interface GenerationResult {
2
+ keyword: string;
3
+ generatedPrompt: string;
4
+ imageUrl?: string;
5
+ blessingPhrase?: string;
6
+ }
7
+
8
+ export interface LoadingState {
9
+ isGeneratingPrompt: boolean;
10
+ isGeneratingImage: boolean;
11
+ }
12
+
13
+ export enum GenerationStatus {
14
+ IDLE = 'IDLE',
15
+ PROCESSING_PROMPT = 'PROCESSING_PROMPT',
16
+ PROCESSING_IMAGE = 'PROCESSING_IMAGE',
17
+ COMPLETED = 'COMPLETED',
18
+ ERROR = 'ERROR'
19
+ }
20
+
21
+ // API Types
22
+ export interface GeminiResponse {
23
+ text?: string;
24
+ candidates?: Array<{
25
+ content?: {
26
+ parts?: Array<{
27
+ text?: string;
28
+ inlineData?: {
29
+ mimeType: string;
30
+ data: string;
31
+ };
32
+ }>;
33
+ };
34
+ }>;
35
+ }
36
+
37
+ export interface ApiError {
38
+ status?: number;
39
+ message?: string;
40
+ code?: string;
41
+ }
42
+
43
+ // Settings Types
44
+ export interface AppSettings {
45
+ apiKey: string;
46
+ imageModel: 'gemini-2.5-flash-image' | 'gemini-3-pro-image-preview';
47
+ imageSize: '1K' | '2K' | '4K';
48
+ }
49
+
50
+ // Result Types
51
+ export interface GenerationResultData {
52
+ prompt: string;
53
+ blessingPhrase: string;
54
+ imageUrl: string;
55
+ }
56
+
57
+ // Gemini API Types
58
+ export interface GeminiContentPart {
59
+ text?: string;
60
+ inlineData?: {
61
+ mimeType: string;
62
+ data: string;
63
+ };
64
+ }
65
+
66
+ // Error Types
67
+ export type ApiErrorInput =
68
+ | { status?: number; message?: string; code?: string }
69
+ | Error
70
+ | unknown;
@@ -0,0 +1,123 @@
1
+ import type { ApiErrorInput } from "../types";
2
+
3
+ /**
4
+ * Custom error class for application errors
5
+ */
6
+ export class AppError extends Error {
7
+ constructor(
8
+ message: string,
9
+ public code: string,
10
+ public userMessage: string,
11
+ public recoverable: boolean = false,
12
+ public originalError?: unknown
13
+ ) {
14
+ super(message);
15
+ this.name = 'AppError';
16
+ Object.setPrototypeOf(this, AppError.prototype);
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Handle API errors and convert them to AppError
22
+ */
23
+ export const handleApiError = (error: ApiErrorInput, context?: string): AppError => {
24
+ // Log error for debugging
25
+ console.error(`API Error${context ? ` in ${context}` : ''}:`, error);
26
+
27
+ // Extract error properties safely
28
+ const errorObj = error && typeof error === 'object' ? error as { status?: number; message?: string; code?: string } : null;
29
+ const status = errorObj?.status;
30
+ const message = errorObj?.message || (error instanceof Error ? error.message : String(error));
31
+
32
+ // Handle 400 errors - Invalid Request
33
+ if (status === 400 || message?.includes('400') || message?.includes('INVALID_ARGUMENT')) {
34
+ if (message?.includes('image') || message?.includes('format') || message?.includes('mime')) {
35
+ return new AppError(
36
+ message || 'Invalid image format',
37
+ 'INVALID_IMAGE_FORMAT',
38
+ '圖片格式不支援,請嘗試其他圖片(建議使用 JPG 或 PNG)',
39
+ true,
40
+ error
41
+ );
42
+ }
43
+ return new AppError(
44
+ message || 'Invalid request',
45
+ 'INVALID_REQUEST',
46
+ '請求格式不正確,請檢查設置',
47
+ true,
48
+ error
49
+ );
50
+ }
51
+
52
+ // Handle 403 errors - Permission Denied
53
+ if (status === 403 || message?.includes('403') || message?.includes('Permission Denied')) {
54
+ if (message?.includes('Paid API Key') || message?.includes('billing')) {
55
+ return new AppError(
56
+ message || 'Billing required',
57
+ 'BILLING_REQUIRED',
58
+ '此功能需要付費 API Key,請在設置中切換到免費模型或啟用付費帳戶',
59
+ true,
60
+ error
61
+ );
62
+ }
63
+ return new AppError(
64
+ message || 'Permission denied',
65
+ 'PERMISSION_DENIED',
66
+ 'API Key 無效或沒有權限,請檢查設置',
67
+ true,
68
+ error
69
+ );
70
+ }
71
+
72
+ // Handle 429 errors - Rate Limit
73
+ if (status === 429 || message?.includes('429') || message?.includes('rate limit')) {
74
+ return new AppError(
75
+ message || 'Rate limit exceeded',
76
+ 'RATE_LIMIT',
77
+ '請求過於頻繁,請稍後再試',
78
+ true,
79
+ error
80
+ );
81
+ }
82
+
83
+ // Handle 500 errors - Server Error
84
+ if (status === 500 || status === 503 || message?.includes('overloaded')) {
85
+ return new AppError(
86
+ message || 'Server error',
87
+ 'SERVER_ERROR',
88
+ '服務器暫時無法處理請求,請稍後再試',
89
+ true,
90
+ error
91
+ );
92
+ }
93
+
94
+ // Handle network errors
95
+ if (message?.includes('network') || message?.includes('fetch')) {
96
+ return new AppError(
97
+ message || 'Network error',
98
+ 'NETWORK_ERROR',
99
+ '網路連線錯誤,請檢查網路連線',
100
+ true,
101
+ error
102
+ );
103
+ }
104
+
105
+ // Default error
106
+ return new AppError(
107
+ message || 'An unexpected error occurred',
108
+ 'UNKNOWN_ERROR',
109
+ '發生未知錯誤,請稍後再試',
110
+ false,
111
+ error
112
+ );
113
+ };
114
+
115
+ /**
116
+ * Get user-friendly error message
117
+ */
118
+ export const getUserFriendlyErrorMessage = (error: Error | AppError): string => {
119
+ if (error instanceof AppError) {
120
+ return error.userMessage;
121
+ }
122
+ return error.message || '發生未知錯誤';
123
+ };
@@ -0,0 +1,135 @@
1
+ import { IMAGE_CONSTANTS } from "../constants";
2
+
3
+ /**
4
+ * Process image data URL and extract mime type and base64 data
5
+ * @param dataUrl - The data URL string
6
+ * @returns Object with mimeType and base64Data, or null if invalid
7
+ */
8
+ export const processImageDataUrl = (dataUrl: string): { mimeType: string; base64Data: string } | null => {
9
+ const base64Match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
10
+ if (base64Match) {
11
+ return {
12
+ mimeType: base64Match[1],
13
+ base64Data: base64Match[2]
14
+ };
15
+ }
16
+ return null;
17
+ };
18
+
19
+ /**
20
+ * Compress image file to reduce size while maintaining quality
21
+ * @param file - The image file to compress
22
+ * @param maxSizeKB - Maximum file size in KB (default: 500KB)
23
+ * @param maxDimension - Maximum width or height in pixels (default: 1920)
24
+ * @returns Promise resolving to compressed image data URL
25
+ */
26
+ export const compressImage = async (
27
+ file: File,
28
+ maxSizeKB: number = IMAGE_CONSTANTS.MAX_SIZE_KB,
29
+ maxDimension: number = IMAGE_CONSTANTS.MAX_DIMENSION
30
+ ): Promise<string> => {
31
+ return new Promise((resolve, reject) => {
32
+ // Validate file type
33
+ if (!file.type.startsWith('image/')) {
34
+ reject(new Error('File is not an image'));
35
+ return;
36
+ }
37
+
38
+ const reader = new FileReader();
39
+ reader.onload = (e) => {
40
+ const img = new Image();
41
+ img.onload = () => {
42
+ const canvas = document.createElement('canvas');
43
+ let width = img.width;
44
+ let height = img.height;
45
+
46
+ // Calculate new dimensions to fit within maxDimension
47
+ if (width > height && width > maxDimension) {
48
+ height = (height * maxDimension) / width;
49
+ width = maxDimension;
50
+ } else if (height > maxDimension) {
51
+ width = (width * maxDimension) / height;
52
+ height = maxDimension;
53
+ }
54
+
55
+ canvas.width = width;
56
+ canvas.height = height;
57
+ const ctx = canvas.getContext('2d');
58
+
59
+ if (!ctx) {
60
+ reject(new Error('Could not get canvas context'));
61
+ return;
62
+ }
63
+
64
+ // Draw image with new dimensions
65
+ ctx.drawImage(img, 0, 0, width, height);
66
+
67
+ // Convert to blob with quality compression
68
+ canvas.toBlob(
69
+ (blob) => {
70
+ if (!blob) {
71
+ reject(new Error('Failed to compress image'));
72
+ return;
73
+ }
74
+
75
+ // Check if compressed size is acceptable
76
+ const sizeKB = blob.size / 1024;
77
+ if (sizeKB <= maxSizeKB) {
78
+ // Size is acceptable, convert to data URL
79
+ const reader = new FileReader();
80
+ reader.onload = () => resolve(reader.result as string);
81
+ reader.onerror = () => reject(new Error('Failed to read compressed image'));
82
+ reader.readAsDataURL(blob);
83
+ } else {
84
+ // Still too large, try lower quality
85
+ const quality = Math.max(
86
+ IMAGE_CONSTANTS.MIN_COMPRESSION_QUALITY,
87
+ IMAGE_CONSTANTS.COMPRESSION_QUALITY * (maxSizeKB / sizeKB)
88
+ );
89
+ canvas.toBlob(
90
+ (compressedBlob) => {
91
+ if (!compressedBlob) {
92
+ reject(new Error('Failed to compress image'));
93
+ return;
94
+ }
95
+ const finalReader = new FileReader();
96
+ finalReader.onload = () => resolve(finalReader.result as string);
97
+ finalReader.onerror = () => reject(new Error('Failed to read compressed image'));
98
+ finalReader.readAsDataURL(compressedBlob);
99
+ },
100
+ 'image/jpeg',
101
+ quality
102
+ );
103
+ }
104
+ },
105
+ 'image/jpeg',
106
+ IMAGE_CONSTANTS.COMPRESSION_QUALITY // Initial quality
107
+ );
108
+ };
109
+ img.onerror = () => reject(new Error('Failed to load image'));
110
+ img.src = e.target?.result as string;
111
+ };
112
+ reader.onerror = () => reject(new Error('Failed to read file'));
113
+ reader.readAsDataURL(file);
114
+ });
115
+ };
116
+
117
+ /**
118
+ * Validate image file
119
+ * @param file - The file to validate
120
+ * @param maxSizeMB - Maximum file size in MB (default: 10MB)
121
+ * @returns Error message if invalid, null if valid
122
+ */
123
+ export const validateImageFile = (file: File, maxSizeMB: number = IMAGE_CONSTANTS.MAX_FILE_SIZE_MB): string | null => {
124
+ // Validate file type
125
+ if (!file.type.startsWith('image/')) {
126
+ return 'Please upload an image file (JPG, PNG, etc.)';
127
+ }
128
+
129
+ // Validate file size
130
+ if (file.size > maxSizeMB * 1024 * 1024) {
131
+ return `Image file is too large. Maximum size is ${maxSizeMB}MB.`;
132
+ }
133
+
134
+ return null;
135
+ };
package/utils/retry.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Retry a function with exponential backoff
3
+ * @param fn - The function to retry
4
+ * @param maxRetries - Maximum number of retries (default: 3)
5
+ * @param initialDelay - Initial delay in milliseconds (default: 1000)
6
+ * @returns Promise resolving to the function result
7
+ */
8
+ export const retryWithBackoff = async <T>(
9
+ fn: () => Promise<T>,
10
+ maxRetries: number = 3,
11
+ initialDelay: number = 1000
12
+ ): Promise<T> => {
13
+ let lastError: Error | unknown;
14
+
15
+ for (let i = 0; i < maxRetries; i++) {
16
+ try {
17
+ return await fn();
18
+ } catch (error) {
19
+ lastError = error;
20
+
21
+ // Don't retry on certain errors (4xx client errors except 429)
22
+ if (error && typeof error === 'object' && 'status' in error) {
23
+ const status = (error as { status?: number }).status;
24
+ if (status && status >= 400 && status < 500 && status !== 429) {
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ // If this is the last retry, throw the error
30
+ if (i === maxRetries - 1) {
31
+ throw error;
32
+ }
33
+
34
+ // Calculate delay with exponential backoff
35
+ const delay = initialDelay * Math.pow(2, i);
36
+ await new Promise(resolve => setTimeout(resolve, delay));
37
+ }
38
+ }
39
+
40
+ throw lastError || new Error('Max retries exceeded');
41
+ };