@justin_666/square-couplets-master-skills 1.0.2 → 1.0.4
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/README.md +28 -1
- package/bin/doufang-image.js +34 -0
- package/bin/doufang-init.js +81 -2
- package/bin/doufang-optimize.js +34 -0
- package/bin/doufang-prompt.js +34 -0
- package/constants.ts +240 -0
- package/package.json +10 -3
- package/services/geminiService.ts +236 -0
- package/skills/README.md +40 -1
- package/skills/generate-doufang-image/index.js +202 -0
- package/skills/generate-doufang-prompt/index.js +141 -0
- package/skills/optimize-doufang-prompt/index.js +103 -0
- package/types.ts +70 -0
- package/utils/errorHandler.ts +123 -0
- package/utils/imageUtils.ts +135 -0
- package/utils/retry.ts +41 -0
|
@@ -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
|
+
};
|