@justin_666/square-couplets-master-skills 1.0.6 → 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.
- package/bin/doufang-init.js +83 -38
- 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 +4 -6
- package/skills/generate-doufang-image/index.js +31 -12
- package/skills/generate-doufang-prompt/index.js +31 -24
- 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
package/bin/doufang-init.js
CHANGED
|
@@ -119,20 +119,28 @@ Skills are located in the \`skills/\` directory.
|
|
|
119
119
|
writeFileSync(cursorRulesPath, cursorRules);
|
|
120
120
|
console.log(` ✅ Created ${cursorRulesPath}`);
|
|
121
121
|
|
|
122
|
-
// Create .cursor/
|
|
123
|
-
const
|
|
124
|
-
if (!existsSync(
|
|
125
|
-
mkdirSync(
|
|
122
|
+
// Create .cursor/commands directory for Cursor Commands (preferred over rules)
|
|
123
|
+
const cursorCommandsDir = join(projectPath, '.cursor', 'commands');
|
|
124
|
+
if (!existsSync(cursorCommandsDir)) {
|
|
125
|
+
mkdirSync(cursorCommandsDir, { recursive: true });
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// Create doufang.
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
description: Generate Chinese New Year Doufang artwork using Doufang Skills. Use /doufang command to generate prompts, images, or optimize prompts.
|
|
132
|
-
globs:
|
|
133
|
-
---
|
|
128
|
+
// Create doufang.md command file for slash command
|
|
129
|
+
const doufangCommandPath = join(cursorCommandsDir, 'doufang.md');
|
|
130
|
+
const doufangCommand = `# Doufang Skills Command
|
|
134
131
|
|
|
135
|
-
|
|
132
|
+
## Description
|
|
133
|
+
Generate Chinese New Year Doufang artwork using Doufang Skills CLI commands.
|
|
134
|
+
|
|
135
|
+
## CRITICAL RULES - READ FIRST
|
|
136
|
+
|
|
137
|
+
**🚨 NEVER CREATE SCRIPT FILES 🚨**
|
|
138
|
+
- **DO NOT** create any .js, .ts, .mjs, or any script files
|
|
139
|
+
- **DO NOT** write code to files
|
|
140
|
+
- **DO** execute CLI commands directly in the terminal
|
|
141
|
+
- The CLI commands are already installed and ready to use
|
|
142
|
+
|
|
143
|
+
## How This Works
|
|
136
144
|
|
|
137
145
|
You are a Doufang assistant. When the user uses the \`/doufang\` command or asks about Doufang functionality, follow these guidelines:
|
|
138
146
|
|
|
@@ -153,23 +161,31 @@ You are a Doufang assistant. When the user uses the \`/doufang\` command or asks
|
|
|
153
161
|
- Optimizes prompts to reduce white margins and improve composition
|
|
154
162
|
- Example: \`doufang-optimize "A prompt with wide margins..."\`
|
|
155
163
|
|
|
156
|
-
## Execution
|
|
164
|
+
## Step-by-Step Execution Guide
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
### When User Says: "生成財富主題的 prompt"
|
|
167
|
+
1. Execute this command in terminal: \`doufang-prompt "財富"\`
|
|
168
|
+
2. Wait for JSON output
|
|
169
|
+
3. Show the result to user
|
|
170
|
+
|
|
171
|
+
### When User Says: "用上述 prompt 生成 2K 圖片"
|
|
172
|
+
1. Extract the imagePrompt from previous output
|
|
173
|
+
2. Execute this command in terminal: \`doufang-image "<imagePrompt>" gemini-3-pro-image-preview 2K\`
|
|
174
|
+
3. Wait for image to be saved
|
|
175
|
+
4. Show the file path to user
|
|
176
|
+
|
|
177
|
+
### When User Says: "優化這個 prompt"
|
|
178
|
+
1. Execute this command in terminal: \`doufang-optimize "<prompt>"\`
|
|
179
|
+
2. Wait for optimized prompt
|
|
180
|
+
3. Show the result to user
|
|
181
|
+
|
|
182
|
+
## Execution Guidelines
|
|
168
183
|
|
|
169
|
-
- **
|
|
170
|
-
- **
|
|
184
|
+
- **Use Terminal Command Execution**: Use the run_terminal_cmd tool to execute commands
|
|
185
|
+
- **No Script Creation**: You must NOT create any files. Just execute the CLI commands.
|
|
186
|
+
- **API Key**: Commands automatically read GEMINI_API_KEY from .env or .env.local
|
|
171
187
|
- **Error Handling**: If API key is missing, guide user to set GEMINI_API_KEY in .env file
|
|
172
|
-
- **Output
|
|
188
|
+
- **Output**: Commands output JSON or save files to output/ directory
|
|
173
189
|
|
|
174
190
|
## Common Workflows
|
|
175
191
|
|
|
@@ -188,13 +204,39 @@ You are a Doufang assistant. When the user uses the \`/doufang\` command or asks
|
|
|
188
204
|
2. Execute: \`doufang-optimize "<imagePrompt>"\` (directly in terminal)
|
|
189
205
|
3. Execute: \`doufang-image "<optimizedPrompt>" gemini-3-pro-image-preview 2K\` (directly in terminal)
|
|
190
206
|
|
|
191
|
-
##
|
|
207
|
+
## Example: Complete Workflow
|
|
192
208
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
209
|
+
\`\`\`
|
|
210
|
+
User: /doufang 生成一個財富主題的 2K 圖片
|
|
211
|
+
|
|
212
|
+
Your Action:
|
|
213
|
+
Step 1: Execute in terminal
|
|
214
|
+
$ doufang-prompt "財富"
|
|
215
|
+
[Wait for output]
|
|
216
|
+
|
|
217
|
+
Step 2: Extract imagePrompt from JSON output, then execute in terminal
|
|
218
|
+
$ doufang-image "<extracted-imagePrompt>" gemini-3-pro-image-preview 2K
|
|
219
|
+
[Wait for image to be saved]
|
|
220
|
+
|
|
221
|
+
Step 3: Tell user the image path
|
|
222
|
+
\`\`\`
|
|
223
|
+
|
|
224
|
+
## FINAL REMINDERS
|
|
225
|
+
|
|
226
|
+
✅ DO:
|
|
227
|
+
- Execute: \`doufang-prompt "keyword"\`
|
|
228
|
+
- Execute: \`doufang-image "prompt" model size\`
|
|
229
|
+
- Execute: \`doufang-optimize "prompt"\`
|
|
230
|
+
- Use run_terminal_cmd tool
|
|
231
|
+
|
|
232
|
+
❌ DO NOT:
|
|
233
|
+
- Create .js files
|
|
234
|
+
- Create .ts files
|
|
235
|
+
- Create any script files
|
|
236
|
+
- Write code to files
|
|
237
|
+
- Use write tool or edit tool
|
|
238
|
+
|
|
239
|
+
The commands are already installed. Just run them!
|
|
198
240
|
|
|
199
241
|
## Skills Location
|
|
200
242
|
|
|
@@ -210,16 +252,19 @@ The \`generate-doufang-image\` command requires GEMINI_API_KEY. Check for it in:
|
|
|
210
252
|
Get API key from: https://aistudio.google.com/
|
|
211
253
|
`;
|
|
212
254
|
|
|
213
|
-
writeFileSync(
|
|
214
|
-
console.log(` ✅ Created ${
|
|
215
|
-
console.log(` ✅ Slash command /doufang registered in Cursor`);
|
|
255
|
+
writeFileSync(doufangCommandPath, doufangCommand);
|
|
256
|
+
console.log(` ✅ Created ${doufangCommandPath}`);
|
|
257
|
+
console.log(` ✅ Slash command /doufang registered in Cursor Commands`);
|
|
216
258
|
|
|
217
259
|
console.log('\n✨ Cursor setup complete!');
|
|
218
260
|
console.log('\n📝 Usage:');
|
|
219
|
-
console.log(' 1.
|
|
220
|
-
console.log(' 2.
|
|
221
|
-
console.log(' 3.
|
|
222
|
-
console.log(' 4.
|
|
261
|
+
console.log(' 1. Restart Cursor to load the new command');
|
|
262
|
+
console.log(' 2. Type "/" in Cursor chat to see available commands');
|
|
263
|
+
console.log(' 3. Select "/doufang" from the dropdown');
|
|
264
|
+
console.log(' 4. Enter your request, e.g.: "Generate a prompt for wealth theme"');
|
|
265
|
+
console.log(' 5. Or directly type: /doufang Generate a prompt for wealth theme');
|
|
266
|
+
console.log('\n⚠️ IMPORTANT: The /doufang command will execute CLI commands directly.');
|
|
267
|
+
console.log(' It will NOT create any script files. Just run terminal commands.');
|
|
223
268
|
|
|
224
269
|
return true;
|
|
225
270
|
}
|
|
@@ -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.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
|
-
"
|
|
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 (
|
|
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
|
|
@@ -146,34 +146,41 @@ async function main() {
|
|
|
146
146
|
process.exit(1);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// Dynamic import of service (
|
|
149
|
+
// Dynamic import of service (prioritize compiled .js from dist/)
|
|
150
150
|
let serviceModule;
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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);
|
|
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;
|
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
|
-
};
|