@justin_666/square-couplets-master-skills 1.0.8 → 1.0.11
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/{constants.ts → dist/constants.js} +15 -21
- package/dist/services/geminiService.js +196 -0
- package/dist/types.js +8 -0
- package/dist/utils/errorHandler.js +62 -0
- package/dist/utils/imageUtils.node.js +35 -0
- package/dist/utils/retry.js +33 -0
- package/package.json +6 -8
- package/skills/generate-doufang-image/index.js +31 -12
- package/skills/generate-doufang-prompt/index.js +60 -40
- package/services/geminiService.ts +0 -236
- package/types.ts +0 -70
- package/utils/errorHandler.ts +0 -123
- package/utils/imageUtils.ts +0 -135
- package/utils/retry.ts +0 -41
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Image Processing Constants
|
|
2
2
|
export const IMAGE_CONSTANTS = {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
3
|
+
MAX_SIZE_KB: 500,
|
|
4
|
+
MAX_DIMENSION: 1920,
|
|
5
|
+
MAX_FILE_SIZE_MB: 10,
|
|
6
|
+
COMPRESSION_QUALITY: 0.85,
|
|
7
|
+
MIN_COMPRESSION_QUALITY: 0.1
|
|
8
|
+
};
|
|
10
9
|
// Base system prompt for generating Doufang prompts
|
|
11
10
|
export const DOUFANG_SYSTEM_PROMPT = `
|
|
12
11
|
You are a professional Chinese New Year couplet and calligraphy art designer.
|
|
@@ -69,9 +68,8 @@ Return a JSON object with the following structure:
|
|
|
69
68
|
"imagePrompt": "The full detailed English image generation prompt based on the instructions above."
|
|
70
69
|
}
|
|
71
70
|
`;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return `You are a professional Chinese New Year Doufang (diamond-shaped couplet) designer and calligrapher.
|
|
71
|
+
export const getDoufangSystemPromptWithReference = () => {
|
|
72
|
+
return `You are a professional Chinese New Year Doufang (diamond-shaped couplet) designer and calligrapher.
|
|
75
73
|
|
|
76
74
|
### CORE MISSION:
|
|
77
75
|
Your task is to analyze a REFERENCE IMAGE and a KEYWORD to create a unique, high-end Chinese New Year Doufang. The reference image is your PRIMARY visual guide for style, subject category, and material language — but it must NEVER be copied.
|
|
@@ -185,11 +183,9 @@ Return only a JSON object:
|
|
|
185
183
|
}
|
|
186
184
|
`;
|
|
187
185
|
};
|
|
188
|
-
|
|
189
|
-
|
|
190
186
|
// User input prompt template for reference image analysis
|
|
191
|
-
export const getReferenceImageAnalysisPrompt = (userKeyword
|
|
192
|
-
|
|
187
|
+
export const getReferenceImageAnalysisPrompt = (userKeyword) => {
|
|
188
|
+
return `User input keyword: 「${userKeyword}」
|
|
193
189
|
|
|
194
190
|
CRITICAL INSTRUCTION: A reference image has been provided above. You MUST analyze this reference image in detail and generate a prompt that DIRECTLY USES the reference image's visual content, patterns, and style.
|
|
195
191
|
|
|
@@ -224,17 +220,15 @@ The generated prompt MUST explicitly describe:
|
|
|
224
220
|
|
|
225
221
|
DO NOT create generic descriptions. Be SPECIFIC about what you see in the reference image and how to recreate it.`;
|
|
226
222
|
};
|
|
227
|
-
|
|
228
223
|
// Simple user input prompt without reference image
|
|
229
|
-
export const getSimpleUserInputPrompt = (userKeyword
|
|
230
|
-
|
|
224
|
+
export const getSimpleUserInputPrompt = (userKeyword) => {
|
|
225
|
+
return `User input keyword: 「${userKeyword}」`;
|
|
231
226
|
};
|
|
232
|
-
|
|
233
227
|
// Image generation prompt enhancement when reference image is provided
|
|
234
|
-
export const getImageGenerationPromptWithReference = (basePrompt
|
|
235
|
-
|
|
228
|
+
export const getImageGenerationPromptWithReference = (basePrompt) => {
|
|
229
|
+
return `${basePrompt}
|
|
236
230
|
|
|
237
231
|
IMPORTANT COMPOSITION NOTE: The diamond-shaped Doufang should fill 85-95% of the frame with minimal margins (2-5% of frame width). Avoid excessive white space or wide margins. Maximize the visual impact by making the Doufang artwork occupy most of the image area.
|
|
238
232
|
|
|
239
233
|
Note: The reference image provided above should be used as a visual style guide. Follow the style, color palette, and artistic approach described in the prompt, which was generated based on analysis of this reference image.`;
|
|
240
|
-
};
|
|
234
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { GoogleGenAI, Type } from "@google/genai";
|
|
2
|
+
import { DOUFANG_SYSTEM_PROMPT, getDoufangSystemPromptWithReference, getReferenceImageAnalysisPrompt, getSimpleUserInputPrompt, getImageGenerationPromptWithReference } from "../constants.js";
|
|
3
|
+
import { processImageDataUrl } from "../utils/imageUtils.node.js";
|
|
4
|
+
import { handleApiError } from "../utils/errorHandler.js";
|
|
5
|
+
import { retryWithBackoff } from "../utils/retry.js";
|
|
6
|
+
const getClient = (apiKey) => {
|
|
7
|
+
// Use user-provided key if available, otherwise fallback to env var
|
|
8
|
+
const userKey = apiKey?.trim();
|
|
9
|
+
const envKey = process.env.API_KEY?.trim();
|
|
10
|
+
const key = userKey || envKey;
|
|
11
|
+
if (!key) {
|
|
12
|
+
throw new Error("API Key is missing. Please click the Settings icon to configure your Gemini API Key.");
|
|
13
|
+
}
|
|
14
|
+
// Basic validation: API keys should be non-empty strings
|
|
15
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
16
|
+
throw new Error("Invalid API Key format. Please check your API Key configuration.");
|
|
17
|
+
}
|
|
18
|
+
return new GoogleGenAI({ apiKey: key });
|
|
19
|
+
};
|
|
20
|
+
export const generateDoufangPrompt = async (userKeyword, apiKey, referenceImageDataUrl, signal) => {
|
|
21
|
+
return retryWithBackoff(async () => {
|
|
22
|
+
const ai = getClient(apiKey);
|
|
23
|
+
try {
|
|
24
|
+
// Check if request was cancelled
|
|
25
|
+
if (signal?.aborted) {
|
|
26
|
+
throw new Error('Request cancelled');
|
|
27
|
+
}
|
|
28
|
+
// Prepare content parts
|
|
29
|
+
const parts = [];
|
|
30
|
+
// Add reference image if provided - image should come first
|
|
31
|
+
if (referenceImageDataUrl) {
|
|
32
|
+
const imageData = processImageDataUrl(referenceImageDataUrl);
|
|
33
|
+
if (imageData) {
|
|
34
|
+
parts.push({
|
|
35
|
+
inlineData: {
|
|
36
|
+
mimeType: imageData.mimeType,
|
|
37
|
+
data: imageData.base64Data
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Fallback: try to use as-is (may fail, but attempt anyway)
|
|
43
|
+
console.warn('Reference image format may be incorrect, attempting to use as-is');
|
|
44
|
+
parts.push({
|
|
45
|
+
inlineData: {
|
|
46
|
+
mimeType: 'image/jpeg', // Default to JPEG
|
|
47
|
+
data: referenceImageDataUrl.replace(/^data:image\/[^;]+;base64,/, '')
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Add text instruction with reference image context
|
|
52
|
+
parts.push({
|
|
53
|
+
text: getReferenceImageAnalysisPrompt(userKeyword)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// No reference image, use simple text
|
|
58
|
+
parts.push({ text: getSimpleUserInputPrompt(userKeyword) });
|
|
59
|
+
}
|
|
60
|
+
// Get system instruction based on whether reference image is provided
|
|
61
|
+
const systemInstruction = referenceImageDataUrl
|
|
62
|
+
? getDoufangSystemPromptWithReference()
|
|
63
|
+
: DOUFANG_SYSTEM_PROMPT;
|
|
64
|
+
const response = await ai.models.generateContent({
|
|
65
|
+
model: 'gemini-3-flash-preview',
|
|
66
|
+
contents: {
|
|
67
|
+
parts: parts
|
|
68
|
+
},
|
|
69
|
+
config: {
|
|
70
|
+
systemInstruction: systemInstruction,
|
|
71
|
+
responseMimeType: "application/json",
|
|
72
|
+
responseSchema: {
|
|
73
|
+
type: Type.OBJECT,
|
|
74
|
+
properties: {
|
|
75
|
+
blessingPhrase: { type: Type.STRING },
|
|
76
|
+
imagePrompt: { type: Type.STRING }
|
|
77
|
+
},
|
|
78
|
+
required: ["blessingPhrase", "imagePrompt"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const text = response.text;
|
|
83
|
+
if (!text) {
|
|
84
|
+
throw new Error("No response from Gemini");
|
|
85
|
+
}
|
|
86
|
+
// Check if request was cancelled before parsing
|
|
87
|
+
if (signal?.aborted) {
|
|
88
|
+
throw new Error('Request cancelled');
|
|
89
|
+
}
|
|
90
|
+
return JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (signal?.aborted) {
|
|
94
|
+
throw new Error('Request cancelled');
|
|
95
|
+
}
|
|
96
|
+
throw handleApiError(e, 'generateDoufangPrompt');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
export const generateDoufangImage = async (prompt, apiKey, model = 'gemini-2.5-flash-image', imageSize = '1K', referenceImageDataUrl, signal) => {
|
|
101
|
+
return retryWithBackoff(async () => {
|
|
102
|
+
const ai = getClient(apiKey);
|
|
103
|
+
// Check if request was cancelled
|
|
104
|
+
if (signal?.aborted) {
|
|
105
|
+
throw new Error('Request cancelled');
|
|
106
|
+
}
|
|
107
|
+
let config = {};
|
|
108
|
+
// Different models have different support for imageConfig
|
|
109
|
+
// Flash model does NOT support imageConfig parameter - it will cause 400 errors
|
|
110
|
+
// Only Pro model supports imageConfig with custom sizes
|
|
111
|
+
if (model === 'gemini-3-pro-image-preview') {
|
|
112
|
+
// Pro model supports all sizes (1K, 2K, 4K)
|
|
113
|
+
config = {
|
|
114
|
+
imageConfig: {
|
|
115
|
+
aspectRatio: "1:1",
|
|
116
|
+
imageSize: imageSize
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// For Flash model: Do NOT set imageConfig at all
|
|
121
|
+
// Flash model only supports default 1K (1024x1024) resolution
|
|
122
|
+
// Setting imageConfig will cause 400 INVALID_ARGUMENT error
|
|
123
|
+
try {
|
|
124
|
+
// Prepare content parts
|
|
125
|
+
const parts = [];
|
|
126
|
+
// Add reference image if provided - image should come first
|
|
127
|
+
// Note: The prompt already contains style guidance from the reference image
|
|
128
|
+
// (generated in generateDoufangPrompt), so we just need to provide the image
|
|
129
|
+
// as additional visual reference for the image generation model
|
|
130
|
+
if (referenceImageDataUrl) {
|
|
131
|
+
const imageData = processImageDataUrl(referenceImageDataUrl);
|
|
132
|
+
if (imageData) {
|
|
133
|
+
parts.push({
|
|
134
|
+
inlineData: {
|
|
135
|
+
mimeType: imageData.mimeType,
|
|
136
|
+
data: imageData.base64Data
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Fallback: try to use as-is (may fail, but attempt anyway)
|
|
142
|
+
console.warn('Reference image format may be incorrect, attempting to use as-is');
|
|
143
|
+
parts.push({
|
|
144
|
+
inlineData: {
|
|
145
|
+
mimeType: 'image/jpeg', // Default to JPEG
|
|
146
|
+
data: referenceImageDataUrl.replace(/^data:image\/[^;]+;base64,/, '')
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Add prompt with reference image context
|
|
151
|
+
// The prompt already includes style guidance, so we just reinforce it
|
|
152
|
+
parts.push({
|
|
153
|
+
text: getImageGenerationPromptWithReference(prompt)
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// No reference image, use original prompt
|
|
158
|
+
parts.push({ text: prompt });
|
|
159
|
+
}
|
|
160
|
+
const response = await ai.models.generateContent({
|
|
161
|
+
model: model,
|
|
162
|
+
contents: {
|
|
163
|
+
parts: parts
|
|
164
|
+
},
|
|
165
|
+
config: Object.keys(config).length > 0 ? config : undefined
|
|
166
|
+
});
|
|
167
|
+
// Check if request was cancelled before processing response
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
throw new Error('Request cancelled');
|
|
170
|
+
}
|
|
171
|
+
// Extract image
|
|
172
|
+
for (const part of response.candidates?.[0]?.content?.parts || []) {
|
|
173
|
+
if (part.inlineData) {
|
|
174
|
+
return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new Error("No image generated in the response.");
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (signal?.aborted) {
|
|
181
|
+
throw new Error('Request cancelled');
|
|
182
|
+
}
|
|
183
|
+
const error = handleApiError(e, 'generateDoufangImage');
|
|
184
|
+
// Add specific context for image size errors
|
|
185
|
+
if (error.code === 'INVALID_REQUEST') {
|
|
186
|
+
if (model === 'gemini-2.5-flash-image') {
|
|
187
|
+
error.userMessage = 'Flash 模型不支援自訂圖片大小設定,僅支援預設 1K (1024×1024) 解析度。如需更高解析度,請使用 Pro 模型。';
|
|
188
|
+
}
|
|
189
|
+
else if (imageSize === '4K') {
|
|
190
|
+
error.userMessage = '4K 解析度可能不被此模型或您的 API 方案支援,請嘗試 2K 或 1K。';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export var GenerationStatus;
|
|
2
|
+
(function (GenerationStatus) {
|
|
3
|
+
GenerationStatus["IDLE"] = "IDLE";
|
|
4
|
+
GenerationStatus["PROCESSING_PROMPT"] = "PROCESSING_PROMPT";
|
|
5
|
+
GenerationStatus["PROCESSING_IMAGE"] = "PROCESSING_IMAGE";
|
|
6
|
+
GenerationStatus["COMPLETED"] = "COMPLETED";
|
|
7
|
+
GenerationStatus["ERROR"] = "ERROR";
|
|
8
|
+
})(GenerationStatus || (GenerationStatus = {}));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error class for application errors
|
|
3
|
+
*/
|
|
4
|
+
export class AppError extends Error {
|
|
5
|
+
constructor(message, code, userMessage, recoverable = false, originalError) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.userMessage = userMessage;
|
|
9
|
+
this.recoverable = recoverable;
|
|
10
|
+
this.originalError = originalError;
|
|
11
|
+
this.name = 'AppError';
|
|
12
|
+
Object.setPrototypeOf(this, AppError.prototype);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Handle API errors and convert them to AppError
|
|
17
|
+
*/
|
|
18
|
+
export const handleApiError = (error, context) => {
|
|
19
|
+
// Log error for debugging
|
|
20
|
+
console.error(`API Error${context ? ` in ${context}` : ''}:`, error);
|
|
21
|
+
// Extract error properties safely
|
|
22
|
+
const errorObj = error && typeof error === 'object' ? error : null;
|
|
23
|
+
const status = errorObj?.status;
|
|
24
|
+
const message = errorObj?.message || (error instanceof Error ? error.message : String(error));
|
|
25
|
+
// Handle 400 errors - Invalid Request
|
|
26
|
+
if (status === 400 || message?.includes('400') || message?.includes('INVALID_ARGUMENT')) {
|
|
27
|
+
if (message?.includes('image') || message?.includes('format') || message?.includes('mime')) {
|
|
28
|
+
return new AppError(message || 'Invalid image format', 'INVALID_IMAGE_FORMAT', '圖片格式不支援,請嘗試其他圖片(建議使用 JPG 或 PNG)', true, error);
|
|
29
|
+
}
|
|
30
|
+
return new AppError(message || 'Invalid request', 'INVALID_REQUEST', '請求格式不正確,請檢查設置', true, error);
|
|
31
|
+
}
|
|
32
|
+
// Handle 403 errors - Permission Denied
|
|
33
|
+
if (status === 403 || message?.includes('403') || message?.includes('Permission Denied')) {
|
|
34
|
+
if (message?.includes('Paid API Key') || message?.includes('billing')) {
|
|
35
|
+
return new AppError(message || 'Billing required', 'BILLING_REQUIRED', '此功能需要付費 API Key,請在設置中切換到免費模型或啟用付費帳戶', true, error);
|
|
36
|
+
}
|
|
37
|
+
return new AppError(message || 'Permission denied', 'PERMISSION_DENIED', 'API Key 無效或沒有權限,請檢查設置', true, error);
|
|
38
|
+
}
|
|
39
|
+
// Handle 429 errors - Rate Limit
|
|
40
|
+
if (status === 429 || message?.includes('429') || message?.includes('rate limit')) {
|
|
41
|
+
return new AppError(message || 'Rate limit exceeded', 'RATE_LIMIT', '請求過於頻繁,請稍後再試', true, error);
|
|
42
|
+
}
|
|
43
|
+
// Handle 500 errors - Server Error
|
|
44
|
+
if (status === 500 || status === 503 || message?.includes('overloaded')) {
|
|
45
|
+
return new AppError(message || 'Server error', 'SERVER_ERROR', '服務器暫時無法處理請求,請稍後再試', true, error);
|
|
46
|
+
}
|
|
47
|
+
// Handle network errors
|
|
48
|
+
if (message?.includes('network') || message?.includes('fetch')) {
|
|
49
|
+
return new AppError(message || 'Network error', 'NETWORK_ERROR', '網路連線錯誤,請檢查網路連線', true, error);
|
|
50
|
+
}
|
|
51
|
+
// Default error
|
|
52
|
+
return new AppError(message || 'An unexpected error occurred', 'UNKNOWN_ERROR', '發生未知錯誤,請稍後再試', false, error);
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Get user-friendly error message
|
|
56
|
+
*/
|
|
57
|
+
export const getUserFriendlyErrorMessage = (error) => {
|
|
58
|
+
if (error instanceof AppError) {
|
|
59
|
+
return error.userMessage;
|
|
60
|
+
}
|
|
61
|
+
return error.message || '發生未知錯誤';
|
|
62
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js version of image utilities (no browser APIs)
|
|
3
|
+
* For CLI usage only
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Process image data URL to extract mime type and base64 data
|
|
7
|
+
*/
|
|
8
|
+
export function processImageDataUrl(dataUrl) {
|
|
9
|
+
try {
|
|
10
|
+
const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
11
|
+
if (!matches) {
|
|
12
|
+
console.warn('Invalid data URL format');
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
mimeType: matches[1],
|
|
17
|
+
base64Data: matches[2]
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.error('Error processing image data URL:', error);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Validate image file size (Node.js version - simplified)
|
|
27
|
+
* @param buffer - Image buffer
|
|
28
|
+
* @param maxSizeMB - Maximum file size in MB
|
|
29
|
+
*/
|
|
30
|
+
export function validateImageSize(buffer, maxSizeMB = 10) {
|
|
31
|
+
const sizeMB = buffer.length / (1024 * 1024);
|
|
32
|
+
if (sizeMB > maxSizeMB) {
|
|
33
|
+
throw new Error(`Image file too large: ${sizeMB.toFixed(2)}MB (max: ${maxSizeMB}MB)`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 (fn, maxRetries = 3, initialDelay = 1000) => {
|
|
9
|
+
let lastError;
|
|
10
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
11
|
+
try {
|
|
12
|
+
return await fn();
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
lastError = error;
|
|
16
|
+
// Don't retry on certain errors (4xx client errors except 429)
|
|
17
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
18
|
+
const status = error.status;
|
|
19
|
+
if (status && status >= 400 && status < 500 && status !== 429) {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// If this is the last retry, throw the error
|
|
24
|
+
if (i === maxRetries - 1) {
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
// Calculate delay with exponential backoff
|
|
28
|
+
const delay = initialDelay * Math.pow(2, i);
|
|
29
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw lastError || new Error('Max retries exceeded');
|
|
33
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justin_666/square-couplets-master-skills",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "Claude Agent Skills for generating Chinese New Year Doufang (diamond-shaped couplet) artwork using Google Gemini AI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/doufang-skills.js",
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"dev": "vite",
|
|
16
16
|
"build": "vite build",
|
|
17
|
+
"build:cli": "tsc --project tsconfig.cli.json && node fix-imports.js",
|
|
17
18
|
"preview": "vite preview",
|
|
18
|
-
"prepublishOnly": "node -e \"const fs = require('fs'); ['doufang-skills.js', 'doufang-init.js', 'doufang-prompt.js', 'doufang-image.js', 'doufang-optimize.js'].forEach(f => fs.chmodSync('bin/' + f, 0o755));\""
|
|
19
|
+
"prepublishOnly": "npm run build:cli && node -e \"const fs = require('fs'); ['doufang-skills.js', 'doufang-init.js', 'doufang-prompt.js', 'doufang-image.js', 'doufang-optimize.js'].forEach(f => fs.chmodSync('bin/' + f, 0o755));\""
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"claude",
|
|
@@ -45,10 +46,7 @@
|
|
|
45
46
|
"files": [
|
|
46
47
|
"bin/",
|
|
47
48
|
"skills/",
|
|
48
|
-
"
|
|
49
|
-
"utils/",
|
|
50
|
-
"constants.ts",
|
|
51
|
-
"types.ts",
|
|
49
|
+
"dist/",
|
|
52
50
|
"README.md",
|
|
53
51
|
"LICENSE",
|
|
54
52
|
"package.json"
|
|
@@ -60,10 +58,10 @@
|
|
|
60
58
|
"node": ">=18.0.0"
|
|
61
59
|
},
|
|
62
60
|
"dependencies": {
|
|
63
|
-
"dotenv": "^17.2.3"
|
|
61
|
+
"dotenv": "^17.2.3",
|
|
62
|
+
"@google/genai": "^1.37.0"
|
|
64
63
|
},
|
|
65
64
|
"devDependencies": {
|
|
66
|
-
"@google/genai": "^1.37.0",
|
|
67
65
|
"@types/node": "^22.14.0",
|
|
68
66
|
"@vitejs/plugin-react": "^5.0.0",
|
|
69
67
|
"react": "^19.2.3",
|
|
@@ -167,22 +167,41 @@ async function main() {
|
|
|
167
167
|
process.exit(1);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
// Dynamic import of service (
|
|
170
|
+
// Dynamic import of service (prioritize compiled .js from dist/)
|
|
171
171
|
let serviceModule;
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
const possibleServicePaths = [
|
|
173
|
+
// 1. Compiled JS in dist/ (npm package)
|
|
174
|
+
join(dirname(servicesPath), 'dist', 'services', 'geminiService.js'),
|
|
175
|
+
// 2. Compiled JS in services/ (if built locally)
|
|
176
|
+
join(servicesPath, 'geminiService.js'),
|
|
177
|
+
// 3. Source TS (development only)
|
|
178
|
+
join(servicesPath, 'geminiService.ts'),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
let importError = null;
|
|
182
|
+
for (const servicePath of possibleServicePaths) {
|
|
176
183
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
if (existsSync(servicePath)) {
|
|
185
|
+
serviceModule = await import(`file://${servicePath}`);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
importError = e;
|
|
190
|
+
continue;
|
|
184
191
|
}
|
|
185
192
|
}
|
|
193
|
+
|
|
194
|
+
if (!serviceModule) {
|
|
195
|
+
console.error('❌ Error: Cannot import service module');
|
|
196
|
+
console.error(' Tried paths:');
|
|
197
|
+
possibleServicePaths.forEach(p => console.error(` - ${p}`));
|
|
198
|
+
if (importError) {
|
|
199
|
+
console.error('\n Last error:', importError.message);
|
|
200
|
+
}
|
|
201
|
+
console.error('\n💡 This usually means the package was not properly built.');
|
|
202
|
+
console.error(' Please report this issue at: https://github.com/poirotw66/Square_Couplets_Master/issues');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
186
205
|
const { generateDoufangImage } = serviceModule;
|
|
187
206
|
|
|
188
207
|
// Load reference image if provided
|
|
@@ -42,7 +42,7 @@ const __dirname = dirname(__filename);
|
|
|
42
42
|
const skillDir = resolve(__dirname);
|
|
43
43
|
const projectRoot = resolve(skillDir, '../..');
|
|
44
44
|
|
|
45
|
-
// Try to find services directory
|
|
45
|
+
// Try to find dist directory (compiled JS) or services directory (source TS)
|
|
46
46
|
function findServicesPath() {
|
|
47
47
|
// Get npm global prefix to find globally installed packages
|
|
48
48
|
let globalPrefix = null;
|
|
@@ -62,29 +62,42 @@ function findServicesPath() {
|
|
|
62
62
|
// Package not found via require.resolve, will try other paths
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Build list of possible paths
|
|
66
|
+
// IMPORTANT: We're looking for dist/services (compiled) not services/ (source)
|
|
65
67
|
const possiblePaths = [
|
|
66
|
-
// Global npm package
|
|
68
|
+
// Global npm package - dist directory (compiled JS)
|
|
67
69
|
...(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
|
+
join(globalPrefix, 'lib', 'node_modules', '@justin_666', 'square-couplets-master-skills', 'dist', 'services'),
|
|
71
|
+
join(globalPrefix, 'node_modules', '@justin_666', 'square-couplets-master-skills', 'dist', 'services'),
|
|
70
72
|
] : []),
|
|
71
|
-
// From resolved package root
|
|
72
|
-
...(packageRoot ? [
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// From resolved package root - dist directory
|
|
74
|
+
...(packageRoot ? [
|
|
75
|
+
join(packageRoot, 'dist', 'services'),
|
|
76
|
+
join(packageRoot, 'services'), // fallback to source
|
|
77
|
+
] : []),
|
|
78
|
+
// Local project root - dist directory
|
|
79
|
+
join(projectRoot, 'dist', 'services'),
|
|
80
|
+
join(projectRoot, 'services'), // fallback to source
|
|
81
|
+
// Local node_modules - dist directory
|
|
82
|
+
join(projectRoot, 'node_modules', '@justin_666', 'square-couplets-master-skills', 'dist', 'services'),
|
|
83
|
+
// Current working directory - dist directory
|
|
84
|
+
join(process.cwd(), 'dist', 'services'),
|
|
78
85
|
join(process.cwd(), 'services'),
|
|
79
86
|
// Current working directory node_modules
|
|
80
|
-
join(process.cwd(), 'node_modules', '@justin_666', 'square-couplets-master-skills', 'services'),
|
|
87
|
+
join(process.cwd(), 'node_modules', '@justin_666', 'square-couplets-master-skills', 'dist', 'services'),
|
|
81
88
|
];
|
|
82
89
|
|
|
83
|
-
// Debug: log all paths being checked (
|
|
90
|
+
// Debug: log all paths being checked (enable with DEBUG_DOUFANG=1)
|
|
84
91
|
if (process.env.DEBUG_DOUFANG) {
|
|
85
92
|
console.log('🔍 Checking paths for services directory:');
|
|
93
|
+
console.log(` packageRoot: ${packageRoot || 'not found'}`);
|
|
94
|
+
console.log(` globalPrefix: ${globalPrefix || 'not found'}`);
|
|
95
|
+
console.log(` projectRoot: ${projectRoot}`);
|
|
96
|
+
console.log(` cwd: ${process.cwd()}`);
|
|
97
|
+
console.log('\n Trying paths:');
|
|
86
98
|
for (const path of possiblePaths) {
|
|
87
|
-
|
|
99
|
+
const exists = existsSync(path);
|
|
100
|
+
console.log(` ${exists ? '✅' : '❌'} ${path}`);
|
|
88
101
|
}
|
|
89
102
|
}
|
|
90
103
|
|
|
@@ -92,7 +105,7 @@ function findServicesPath() {
|
|
|
92
105
|
try {
|
|
93
106
|
if (statSync(path).isDirectory()) {
|
|
94
107
|
if (process.env.DEBUG_DOUFANG) {
|
|
95
|
-
console.log(
|
|
108
|
+
console.log(`\n✅ Found services at: ${path}`);
|
|
96
109
|
}
|
|
97
110
|
return path;
|
|
98
111
|
}
|
|
@@ -103,7 +116,7 @@ function findServicesPath() {
|
|
|
103
116
|
|
|
104
117
|
// If not found, provide helpful error message
|
|
105
118
|
if (process.env.DEBUG_DOUFANG) {
|
|
106
|
-
console.log('❌ Services directory not found in any of the checked paths');
|
|
119
|
+
console.log('\n❌ Services directory not found in any of the checked paths');
|
|
107
120
|
}
|
|
108
121
|
return null;
|
|
109
122
|
}
|
|
@@ -146,34 +159,41 @@ async function main() {
|
|
|
146
159
|
process.exit(1);
|
|
147
160
|
}
|
|
148
161
|
|
|
149
|
-
// Dynamic import of service (
|
|
162
|
+
// Dynamic import of service (prioritize compiled .js from dist/)
|
|
150
163
|
let serviceModule;
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
const possibleServicePaths = [
|
|
165
|
+
// 1. Compiled JS in dist/ (npm package)
|
|
166
|
+
join(dirname(servicesPath), 'dist', 'services', 'geminiService.js'),
|
|
167
|
+
// 2. Compiled JS in services/ (if built locally)
|
|
168
|
+
join(servicesPath, 'geminiService.js'),
|
|
169
|
+
// 3. Source TS (development only)
|
|
170
|
+
join(servicesPath, 'geminiService.ts'),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
let importError = null;
|
|
174
|
+
for (const servicePath of possibleServicePaths) {
|
|
155
175
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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);
|
|
176
|
+
if (existsSync(servicePath)) {
|
|
177
|
+
serviceModule = await import(`file://${servicePath}`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
importError = e;
|
|
182
|
+
continue;
|
|
175
183
|
}
|
|
176
184
|
}
|
|
185
|
+
|
|
186
|
+
if (!serviceModule) {
|
|
187
|
+
console.error('❌ Error: Cannot import service module');
|
|
188
|
+
console.error(' Tried paths:');
|
|
189
|
+
possibleServicePaths.forEach(p => console.error(` - ${p}`));
|
|
190
|
+
if (importError) {
|
|
191
|
+
console.error('\n Last error:', importError.message);
|
|
192
|
+
}
|
|
193
|
+
console.error('\n💡 This usually means the package was not properly built.');
|
|
194
|
+
console.error(' Please report this issue at: https://github.com/poirotw66/Square_Couplets_Master/issues');
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
177
197
|
const { generateDoufangPrompt } = serviceModule;
|
|
178
198
|
|
|
179
199
|
// Load reference image if provided
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import { GoogleGenAI, Type } from "@google/genai";
|
|
2
|
-
import {
|
|
3
|
-
DOUFANG_SYSTEM_PROMPT,
|
|
4
|
-
getDoufangSystemPromptWithReference,
|
|
5
|
-
getReferenceImageAnalysisPrompt,
|
|
6
|
-
getSimpleUserInputPrompt,
|
|
7
|
-
getImageGenerationPromptWithReference
|
|
8
|
-
} from "../constants";
|
|
9
|
-
import { processImageDataUrl } from "../utils/imageUtils";
|
|
10
|
-
import { handleApiError } from "../utils/errorHandler";
|
|
11
|
-
import { retryWithBackoff } from "../utils/retry";
|
|
12
|
-
import type { GeminiContentPart } from "../types";
|
|
13
|
-
|
|
14
|
-
const getClient = (apiKey?: string) => {
|
|
15
|
-
// Use user-provided key if available, otherwise fallback to env var
|
|
16
|
-
const userKey = apiKey?.trim();
|
|
17
|
-
const envKey = process.env.API_KEY?.trim();
|
|
18
|
-
const key = userKey || envKey;
|
|
19
|
-
|
|
20
|
-
if (!key) {
|
|
21
|
-
throw new Error("API Key is missing. Please click the Settings icon to configure your Gemini API Key.");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Basic validation: API keys should be non-empty strings
|
|
25
|
-
if (typeof key !== 'string' || key.length === 0) {
|
|
26
|
-
throw new Error("Invalid API Key format. Please check your API Key configuration.");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return new GoogleGenAI({ apiKey: key });
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export const generateDoufangPrompt = async (
|
|
33
|
-
userKeyword: string,
|
|
34
|
-
apiKey?: string,
|
|
35
|
-
referenceImageDataUrl?: string | null,
|
|
36
|
-
signal?: AbortSignal
|
|
37
|
-
): Promise<{ blessingPhrase: string; imagePrompt: string }> => {
|
|
38
|
-
return retryWithBackoff(async () => {
|
|
39
|
-
const ai = getClient(apiKey);
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
// Check if request was cancelled
|
|
43
|
-
if (signal?.aborted) {
|
|
44
|
-
throw new Error('Request cancelled');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Prepare content parts
|
|
48
|
-
const parts: GeminiContentPart[] = [];
|
|
49
|
-
|
|
50
|
-
// Add reference image if provided - image should come first
|
|
51
|
-
if (referenceImageDataUrl) {
|
|
52
|
-
const imageData = processImageDataUrl(referenceImageDataUrl);
|
|
53
|
-
if (imageData) {
|
|
54
|
-
parts.push({
|
|
55
|
-
inlineData: {
|
|
56
|
-
mimeType: imageData.mimeType,
|
|
57
|
-
data: imageData.base64Data
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
} else {
|
|
61
|
-
// Fallback: try to use as-is (may fail, but attempt anyway)
|
|
62
|
-
console.warn('Reference image format may be incorrect, attempting to use as-is');
|
|
63
|
-
parts.push({
|
|
64
|
-
inlineData: {
|
|
65
|
-
mimeType: 'image/jpeg', // Default to JPEG
|
|
66
|
-
data: referenceImageDataUrl.replace(/^data:image\/[^;]+;base64,/, '')
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Add text instruction with reference image context
|
|
72
|
-
parts.push({
|
|
73
|
-
text: getReferenceImageAnalysisPrompt(userKeyword)
|
|
74
|
-
});
|
|
75
|
-
} else {
|
|
76
|
-
// No reference image, use simple text
|
|
77
|
-
parts.push({ text: getSimpleUserInputPrompt(userKeyword) });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Get system instruction based on whether reference image is provided
|
|
81
|
-
const systemInstruction = referenceImageDataUrl
|
|
82
|
-
? getDoufangSystemPromptWithReference()
|
|
83
|
-
: DOUFANG_SYSTEM_PROMPT;
|
|
84
|
-
|
|
85
|
-
const response = await ai.models.generateContent({
|
|
86
|
-
model: 'gemini-3-flash-preview',
|
|
87
|
-
contents: {
|
|
88
|
-
parts: parts
|
|
89
|
-
},
|
|
90
|
-
config: {
|
|
91
|
-
systemInstruction: systemInstruction,
|
|
92
|
-
responseMimeType: "application/json",
|
|
93
|
-
responseSchema: {
|
|
94
|
-
type: Type.OBJECT,
|
|
95
|
-
properties: {
|
|
96
|
-
blessingPhrase: { type: Type.STRING },
|
|
97
|
-
imagePrompt: { type: Type.STRING }
|
|
98
|
-
},
|
|
99
|
-
required: ["blessingPhrase", "imagePrompt"]
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const text = response.text;
|
|
105
|
-
if (!text) {
|
|
106
|
-
throw new Error("No response from Gemini");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Check if request was cancelled before parsing
|
|
110
|
-
if (signal?.aborted) {
|
|
111
|
-
throw new Error('Request cancelled');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return JSON.parse(text);
|
|
115
|
-
} catch (e: unknown) {
|
|
116
|
-
if (signal?.aborted) {
|
|
117
|
-
throw new Error('Request cancelled');
|
|
118
|
-
}
|
|
119
|
-
throw handleApiError(e, 'generateDoufangPrompt');
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
export const generateDoufangImage = async (
|
|
125
|
-
prompt: string,
|
|
126
|
-
apiKey?: string,
|
|
127
|
-
model: string = 'gemini-2.5-flash-image',
|
|
128
|
-
imageSize: '1K' | '2K' | '4K' = '1K',
|
|
129
|
-
referenceImageDataUrl?: string | null,
|
|
130
|
-
signal?: AbortSignal
|
|
131
|
-
): Promise<string> => {
|
|
132
|
-
return retryWithBackoff(async () => {
|
|
133
|
-
const ai = getClient(apiKey);
|
|
134
|
-
|
|
135
|
-
// Check if request was cancelled
|
|
136
|
-
if (signal?.aborted) {
|
|
137
|
-
throw new Error('Request cancelled');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let config: Record<string, unknown> = {};
|
|
141
|
-
|
|
142
|
-
// Different models have different support for imageConfig
|
|
143
|
-
// Flash model does NOT support imageConfig parameter - it will cause 400 errors
|
|
144
|
-
// Only Pro model supports imageConfig with custom sizes
|
|
145
|
-
if (model === 'gemini-3-pro-image-preview') {
|
|
146
|
-
// Pro model supports all sizes (1K, 2K, 4K)
|
|
147
|
-
config = {
|
|
148
|
-
imageConfig: {
|
|
149
|
-
aspectRatio: "1:1",
|
|
150
|
-
imageSize: imageSize
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
// For Flash model: Do NOT set imageConfig at all
|
|
155
|
-
// Flash model only supports default 1K (1024x1024) resolution
|
|
156
|
-
// Setting imageConfig will cause 400 INVALID_ARGUMENT error
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
// Prepare content parts
|
|
160
|
-
const parts: GeminiContentPart[] = [];
|
|
161
|
-
|
|
162
|
-
// Add reference image if provided - image should come first
|
|
163
|
-
// Note: The prompt already contains style guidance from the reference image
|
|
164
|
-
// (generated in generateDoufangPrompt), so we just need to provide the image
|
|
165
|
-
// as additional visual reference for the image generation model
|
|
166
|
-
if (referenceImageDataUrl) {
|
|
167
|
-
const imageData = processImageDataUrl(referenceImageDataUrl);
|
|
168
|
-
if (imageData) {
|
|
169
|
-
parts.push({
|
|
170
|
-
inlineData: {
|
|
171
|
-
mimeType: imageData.mimeType,
|
|
172
|
-
data: imageData.base64Data
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
} else {
|
|
176
|
-
// Fallback: try to use as-is (may fail, but attempt anyway)
|
|
177
|
-
console.warn('Reference image format may be incorrect, attempting to use as-is');
|
|
178
|
-
parts.push({
|
|
179
|
-
inlineData: {
|
|
180
|
-
mimeType: 'image/jpeg', // Default to JPEG
|
|
181
|
-
data: referenceImageDataUrl.replace(/^data:image\/[^;]+;base64,/, '')
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Add prompt with reference image context
|
|
187
|
-
// The prompt already includes style guidance, so we just reinforce it
|
|
188
|
-
parts.push({
|
|
189
|
-
text: getImageGenerationPromptWithReference(prompt)
|
|
190
|
-
});
|
|
191
|
-
} else {
|
|
192
|
-
// No reference image, use original prompt
|
|
193
|
-
parts.push({ text: prompt });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const response = await ai.models.generateContent({
|
|
197
|
-
model: model,
|
|
198
|
-
contents: {
|
|
199
|
-
parts: parts
|
|
200
|
-
},
|
|
201
|
-
config: Object.keys(config).length > 0 ? config : undefined
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// Check if request was cancelled before processing response
|
|
205
|
-
if (signal?.aborted) {
|
|
206
|
-
throw new Error('Request cancelled');
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Extract image
|
|
210
|
-
for (const part of response.candidates?.[0]?.content?.parts || []) {
|
|
211
|
-
if (part.inlineData) {
|
|
212
|
-
return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
throw new Error("No image generated in the response.");
|
|
217
|
-
} catch (e: unknown) {
|
|
218
|
-
if (signal?.aborted) {
|
|
219
|
-
throw new Error('Request cancelled');
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const error = handleApiError(e, 'generateDoufangImage');
|
|
223
|
-
|
|
224
|
-
// Add specific context for image size errors
|
|
225
|
-
if (error.code === 'INVALID_REQUEST') {
|
|
226
|
-
if (model === 'gemini-2.5-flash-image') {
|
|
227
|
-
error.userMessage = 'Flash 模型不支援自訂圖片大小設定,僅支援預設 1K (1024×1024) 解析度。如需更高解析度,請使用 Pro 模型。';
|
|
228
|
-
} else if (imageSize === '4K') {
|
|
229
|
-
error.userMessage = '4K 解析度可能不被此模型或您的 API 方案支援,請嘗試 2K 或 1K。';
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
throw error;
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
};
|
package/types.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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;
|
package/utils/errorHandler.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
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
|
-
};
|
package/utils/imageUtils.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
};
|