@justin_666/square-couplets-master-skills 1.0.8 → 1.0.10

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.
@@ -1,12 +1,11 @@
1
1
  // Image Processing Constants
2
2
  export const IMAGE_CONSTANTS = {
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
- } as const;
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
- export const getDoufangSystemPromptWithReference = (): string => {
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: string): string => {
192
- return `User input keyword: 「${userKeyword}」
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: string): string => {
230
- return `User input keyword: 「${userKeyword}」`;
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: string): string => {
235
- return `${basePrompt}
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.8",
3
+ "version": "1.0.10",
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
- "services/",
49
- "utils/",
50
- "constants.ts",
51
- "types.ts",
49
+ "dist/",
52
50
  "README.md",
53
51
  "LICENSE",
54
52
  "package.json"
@@ -167,22 +167,41 @@ async function main() {
167
167
  process.exit(1);
168
168
  }
169
169
 
170
- // Dynamic import of service (support both .ts and .js)
170
+ // Dynamic import of service (prioritize compiled .js from dist/)
171
171
  let serviceModule;
172
- try {
173
- // Try .js first (for npm package)
174
- serviceModule = await import(`file://${join(servicesPath, 'geminiService.js')}`);
175
- } catch (e) {
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
- // Try .ts (for development)
178
- serviceModule = await import(`file://${join(servicesPath, 'geminiService.ts')}`);
179
- } catch (e2) {
180
- console.error('❌ Error: Cannot import service module');
181
- console.error(' Tried:', join(servicesPath, 'geminiService.js'));
182
- console.error(' Tried:', join(servicesPath, 'geminiService.ts'));
183
- process.exit(1);
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
@@ -146,34 +146,41 @@ async function main() {
146
146
  process.exit(1);
147
147
  }
148
148
 
149
- // Dynamic import of service (support both .ts and .js)
149
+ // Dynamic import of service (prioritize compiled .js from dist/)
150
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) {
151
+ const possibleServicePaths = [
152
+ // 1. Compiled JS in dist/ (npm package)
153
+ join(dirname(servicesPath), 'dist', 'services', 'geminiService.js'),
154
+ // 2. Compiled JS in services/ (if built locally)
155
+ join(servicesPath, 'geminiService.js'),
156
+ // 3. Source TS (development only)
157
+ join(servicesPath, 'geminiService.ts'),
158
+ ];
159
+
160
+ let importError = null;
161
+ for (const servicePath of possibleServicePaths) {
155
162
  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);
163
+ if (existsSync(servicePath)) {
164
+ serviceModule = await import(`file://${servicePath}`);
165
+ break;
166
+ }
167
+ } catch (e) {
168
+ importError = e;
169
+ continue;
175
170
  }
176
171
  }
172
+
173
+ if (!serviceModule) {
174
+ console.error('❌ Error: Cannot import service module');
175
+ console.error(' Tried paths:');
176
+ possibleServicePaths.forEach(p => console.error(` - ${p}`));
177
+ if (importError) {
178
+ console.error('\n Last error:', importError.message);
179
+ }
180
+ console.error('\n💡 This usually means the package was not properly built.');
181
+ console.error(' Please report this issue at: https://github.com/poirotw66/Square_Couplets_Master/issues');
182
+ process.exit(1);
183
+ }
177
184
  const { generateDoufangPrompt } = serviceModule;
178
185
 
179
186
  // 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;
@@ -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
- };
@@ -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
- };