@markus-global/cli 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/commands/start.d.ts.map +1 -1
  2. package/dist/commands/start.js +39 -11
  3. package/dist/commands/start.js.map +1 -1
  4. package/dist/markus.mjs +1214 -339
  5. package/dist/web-ui/assets/index-DcnwpDqb.css +1 -0
  6. package/dist/web-ui/assets/index-tONQYLWM.js +351 -0
  7. package/dist/web-ui/index.html +2 -2
  8. package/dist/web-ui/logo.png +0 -0
  9. package/package.json +1 -1
  10. package/templates/roles/SHARED.md +3 -6
  11. package/templates/roles/developer/POLICIES.md +1 -1
  12. package/templates/roles/secretary/HEARTBEAT.md +1 -1
  13. package/templates/roles/secretary/ROLE.md +80 -4
  14. package/templates/skills/agent-building/SKILL.md +1 -1
  15. package/templates/skills/chrome-devtools/SKILL.md +56 -0
  16. package/templates/skills/image-generation/SKILL.md +183 -0
  17. package/templates/skills/image-generation/server.mjs +1269 -0
  18. package/templates/skills/image-generation/skill.json +26 -0
  19. package/templates/skills/markus-admin-cli/SKILL.md +1 -1
  20. package/templates/skills/self-evolution/SKILL.md +4 -4
  21. package/templates/skills/skill-building/SKILL.md +1 -1
  22. package/templates/skills/team-building/SKILL.md +1 -1
  23. package/templates/teams/content-team/ANNOUNCEMENT.md +28 -24
  24. package/templates/teams/content-team/NORMS.md +50 -48
  25. package/templates/teams/content-team/team.json +46 -16
  26. package/templates/teams/research-lab/ANNOUNCEMENT.md +24 -19
  27. package/templates/teams/research-lab/NORMS.md +77 -88
  28. package/templates/teams/research-lab/team.json +40 -14
  29. package/dist/web-ui/assets/index-C97PujBE.js +0 -351
  30. package/dist/web-ui/assets/index-Q4_kHftV.css +0 -1
@@ -0,0 +1,1269 @@
1
+ #!/usr/bin/env node
2
+
3
+ // MCP server for multi-provider AI image generation.
4
+ // Detects available providers from environment variables and routes
5
+ // requests to the appropriate API. Saves generated images to disk.
6
+ // Protocol: JSON-RPC 2.0 over stdio (MCP 2024-11-05).
7
+
8
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { createInterface } from 'node:readline';
12
+
13
+ const DEFAULT_OUTPUT_DIR = join(homedir(), '.markus', 'generated-images');
14
+
15
+ // ─── Provider registry ──────────────────────────────────────────────────────
16
+
17
+ const PROVIDERS = {
18
+ openai: {
19
+ name: 'OpenAI',
20
+ envKey: 'OPENAI_API_KEY',
21
+ defaultModel: 'dall-e-3',
22
+ models: ['dall-e-3', 'dall-e-2', 'gpt-image-1'],
23
+ supportedSizes: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'],
24
+ supportsEdit: true,
25
+ supportsNegativePrompt: false,
26
+ },
27
+ azure_openai: {
28
+ name: 'Azure OpenAI',
29
+ envKey: 'AZURE_OPENAI_API_KEY',
30
+ extraEnv: ['AZURE_OPENAI_ENDPOINT'],
31
+ defaultModel: 'dall-e-3',
32
+ models: ['dall-e-3', 'dall-e-2'],
33
+ supportedSizes: ['1024x1024', '1024x1792', '1792x1024'],
34
+ supportsEdit: false,
35
+ supportsNegativePrompt: false,
36
+ },
37
+ stability: {
38
+ name: 'Stability AI',
39
+ envKey: 'STABILITY_API_KEY',
40
+ defaultModel: 'sd3-large',
41
+ models: ['sd3-large', 'sd3-large-turbo', 'sd3-medium', 'stable-image-ultra', 'stable-image-core'],
42
+ supportedSizes: ['1024x1024', '1536x1024', '1024x1536', '1344x768', '768x1344'],
43
+ supportsEdit: true,
44
+ supportsNegativePrompt: true,
45
+ },
46
+ google: {
47
+ name: 'Google Imagen',
48
+ envKey: 'GOOGLE_API_KEY',
49
+ defaultModel: 'imagen-3.0-generate-002',
50
+ models: ['imagen-3.0-generate-002', 'imagen-3.0-generate-001'],
51
+ supportedSizes: ['1024x1024', '1536x1024', '1024x1536'],
52
+ supportsEdit: false,
53
+ supportsNegativePrompt: true,
54
+ },
55
+ replicate: {
56
+ name: 'Replicate',
57
+ envKey: 'REPLICATE_API_TOKEN',
58
+ defaultModel: 'black-forest-labs/flux-1.1-pro',
59
+ models: ['black-forest-labs/flux-1.1-pro', 'black-forest-labs/flux-schnell', 'stability-ai/sdxl'],
60
+ supportedSizes: ['1024x1024', '1024x768', '768x1024'],
61
+ supportsEdit: false,
62
+ supportsNegativePrompt: true,
63
+ },
64
+ tongyi: {
65
+ name: 'Tongyi Wanxiang (Aliyun)',
66
+ envKey: 'DASHSCOPE_API_KEY',
67
+ defaultModel: 'wanx2.1-t2i-turbo',
68
+ models: ['wanx2.1-t2i-turbo', 'wanx2.1-t2i-plus', 'wanx-v1'],
69
+ supportedSizes: ['1024x1024', '720x1280', '1280x720'],
70
+ supportsEdit: false,
71
+ supportsNegativePrompt: true,
72
+ },
73
+ zhipu: {
74
+ name: 'Zhipu AI',
75
+ envKey: 'ZHIPU_API_KEY',
76
+ defaultModel: 'cogview-4',
77
+ models: ['cogview-4', 'cogview-4-250304', 'cogview-3-plus', 'cogview-3'],
78
+ supportedSizes: ['1024x1024', '768x1344', '1344x768', '864x1152', '1152x864'],
79
+ supportsEdit: false,
80
+ supportsNegativePrompt: false,
81
+ },
82
+ siliconflow: {
83
+ name: 'SiliconFlow',
84
+ envKey: 'SILICONFLOW_API_KEY',
85
+ defaultModel: 'black-forest-labs/FLUX.1-schnell',
86
+ models: [
87
+ 'black-forest-labs/FLUX.1-schnell',
88
+ 'black-forest-labs/FLUX.1-dev',
89
+ 'black-forest-labs/FLUX.1-pro',
90
+ 'black-forest-labs/FLUX.1.1-pro',
91
+ 'stabilityai/stable-diffusion-3-5-large',
92
+ 'stabilityai/stable-diffusion-3-5-large-turbo',
93
+ 'stabilityai/stable-diffusion-3-5-medium',
94
+ 'stabilityai/stable-diffusion-xl-base-1.0',
95
+ 'Qwen/Qwen-Image',
96
+ 'deepseek-ai/Janus-Pro-7B',
97
+ ],
98
+ supportedSizes: ['1024x1024', '1024x768', '768x1024', '1024x576', '576x1024'],
99
+ supportsEdit: false,
100
+ supportsNegativePrompt: true,
101
+ },
102
+ together: {
103
+ name: 'Together AI',
104
+ envKey: 'TOGETHER_API_KEY',
105
+ defaultModel: 'black-forest-labs/FLUX.1.1-pro',
106
+ models: ['black-forest-labs/FLUX.1.1-pro', 'black-forest-labs/FLUX.1-schnell', 'stabilityai/stable-diffusion-xl-base-1.0'],
107
+ supportedSizes: ['1024x1024', '1024x768', '768x1024'],
108
+ supportsEdit: false,
109
+ supportsNegativePrompt: true,
110
+ },
111
+ fal: {
112
+ name: 'FAL',
113
+ envKey: 'FAL_KEY',
114
+ defaultModel: 'fal-ai/flux-pro/v1.1',
115
+ models: ['fal-ai/flux-pro/v1.1', 'fal-ai/flux/schnell', 'fal-ai/flux/dev', 'fal-ai/flux-pro', 'fal-ai/stable-diffusion-v35-large'],
116
+ supportedSizes: ['1024x1024', '1024x768', '768x1024', '1280x720', '720x1280'],
117
+ supportsEdit: false,
118
+ supportsNegativePrompt: true,
119
+ },
120
+ ideogram: {
121
+ name: 'Ideogram',
122
+ envKey: 'IDEOGRAM_API_KEY',
123
+ defaultModel: 'V_2',
124
+ models: ['V_2', 'V_2_TURBO', 'V_1', 'V_1_TURBO'],
125
+ supportedSizes: ['1024x1024', '1024x768', '768x1024', '1344x768', '768x1344'],
126
+ supportsEdit: true,
127
+ supportsNegativePrompt: true,
128
+ },
129
+ baidu: {
130
+ name: 'Baidu ERNIE ViLG',
131
+ envKey: 'BAIDU_API_KEY',
132
+ extraEnv: ['BAIDU_SECRET_KEY'],
133
+ defaultModel: 'sd_xl',
134
+ models: ['sd_xl', 'ernievilg-v1'],
135
+ supportedSizes: ['1024x1024', '768x1024', '1024x768', '576x1024', '1024x576'],
136
+ supportsEdit: false,
137
+ supportsNegativePrompt: true,
138
+ },
139
+ hunyuan: {
140
+ name: 'Tencent Hunyuan',
141
+ envKey: 'HUNYUAN_API_KEY',
142
+ defaultModel: 'hunyuan-image',
143
+ models: ['hunyuan-image', 'hunyuan-image-fast'],
144
+ supportedSizes: ['1024x1024', '768x1024', '1024x768'],
145
+ supportsEdit: false,
146
+ supportsNegativePrompt: true,
147
+ },
148
+ volcengine: {
149
+ name: 'Volcengine (Doubao)',
150
+ envKey: 'VOLCENGINE_API_KEY',
151
+ defaultModel: 'general_v2.1_L',
152
+ models: ['general_v2.1_L', 'general_v2.0_L', 'general_v1.4'],
153
+ supportedSizes: ['1024x1024', '768x1024', '1024x768', '512x512'],
154
+ supportsEdit: false,
155
+ supportsNegativePrompt: false,
156
+ },
157
+ };
158
+
159
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
160
+
161
+ function getEnv(key) {
162
+ return process.env[key] || null;
163
+ }
164
+
165
+ function getAvailableProviders() {
166
+ const available = [];
167
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
168
+ const apiKey = getEnv(cfg.envKey);
169
+ if (!apiKey) continue;
170
+ if (cfg.extraEnv) {
171
+ const missing = cfg.extraEnv.filter(k => !getEnv(k));
172
+ if (missing.length > 0) continue;
173
+ }
174
+ available.push({
175
+ id,
176
+ name: cfg.name,
177
+ defaultModel: cfg.defaultModel,
178
+ models: cfg.models,
179
+ supportedSizes: cfg.supportedSizes,
180
+ supportsEdit: cfg.supportsEdit,
181
+ supportsNegativePrompt: cfg.supportsNegativePrompt,
182
+ });
183
+ }
184
+ return available;
185
+ }
186
+
187
+ function pickProvider(requestedProvider) {
188
+ if (requestedProvider) {
189
+ const cfg = PROVIDERS[requestedProvider];
190
+ if (!cfg) throw new Error(`Unknown provider: ${requestedProvider}. Available: ${Object.keys(PROVIDERS).join(', ')}`);
191
+ if (!getEnv(cfg.envKey)) throw new Error(`Provider ${requestedProvider} requires ${cfg.envKey} to be set`);
192
+ return requestedProvider;
193
+ }
194
+ for (const id of Object.keys(PROVIDERS)) {
195
+ const cfg = PROVIDERS[id];
196
+ if (!getEnv(cfg.envKey)) continue;
197
+ if (cfg.extraEnv && cfg.extraEnv.some(k => !getEnv(k))) continue;
198
+ return id;
199
+ }
200
+ throw new Error(
201
+ 'No image generation provider configured. Set one of these environment variables: ' +
202
+ Object.values(PROVIDERS).map(p => p.envKey).join(', ')
203
+ );
204
+ }
205
+
206
+ function timestamp() {
207
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
208
+ }
209
+
210
+ function ensureDir(dir) {
211
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
212
+ }
213
+
214
+ function saveBase64Image(base64, outputDir, format) {
215
+ ensureDir(outputDir);
216
+ const ext = format || 'png';
217
+ const filename = `image-${timestamp()}.${ext}`;
218
+ const filepath = join(outputDir, filename);
219
+ writeFileSync(filepath, Buffer.from(base64, 'base64'));
220
+ return filepath;
221
+ }
222
+
223
+ async function downloadImage(url, outputDir, format) {
224
+ ensureDir(outputDir);
225
+ const ext = format || 'png';
226
+ const filename = `image-${timestamp()}.${ext}`;
227
+ const filepath = join(outputDir, filename);
228
+ const res = await fetch(url);
229
+ if (!res.ok) throw new Error(`Failed to download image: HTTP ${res.status}`);
230
+ const buf = Buffer.from(await res.arrayBuffer());
231
+ writeFileSync(filepath, buf);
232
+ return filepath;
233
+ }
234
+
235
+ // ─── Provider implementations ────────────────────────────────────────────────
236
+
237
+ async function generateOpenAI(args) {
238
+ const apiKey = getEnv('OPENAI_API_KEY');
239
+ const model = args.model || 'dall-e-3';
240
+ const body = {
241
+ model,
242
+ prompt: args.prompt,
243
+ n: args.n || 1,
244
+ size: args.size || '1024x1024',
245
+ };
246
+ if (model === 'dall-e-3') {
247
+ if (args.quality) body.quality = args.quality;
248
+ if (args.style) body.style = args.style;
249
+ body.response_format = 'b64_json';
250
+ } else if (model === 'gpt-image-1') {
251
+ if (args.quality) body.quality = args.quality;
252
+ body.response_format = 'b64_json';
253
+ } else {
254
+ body.response_format = 'b64_json';
255
+ }
256
+
257
+ const res = await fetch('https://api.openai.com/v1/images/generations', {
258
+ method: 'POST',
259
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
260
+ body: JSON.stringify(body),
261
+ });
262
+ if (!res.ok) {
263
+ const errText = await res.text().catch(() => '');
264
+ throw new Error(`OpenAI API error ${res.status}: ${errText}`);
265
+ }
266
+ const data = await res.json();
267
+ const results = [];
268
+ for (const item of data.data) {
269
+ if (item.b64_json) {
270
+ const filepath = saveBase64Image(item.b64_json, args.output_dir, args.output_format);
271
+ results.push({ file_path: filepath, revised_prompt: item.revised_prompt });
272
+ } else if (item.url) {
273
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
274
+ results.push({ file_path: filepath, revised_prompt: item.revised_prompt });
275
+ }
276
+ }
277
+ return results;
278
+ }
279
+
280
+ async function generateAzureOpenAI(args) {
281
+ const apiKey = getEnv('AZURE_OPENAI_API_KEY');
282
+ const endpoint = getEnv('AZURE_OPENAI_ENDPOINT');
283
+ const model = args.model || 'dall-e-3';
284
+ const body = {
285
+ prompt: args.prompt,
286
+ n: args.n || 1,
287
+ size: args.size || '1024x1024',
288
+ response_format: 'b64_json',
289
+ };
290
+ if (args.quality) body.quality = args.quality;
291
+ if (args.style) body.style = args.style;
292
+
293
+ const url = `${endpoint}/openai/deployments/${model}/images/generations?api-version=2024-02-01`;
294
+ const res = await fetch(url, {
295
+ method: 'POST',
296
+ headers: { 'api-key': apiKey, 'Content-Type': 'application/json' },
297
+ body: JSON.stringify(body),
298
+ });
299
+ if (!res.ok) {
300
+ const errText = await res.text().catch(() => '');
301
+ throw new Error(`Azure OpenAI API error ${res.status}: ${errText}`);
302
+ }
303
+ const data = await res.json();
304
+ const results = [];
305
+ for (const item of data.data) {
306
+ if (item.b64_json) {
307
+ const filepath = saveBase64Image(item.b64_json, args.output_dir, args.output_format);
308
+ results.push({ file_path: filepath, revised_prompt: item.revised_prompt });
309
+ }
310
+ }
311
+ return results;
312
+ }
313
+
314
+ async function generateStability(args) {
315
+ const apiKey = getEnv('STABILITY_API_KEY');
316
+ const model = args.model || 'sd3-large';
317
+
318
+ const formData = new FormData();
319
+ formData.append('prompt', args.prompt);
320
+ if (args.negative_prompt) formData.append('negative_prompt', args.negative_prompt);
321
+ formData.append('output_format', args.output_format || 'png');
322
+
323
+ if (model.startsWith('sd3')) {
324
+ formData.append('model', model);
325
+ if (args.seed !== undefined) formData.append('seed', String(args.seed));
326
+ const aspectMap = {
327
+ '1024x1024': '1:1', '1536x1024': '3:2', '1024x1536': '2:3',
328
+ '1344x768': '16:9', '768x1344': '9:16',
329
+ };
330
+ if (args.size && aspectMap[args.size]) formData.append('aspect_ratio', aspectMap[args.size]);
331
+ }
332
+
333
+ const endpoint = model.startsWith('stable-image')
334
+ ? `https://api.stability.ai/v2beta/stable-image/generate/${model.replace('stable-image-', '')}`
335
+ : 'https://api.stability.ai/v2beta/stable-image/generate/sd3';
336
+
337
+ const res = await fetch(endpoint, {
338
+ method: 'POST',
339
+ headers: {
340
+ 'Authorization': `Bearer ${apiKey}`,
341
+ 'Accept': 'image/*',
342
+ },
343
+ body: formData,
344
+ });
345
+ if (!res.ok) {
346
+ const errText = await res.text().catch(() => '');
347
+ throw new Error(`Stability API error ${res.status}: ${errText}`);
348
+ }
349
+ const buf = Buffer.from(await res.arrayBuffer());
350
+ ensureDir(args.output_dir);
351
+ const ext = args.output_format || 'png';
352
+ const filename = `image-${timestamp()}.${ext}`;
353
+ const filepath = join(args.output_dir, filename);
354
+ writeFileSync(filepath, buf);
355
+ return [{ file_path: filepath }];
356
+ }
357
+
358
+ async function generateGoogle(args) {
359
+ const apiKey = getEnv('GOOGLE_API_KEY');
360
+ const model = args.model || 'imagen-3.0-generate-002';
361
+
362
+ const body = {
363
+ instances: [{ prompt: args.prompt }],
364
+ parameters: {
365
+ sampleCount: args.n || 1,
366
+ },
367
+ };
368
+ if (args.negative_prompt) body.parameters.negativePrompt = args.negative_prompt;
369
+ if (args.seed !== undefined) body.parameters.seed = args.seed;
370
+ if (args.size) {
371
+ const [w, h] = args.size.split('x').map(Number);
372
+ if (w && h) {
373
+ body.parameters.aspectRatio = w === h ? '1:1' : w > h ? '3:2' : '2:3';
374
+ }
375
+ }
376
+
377
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict?key=${apiKey}`;
378
+ const res = await fetch(url, {
379
+ method: 'POST',
380
+ headers: { 'Content-Type': 'application/json' },
381
+ body: JSON.stringify(body),
382
+ });
383
+ if (!res.ok) {
384
+ const errText = await res.text().catch(() => '');
385
+ throw new Error(`Google Imagen API error ${res.status}: ${errText}`);
386
+ }
387
+ const data = await res.json();
388
+ const results = [];
389
+ for (const pred of (data.predictions || [])) {
390
+ if (pred.bytesBase64Encoded) {
391
+ const filepath = saveBase64Image(pred.bytesBase64Encoded, args.output_dir, args.output_format);
392
+ results.push({ file_path: filepath });
393
+ }
394
+ }
395
+ return results;
396
+ }
397
+
398
+ async function generateReplicate(args) {
399
+ const apiToken = getEnv('REPLICATE_API_TOKEN');
400
+ const model = args.model || 'black-forest-labs/flux-1.1-pro';
401
+
402
+ const input = { prompt: args.prompt };
403
+ if (args.negative_prompt) input.negative_prompt = args.negative_prompt;
404
+ if (args.size) {
405
+ const [w, h] = args.size.split('x').map(Number);
406
+ if (w && h) { input.width = w; input.height = h; }
407
+ }
408
+ if (args.seed !== undefined) input.seed = args.seed;
409
+ if (args.n && args.n > 1) input.num_outputs = args.n;
410
+
411
+ const createRes = await fetch('https://api.replicate.com/v1/predictions', {
412
+ method: 'POST',
413
+ headers: {
414
+ 'Authorization': `Bearer ${apiToken}`,
415
+ 'Content-Type': 'application/json',
416
+ 'Prefer': 'wait',
417
+ },
418
+ body: JSON.stringify({ version: undefined, model, input }),
419
+ });
420
+ if (!createRes.ok) {
421
+ const errText = await createRes.text().catch(() => '');
422
+ throw new Error(`Replicate API error ${createRes.status}: ${errText}`);
423
+ }
424
+
425
+ let prediction = await createRes.json();
426
+
427
+ // Poll if not yet completed (Prefer: wait may not always work)
428
+ let attempts = 0;
429
+ while (prediction.status !== 'succeeded' && prediction.status !== 'failed' && attempts < 60) {
430
+ await new Promise(r => setTimeout(r, 2000));
431
+ const pollRes = await fetch(`https://api.replicate.com/v1/predictions/${prediction.id}`, {
432
+ headers: { 'Authorization': `Bearer ${apiToken}` },
433
+ });
434
+ prediction = await pollRes.json();
435
+ attempts++;
436
+ }
437
+
438
+ if (prediction.status === 'failed') {
439
+ throw new Error(`Replicate prediction failed: ${prediction.error || 'unknown error'}`);
440
+ }
441
+
442
+ const outputs = Array.isArray(prediction.output) ? prediction.output : [prediction.output];
443
+ const results = [];
444
+ for (const outputUrl of outputs) {
445
+ if (typeof outputUrl === 'string') {
446
+ const filepath = await downloadImage(outputUrl, args.output_dir, args.output_format);
447
+ results.push({ file_path: filepath });
448
+ }
449
+ }
450
+ return results;
451
+ }
452
+
453
+ async function generateTongyi(args) {
454
+ const apiKey = getEnv('DASHSCOPE_API_KEY');
455
+ const model = args.model || 'wanx2.1-t2i-turbo';
456
+
457
+ const body = {
458
+ model,
459
+ input: { prompt: args.prompt },
460
+ parameters: { n: args.n || 1 },
461
+ };
462
+ if (args.negative_prompt) body.input.negative_prompt = args.negative_prompt;
463
+ if (args.size) body.parameters.size = args.size;
464
+ if (args.seed !== undefined) body.parameters.seed = args.seed;
465
+ if (args.style) body.parameters.style = args.style;
466
+
467
+ // Async task submission
468
+ const submitRes = await fetch('https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis', {
469
+ method: 'POST',
470
+ headers: {
471
+ 'Authorization': `Bearer ${apiKey}`,
472
+ 'Content-Type': 'application/json',
473
+ 'X-DashScope-Async': 'enable',
474
+ },
475
+ body: JSON.stringify(body),
476
+ });
477
+ if (!submitRes.ok) {
478
+ const errText = await submitRes.text().catch(() => '');
479
+ throw new Error(`Tongyi API error ${submitRes.status}: ${errText}`);
480
+ }
481
+ const submitData = await submitRes.json();
482
+ const taskId = submitData.output?.task_id;
483
+ if (!taskId) throw new Error('Tongyi API did not return a task_id');
484
+
485
+ // Poll for completion
486
+ let taskResult;
487
+ let attempts = 0;
488
+ while (attempts < 60) {
489
+ await new Promise(r => setTimeout(r, 3000));
490
+ const pollRes = await fetch(`https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`, {
491
+ headers: { 'Authorization': `Bearer ${apiKey}` },
492
+ });
493
+ taskResult = await pollRes.json();
494
+ const status = taskResult.output?.task_status;
495
+ if (status === 'SUCCEEDED') break;
496
+ if (status === 'FAILED') throw new Error(`Tongyi task failed: ${taskResult.output?.message || 'unknown'}`);
497
+ attempts++;
498
+ }
499
+
500
+ const results = [];
501
+ for (const item of (taskResult.output?.results || [])) {
502
+ if (item.url) {
503
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
504
+ results.push({ file_path: filepath });
505
+ } else if (item.b64_image) {
506
+ const filepath = saveBase64Image(item.b64_image, args.output_dir, args.output_format);
507
+ results.push({ file_path: filepath });
508
+ }
509
+ }
510
+ return results;
511
+ }
512
+
513
+ async function generateZhipu(args) {
514
+ const apiKey = getEnv('ZHIPU_API_KEY');
515
+ const model = args.model || 'cogview-4';
516
+
517
+ const body = {
518
+ model,
519
+ prompt: args.prompt,
520
+ };
521
+ if (args.size) body.size = args.size;
522
+ if (args.quality) body.quality = args.quality;
523
+ if (args.style) body.style = args.style;
524
+
525
+ const res = await fetch('https://open.bigmodel.cn/api/paas/v4/images/generations', {
526
+ method: 'POST',
527
+ headers: {
528
+ 'Authorization': `Bearer ${apiKey}`,
529
+ 'Content-Type': 'application/json',
530
+ },
531
+ body: JSON.stringify(body),
532
+ });
533
+ if (!res.ok) {
534
+ const errText = await res.text().catch(() => '');
535
+ throw new Error(`Zhipu API error ${res.status}: ${errText}`);
536
+ }
537
+ const data = await res.json();
538
+ const results = [];
539
+ for (const item of (data.data || [])) {
540
+ if (item.b64_json) {
541
+ const filepath = saveBase64Image(item.b64_json, args.output_dir, args.output_format);
542
+ results.push({ file_path: filepath });
543
+ } else if (item.url) {
544
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
545
+ results.push({ file_path: filepath });
546
+ }
547
+ }
548
+ return results;
549
+ }
550
+
551
+ async function generateSiliconFlow(args) {
552
+ const apiKey = getEnv('SILICONFLOW_API_KEY');
553
+ const SILICONFLOW_DEFAULT = 'black-forest-labs/FLUX.1-schnell';
554
+ const model = args.model || SILICONFLOW_DEFAULT;
555
+ const isFallbackAttempt = args._siliconflowRetry === true;
556
+
557
+ const body = {
558
+ model,
559
+ prompt: args.prompt,
560
+ image_size: args.size || '1024x1024',
561
+ batch_size: args.n || 1,
562
+ };
563
+ if (args.negative_prompt) body.negative_prompt = args.negative_prompt;
564
+ if (args.seed !== undefined) body.seed = args.seed;
565
+
566
+ const res = await fetch('https://api.siliconflow.cn/v1/images/generations', {
567
+ method: 'POST',
568
+ headers: {
569
+ 'Authorization': `Bearer ${apiKey}`,
570
+ 'Content-Type': 'application/json',
571
+ },
572
+ body: JSON.stringify(body),
573
+ });
574
+
575
+ if (!res.ok) {
576
+ const errText = await res.text().catch(() => '');
577
+ const isModelIssue = (res.status === 400 || res.status === 404) &&
578
+ (errText.toLowerCase().includes('not found') ||
579
+ errText.toLowerCase().includes('disabled') ||
580
+ errText.toLowerCase().includes('not support') ||
581
+ errText.toLowerCase().includes('available') ||
582
+ errText.toLowerCase().includes('permission'));
583
+
584
+ if (isModelIssue && !isFallbackAttempt && model !== SILICONFLOW_DEFAULT) {
585
+ // Auto-fallback: retry with the default model
586
+ const fallbackArgs = { ...args, model: SILICONFLOW_DEFAULT, _siliconflowRetry: true };
587
+ const fallbackResult = await generateSiliconFlow(fallbackArgs);
588
+ // Tag the result so the caller knows fallback occurred
589
+ fallbackResult._fallback = true;
590
+ fallbackResult._originalModel = model;
591
+ fallbackResult._fallbackModel = SILICONFLOW_DEFAULT;
592
+ // Update args.model so the response handler uses the actual model
593
+ args.model = SILICONFLOW_DEFAULT;
594
+ return fallbackResult;
595
+ }
596
+
597
+ // Enhanced error message with available models hint
598
+ const modelHint = isModelIssue
599
+ ? `The requested model "${model}" is not available on your account. Available models: ${PROVIDERS.siliconflow.models.join(', ')}`
600
+ : `Available models: ${PROVIDERS.siliconflow.models.join(', ')}`;
601
+
602
+ throw new Error(`SiliconFlow API error ${res.status}: ${errText}. ${modelHint}`);
603
+ }
604
+
605
+ const data = await res.json();
606
+ const results = [];
607
+ for (const item of (data.images || data.data || [])) {
608
+ const url = item.url || item;
609
+ if (typeof url === 'string') {
610
+ const filepath = await downloadImage(url, args.output_dir, args.output_format);
611
+ results.push({ file_path: filepath });
612
+ }
613
+ }
614
+ return results;
615
+ }
616
+
617
+ async function generateTogether(args) {
618
+ const apiKey = getEnv('TOGETHER_API_KEY');
619
+ const model = args.model || 'black-forest-labs/FLUX.1.1-pro';
620
+
621
+ const body = {
622
+ model,
623
+ prompt: args.prompt,
624
+ n: args.n || 1,
625
+ width: 1024,
626
+ height: 1024,
627
+ response_format: 'b64_json',
628
+ };
629
+ if (args.negative_prompt) body.negative_prompt = args.negative_prompt;
630
+ if (args.seed !== undefined) body.seed = args.seed;
631
+ if (args.size) {
632
+ const [w, h] = args.size.split('x').map(Number);
633
+ if (w && h) { body.width = w; body.height = h; }
634
+ }
635
+
636
+ const res = await fetch('https://api.together.xyz/v1/images/generations', {
637
+ method: 'POST',
638
+ headers: {
639
+ 'Authorization': `Bearer ${apiKey}`,
640
+ 'Content-Type': 'application/json',
641
+ },
642
+ body: JSON.stringify(body),
643
+ });
644
+ if (!res.ok) {
645
+ const errText = await res.text().catch(() => '');
646
+ throw new Error(`Together AI API error ${res.status}: ${errText}`);
647
+ }
648
+ const data = await res.json();
649
+ const results = [];
650
+ for (const item of (data.data || [])) {
651
+ if (item.b64_json) {
652
+ const filepath = saveBase64Image(item.b64_json, args.output_dir, args.output_format);
653
+ results.push({ file_path: filepath });
654
+ } else if (item.url) {
655
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
656
+ results.push({ file_path: filepath });
657
+ }
658
+ }
659
+ return results;
660
+ }
661
+
662
+ async function generateFal(args) {
663
+ const apiKey = getEnv('FAL_KEY');
664
+ const model = args.model || 'fal-ai/flux-pro/v1.1';
665
+
666
+ const input = { prompt: args.prompt };
667
+ if (args.negative_prompt) input.negative_prompt = args.negative_prompt;
668
+ if (args.seed !== undefined) input.seed = args.seed;
669
+ if (args.size) {
670
+ const [w, h] = args.size.split('x').map(Number);
671
+ if (w && h) { input.image_size = { width: w, height: h }; }
672
+ }
673
+ if (args.n && args.n > 1) input.num_images = args.n;
674
+
675
+ // Submit request
676
+ const submitRes = await fetch(`https://queue.fal.run/${model}`, {
677
+ method: 'POST',
678
+ headers: {
679
+ 'Authorization': `Key ${apiKey}`,
680
+ 'Content-Type': 'application/json',
681
+ },
682
+ body: JSON.stringify(input),
683
+ });
684
+ if (!submitRes.ok) {
685
+ const errText = await submitRes.text().catch(() => '');
686
+ throw new Error(`FAL API error ${submitRes.status}: ${errText}`);
687
+ }
688
+ const submitData = await submitRes.json();
689
+ const requestId = submitData.request_id;
690
+
691
+ // Poll for result
692
+ let result;
693
+ let attempts = 0;
694
+ while (attempts < 120) {
695
+ await new Promise(r => setTimeout(r, 2000));
696
+ const statusRes = await fetch(`https://queue.fal.run/${model}/requests/${requestId}/status`, {
697
+ headers: { 'Authorization': `Key ${apiKey}` },
698
+ });
699
+ const statusData = await statusRes.json();
700
+ if (statusData.status === 'COMPLETED') {
701
+ const resultRes = await fetch(`https://queue.fal.run/${model}/requests/${requestId}`, {
702
+ headers: { 'Authorization': `Key ${apiKey}` },
703
+ });
704
+ result = await resultRes.json();
705
+ break;
706
+ }
707
+ if (statusData.status === 'FAILED') {
708
+ throw new Error(`FAL task failed: ${statusData.error || 'unknown error'}`);
709
+ }
710
+ attempts++;
711
+ }
712
+ if (!result) throw new Error('FAL task timed out');
713
+
714
+ const results = [];
715
+ for (const img of (result.images || [])) {
716
+ if (img.url) {
717
+ const filepath = await downloadImage(img.url, args.output_dir, args.output_format);
718
+ results.push({ file_path: filepath });
719
+ }
720
+ }
721
+ return results;
722
+ }
723
+
724
+ async function generateIdeogram(args) {
725
+ const apiKey = getEnv('IDEOGRAM_API_KEY');
726
+ const model = args.model || 'V_2';
727
+
728
+ const imageRequest = {
729
+ prompt: args.prompt,
730
+ model,
731
+ };
732
+ if (args.negative_prompt) imageRequest.negative_prompt = args.negative_prompt;
733
+ if (args.seed !== undefined) imageRequest.seed = args.seed;
734
+ if (args.style) imageRequest.style_type = args.style;
735
+ if (args.size) {
736
+ const aspectMap = {
737
+ '1024x1024': 'ASPECT_1_1', '1024x768': 'ASPECT_4_3', '768x1024': 'ASPECT_3_4',
738
+ '1344x768': 'ASPECT_16_9', '768x1344': 'ASPECT_9_16',
739
+ };
740
+ if (aspectMap[args.size]) imageRequest.aspect_ratio = aspectMap[args.size];
741
+ }
742
+
743
+ const res = await fetch('https://api.ideogram.ai/generate', {
744
+ method: 'POST',
745
+ headers: {
746
+ 'Api-Key': apiKey,
747
+ 'Content-Type': 'application/json',
748
+ },
749
+ body: JSON.stringify({ image_request: imageRequest }),
750
+ });
751
+ if (!res.ok) {
752
+ const errText = await res.text().catch(() => '');
753
+ throw new Error(`Ideogram API error ${res.status}: ${errText}`);
754
+ }
755
+ const data = await res.json();
756
+ const results = [];
757
+ for (const item of (data.data || [])) {
758
+ if (item.url) {
759
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
760
+ results.push({ file_path: filepath, prompt: item.prompt });
761
+ }
762
+ }
763
+ return results;
764
+ }
765
+
766
+ async function generateBaidu(args) {
767
+ const apiKey = getEnv('BAIDU_API_KEY');
768
+ const secretKey = getEnv('BAIDU_SECRET_KEY');
769
+
770
+ // Get access token
771
+ const tokenRes = await fetch(
772
+ `https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=${apiKey}&client_secret=${secretKey}`,
773
+ { method: 'POST' }
774
+ );
775
+ if (!tokenRes.ok) throw new Error(`Baidu token error: ${tokenRes.status}`);
776
+ const tokenData = await tokenRes.json();
777
+ const accessToken = tokenData.access_token;
778
+ if (!accessToken) throw new Error('Failed to get Baidu access token');
779
+
780
+ const model = args.model || 'sd_xl';
781
+ const body = {
782
+ prompt: args.prompt,
783
+ n: args.n || 1,
784
+ size: args.size || '1024x1024',
785
+ };
786
+ if (args.negative_prompt) body.negative_prompt = args.negative_prompt;
787
+ if (args.seed !== undefined) body.seed = args.seed;
788
+
789
+ const res = await fetch(
790
+ `https://aip.baidubce.com/rpc/2.0/ernievilg/v1/txt2imgv2?access_token=${accessToken}`,
791
+ {
792
+ method: 'POST',
793
+ headers: { 'Content-Type': 'application/json' },
794
+ body: JSON.stringify(body),
795
+ }
796
+ );
797
+ if (!res.ok) {
798
+ const errText = await res.text().catch(() => '');
799
+ throw new Error(`Baidu API error ${res.status}: ${errText}`);
800
+ }
801
+ const data = await res.json();
802
+ if (data.error_code) throw new Error(`Baidu API error: ${data.error_msg || data.error_code}`);
803
+
804
+ // Async task — poll for result
805
+ const taskId = data.data?.task_id;
806
+ if (!taskId) throw new Error('Baidu API did not return a task_id');
807
+
808
+ let taskResult;
809
+ let attempts = 0;
810
+ while (attempts < 60) {
811
+ await new Promise(r => setTimeout(r, 5000));
812
+ const pollRes = await fetch(
813
+ `https://aip.baidubce.com/rpc/2.0/ernievilg/v1/getImgv2?access_token=${accessToken}`,
814
+ {
815
+ method: 'POST',
816
+ headers: { 'Content-Type': 'application/json' },
817
+ body: JSON.stringify({ task_id: taskId }),
818
+ }
819
+ );
820
+ taskResult = await pollRes.json();
821
+ const status = taskResult.data?.task_status;
822
+ if (status === 'SUCCESS') break;
823
+ if (status === 'FAILED') throw new Error(`Baidu task failed: ${taskResult.data?.error_msg || 'unknown'}`);
824
+ attempts++;
825
+ }
826
+
827
+ const results = [];
828
+ for (const item of (taskResult.data?.sub_task_result_list || [])) {
829
+ if (item.final_image_list?.[0]?.img_url) {
830
+ const filepath = await downloadImage(item.final_image_list[0].img_url, args.output_dir, args.output_format);
831
+ results.push({ file_path: filepath });
832
+ }
833
+ }
834
+ return results;
835
+ }
836
+
837
+ async function generateHunyuan(args) {
838
+ const apiKey = getEnv('HUNYUAN_API_KEY');
839
+ const model = args.model || 'hunyuan-image';
840
+
841
+ const body = {
842
+ model,
843
+ prompt: args.prompt,
844
+ n: args.n || 1,
845
+ size: args.size || '1024x1024',
846
+ response_format: 'b64_json',
847
+ };
848
+ if (args.negative_prompt) body.negative_prompt = args.negative_prompt;
849
+ if (args.style) body.style = args.style;
850
+
851
+ const res = await fetch('https://api.hunyuan.cloud.tencent.com/v1/images/generations', {
852
+ method: 'POST',
853
+ headers: {
854
+ 'Authorization': `Bearer ${apiKey}`,
855
+ 'Content-Type': 'application/json',
856
+ },
857
+ body: JSON.stringify(body),
858
+ });
859
+ if (!res.ok) {
860
+ const errText = await res.text().catch(() => '');
861
+ throw new Error(`Hunyuan API error ${res.status}: ${errText}`);
862
+ }
863
+ const data = await res.json();
864
+ const results = [];
865
+ for (const item of (data.data || [])) {
866
+ if (item.b64_json) {
867
+ const filepath = saveBase64Image(item.b64_json, args.output_dir, args.output_format);
868
+ results.push({ file_path: filepath });
869
+ } else if (item.url) {
870
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
871
+ results.push({ file_path: filepath });
872
+ }
873
+ }
874
+ return results;
875
+ }
876
+
877
+ async function generateVolcengine(args) {
878
+ const apiKey = getEnv('VOLCENGINE_API_KEY');
879
+ const model = args.model || 'general_v2.1_L';
880
+
881
+ const body = {
882
+ req_key: 'text_to_image',
883
+ model_version: model,
884
+ prompt: args.prompt,
885
+ return_url: true,
886
+ image_num: args.n || 1,
887
+ };
888
+ if (args.size) {
889
+ const [w, h] = args.size.split('x').map(Number);
890
+ if (w && h) { body.width = w; body.height = h; }
891
+ }
892
+ if (args.seed !== undefined) body.seed = args.seed;
893
+
894
+ const res = await fetch('https://visual.volcengineapi.com/v1/text_to_image', {
895
+ method: 'POST',
896
+ headers: {
897
+ 'Authorization': `Bearer ${apiKey}`,
898
+ 'Content-Type': 'application/json',
899
+ },
900
+ body: JSON.stringify(body),
901
+ });
902
+ if (!res.ok) {
903
+ const errText = await res.text().catch(() => '');
904
+ throw new Error(`Volcengine API error ${res.status}: ${errText}`);
905
+ }
906
+ const data = await res.json();
907
+ if (data.code !== 0 && data.code !== 10000) {
908
+ throw new Error(`Volcengine error: ${data.message || data.code}`);
909
+ }
910
+ const results = [];
911
+ for (const item of (data.data?.image_urls || [])) {
912
+ if (typeof item === 'string') {
913
+ const filepath = await downloadImage(item, args.output_dir, args.output_format);
914
+ results.push({ file_path: filepath });
915
+ }
916
+ }
917
+ // Fallback: binary_data_base64 array
918
+ if (results.length === 0) {
919
+ for (const b64 of (data.data?.binary_data_base64 || [])) {
920
+ if (b64) {
921
+ const filepath = saveBase64Image(b64, args.output_dir, args.output_format);
922
+ results.push({ file_path: filepath });
923
+ }
924
+ }
925
+ }
926
+ return results;
927
+ }
928
+
929
+ const GENERATE_FN = {
930
+ openai: generateOpenAI,
931
+ azure_openai: generateAzureOpenAI,
932
+ stability: generateStability,
933
+ google: generateGoogle,
934
+ replicate: generateReplicate,
935
+ tongyi: generateTongyi,
936
+ zhipu: generateZhipu,
937
+ siliconflow: generateSiliconFlow,
938
+ together: generateTogether,
939
+ fal: generateFal,
940
+ ideogram: generateIdeogram,
941
+ baidu: generateBaidu,
942
+ hunyuan: generateHunyuan,
943
+ volcengine: generateVolcengine,
944
+ };
945
+
946
+ // ─── Edit image (providers that support it) ──────────────────────────────────
947
+
948
+ async function editOpenAI(args) {
949
+ const apiKey = getEnv('OPENAI_API_KEY');
950
+ const model = args.model || 'dall-e-2';
951
+
952
+ const formData = new FormData();
953
+ formData.append('model', model);
954
+ formData.append('prompt', args.prompt);
955
+ const imgBuf = readFileSync(resolve(args.image_path));
956
+ formData.append('image', new Blob([imgBuf]), 'image.png');
957
+ if (args.mask_path) {
958
+ const maskBuf = readFileSync(resolve(args.mask_path));
959
+ formData.append('mask', new Blob([maskBuf]), 'mask.png');
960
+ }
961
+ if (args.size) formData.append('size', args.size);
962
+ formData.append('n', String(args.n || 1));
963
+ formData.append('response_format', 'b64_json');
964
+
965
+ const res = await fetch('https://api.openai.com/v1/images/edits', {
966
+ method: 'POST',
967
+ headers: { 'Authorization': `Bearer ${apiKey}` },
968
+ body: formData,
969
+ });
970
+ if (!res.ok) {
971
+ const errText = await res.text().catch(() => '');
972
+ throw new Error(`OpenAI edit API error ${res.status}: ${errText}`);
973
+ }
974
+ const data = await res.json();
975
+ const results = [];
976
+ for (const item of data.data) {
977
+ if (item.b64_json) {
978
+ const filepath = saveBase64Image(item.b64_json, args.output_dir, args.output_format);
979
+ results.push({ file_path: filepath });
980
+ }
981
+ }
982
+ return results;
983
+ }
984
+
985
+ async function editStability(args) {
986
+ const apiKey = getEnv('STABILITY_API_KEY');
987
+
988
+ const formData = new FormData();
989
+ formData.append('prompt', args.prompt);
990
+ const imgBuf = readFileSync(resolve(args.image_path));
991
+ formData.append('image', new Blob([imgBuf]), 'image.png');
992
+ if (args.negative_prompt) formData.append('negative_prompt', args.negative_prompt);
993
+ formData.append('output_format', args.output_format || 'png');
994
+ if (args.seed !== undefined) formData.append('seed', String(args.seed));
995
+
996
+ const endpoint = args.mask_path
997
+ ? 'https://api.stability.ai/v2beta/stable-image/edit/inpaint'
998
+ : 'https://api.stability.ai/v2beta/stable-image/edit/search-and-replace';
999
+
1000
+ if (args.mask_path) {
1001
+ const maskBuf = readFileSync(resolve(args.mask_path));
1002
+ formData.append('mask', new Blob([maskBuf]), 'mask.png');
1003
+ }
1004
+
1005
+ const res = await fetch(endpoint, {
1006
+ method: 'POST',
1007
+ headers: {
1008
+ 'Authorization': `Bearer ${apiKey}`,
1009
+ 'Accept': 'image/*',
1010
+ },
1011
+ body: formData,
1012
+ });
1013
+ if (!res.ok) {
1014
+ const errText = await res.text().catch(() => '');
1015
+ throw new Error(`Stability edit API error ${res.status}: ${errText}`);
1016
+ }
1017
+ const buf = Buffer.from(await res.arrayBuffer());
1018
+ ensureDir(args.output_dir);
1019
+ const ext = args.output_format || 'png';
1020
+ const filename = `image-${timestamp()}.${ext}`;
1021
+ const filepath = join(args.output_dir, filename);
1022
+ writeFileSync(filepath, buf);
1023
+ return [{ file_path: filepath }];
1024
+ }
1025
+
1026
+ async function editIdeogram(args) {
1027
+ const apiKey = getEnv('IDEOGRAM_API_KEY');
1028
+
1029
+ const formData = new FormData();
1030
+ const imgBuf = readFileSync(resolve(args.image_path));
1031
+ formData.append('image_file', new Blob([imgBuf]), 'image.png');
1032
+ if (args.mask_path) {
1033
+ const maskBuf = readFileSync(resolve(args.mask_path));
1034
+ formData.append('mask', new Blob([maskBuf]), 'mask.png');
1035
+ }
1036
+
1037
+ const imageRequest = {
1038
+ prompt: args.prompt,
1039
+ model: args.model || 'V_2',
1040
+ };
1041
+ if (args.negative_prompt) imageRequest.negative_prompt = args.negative_prompt;
1042
+ if (args.seed !== undefined) imageRequest.seed = args.seed;
1043
+ formData.append('image_request', JSON.stringify(imageRequest));
1044
+
1045
+ const res = await fetch('https://api.ideogram.ai/edit', {
1046
+ method: 'POST',
1047
+ headers: { 'Api-Key': apiKey },
1048
+ body: formData,
1049
+ });
1050
+ if (!res.ok) {
1051
+ const errText = await res.text().catch(() => '');
1052
+ throw new Error(`Ideogram edit API error ${res.status}: ${errText}`);
1053
+ }
1054
+ const data = await res.json();
1055
+ const results = [];
1056
+ for (const item of (data.data || [])) {
1057
+ if (item.url) {
1058
+ const filepath = await downloadImage(item.url, args.output_dir, args.output_format);
1059
+ results.push({ file_path: filepath });
1060
+ }
1061
+ }
1062
+ return results;
1063
+ }
1064
+
1065
+ const EDIT_FN = {
1066
+ openai: editOpenAI,
1067
+ stability: editStability,
1068
+ ideogram: editIdeogram,
1069
+ };
1070
+
1071
+ // ─── Tool handlers ───────────────────────────────────────────────────────────
1072
+
1073
+ async function handleGenerateImage(toolArgs) {
1074
+ const providerId = pickProvider(toolArgs.provider);
1075
+ const providerCfg = PROVIDERS[providerId];
1076
+ const model = toolArgs.model || providerCfg.defaultModel;
1077
+ const outputDir = toolArgs.output_dir || DEFAULT_OUTPUT_DIR;
1078
+
1079
+ const genArgs = { ...toolArgs, model, output_dir: outputDir };
1080
+ const fn = GENERATE_FN[providerId];
1081
+ if (!fn) throw new Error(`No generate implementation for provider: ${providerId}`);
1082
+
1083
+ const images = await fn(genArgs);
1084
+
1085
+ return JSON.stringify({
1086
+ status: 'success',
1087
+ provider: providerCfg.name,
1088
+ provider_id: providerId,
1089
+ model: genArgs.model, // Use genArgs.model to reflect auto-fallback model changes
1090
+ prompt: toolArgs.prompt,
1091
+ images,
1092
+ count: images.length,
1093
+ }, null, 2);
1094
+ }
1095
+
1096
+ async function handleListProviders() {
1097
+ const available = getAvailableProviders();
1098
+ const allProviders = Object.entries(PROVIDERS).map(([id, cfg]) => ({
1099
+ id,
1100
+ name: cfg.name,
1101
+ env_var: cfg.envKey,
1102
+ extra_env: cfg.extraEnv || [],
1103
+ configured: available.some(a => a.id === id),
1104
+ default_model: cfg.defaultModel,
1105
+ models: cfg.models,
1106
+ supported_sizes: cfg.supportedSizes,
1107
+ supports_edit: cfg.supportsEdit,
1108
+ supports_negative_prompt: cfg.supportsNegativePrompt,
1109
+ }));
1110
+ return JSON.stringify({
1111
+ configured_count: available.length,
1112
+ providers: allProviders,
1113
+ }, null, 2);
1114
+ }
1115
+
1116
+ async function handleEditImage(toolArgs) {
1117
+ const providerId = pickProvider(toolArgs.provider);
1118
+ const providerCfg = PROVIDERS[providerId];
1119
+
1120
+ if (!providerCfg.supportsEdit) {
1121
+ throw new Error(`Provider ${providerCfg.name} does not support image editing. Use OpenAI or Stability AI.`);
1122
+ }
1123
+
1124
+ const fn = EDIT_FN[providerId];
1125
+ if (!fn) throw new Error(`No edit implementation for provider: ${providerId}`);
1126
+
1127
+ const outputDir = toolArgs.output_dir || DEFAULT_OUTPUT_DIR;
1128
+ const editArgs = { ...toolArgs, output_dir: outputDir };
1129
+ const images = await fn(editArgs);
1130
+
1131
+ return JSON.stringify({
1132
+ status: 'success',
1133
+ provider: providerCfg.name,
1134
+ provider_id: providerId,
1135
+ model: toolArgs.model || providerCfg.defaultModel,
1136
+ images,
1137
+ count: images.length,
1138
+ }, null, 2);
1139
+ }
1140
+
1141
+ // ─── MCP tool definitions ────────────────────────────────────────────────────
1142
+
1143
+ const TOOLS = [
1144
+ {
1145
+ name: 'generate_image',
1146
+ description:
1147
+ 'Generate images from a text prompt using AI models. ' +
1148
+ 'Supports 14 providers: OpenAI DALL-E, Azure OpenAI, Stability AI, Google Imagen, Replicate, Tongyi Wanxiang, Zhipu CogView, ' +
1149
+ 'SiliconFlow, Together AI, FAL (Flux), Ideogram, Baidu ERNIE ViLG, Tencent Hunyuan, Volcengine Doubao. ' +
1150
+ 'Auto-selects provider based on available API keys, or specify one explicitly.',
1151
+ inputSchema: {
1152
+ type: 'object',
1153
+ properties: {
1154
+ prompt: { type: 'string', description: 'Text description of the image to generate' },
1155
+ negative_prompt: { type: 'string', description: 'What to avoid in the image (supported by Stability, Google, Replicate, Tongyi)' },
1156
+ provider: { type: 'string', enum: Object.keys(PROVIDERS), description: 'Force a specific provider (auto-detected if omitted)' },
1157
+ model: { type: 'string', description: 'Model name (uses provider default if omitted). e.g. dall-e-3, sd3-large, cogview-4' },
1158
+ size: { type: 'string', description: 'Image dimensions, e.g. 1024x1024, 1792x1024, 768x1344' },
1159
+ quality: { type: 'string', enum: ['standard', 'hd'], description: 'Image quality (OpenAI, Zhipu)' },
1160
+ style: { type: 'string', description: 'Style preset, e.g. natural, vivid (OpenAI), or style name (Tongyi)' },
1161
+ n: { type: 'number', description: 'Number of images to generate (default: 1)' },
1162
+ seed: { type: 'number', description: 'Seed for reproducibility (Stability, Google, Replicate, Tongyi)' },
1163
+ output_dir: { type: 'string', description: 'Directory to save images (default: ~/.markus/generated-images/)' },
1164
+ output_format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Output image format (default: png)' },
1165
+ },
1166
+ required: ['prompt'],
1167
+ },
1168
+ },
1169
+ {
1170
+ name: 'list_providers',
1171
+ description:
1172
+ 'List all supported image generation providers and their configuration status. ' +
1173
+ 'Shows which providers are configured (have API keys set), available models, supported sizes, and capabilities.',
1174
+ inputSchema: {
1175
+ type: 'object',
1176
+ properties: {},
1177
+ },
1178
+ },
1179
+ {
1180
+ name: 'edit_image',
1181
+ description:
1182
+ 'Edit or modify an existing image using AI. Supports inpainting (with mask) and image-to-image transformation. ' +
1183
+ 'Currently supported by OpenAI (DALL-E 2), Stability AI, and Ideogram.',
1184
+ inputSchema: {
1185
+ type: 'object',
1186
+ properties: {
1187
+ image_path: { type: 'string', description: 'Path to the source image file' },
1188
+ prompt: { type: 'string', description: 'Description of the desired edit or the target image' },
1189
+ mask_path: { type: 'string', description: 'Path to mask image for inpainting (transparent areas will be regenerated)' },
1190
+ negative_prompt: { type: 'string', description: 'What to avoid (Stability only)' },
1191
+ provider: { type: 'string', enum: ['openai', 'stability', 'ideogram'], description: 'Provider for editing (must support edit)' },
1192
+ model: { type: 'string', description: 'Model name (e.g. dall-e-2 for OpenAI edits)' },
1193
+ size: { type: 'string', description: 'Output image size' },
1194
+ n: { type: 'number', description: 'Number of variations (default: 1)' },
1195
+ seed: { type: 'number', description: 'Seed for reproducibility (Stability only)' },
1196
+ output_dir: { type: 'string', description: 'Directory to save result' },
1197
+ output_format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Output format (default: png)' },
1198
+ },
1199
+ required: ['image_path', 'prompt'],
1200
+ },
1201
+ },
1202
+ ];
1203
+
1204
+ const TOOL_MAP = {
1205
+ generate_image: handleGenerateImage,
1206
+ list_providers: handleListProviders,
1207
+ edit_image: handleEditImage,
1208
+ };
1209
+
1210
+ // ─── MCP JSON-RPC protocol ──────────────────────────────────────────────────
1211
+
1212
+ function respond(id, result) {
1213
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
1214
+ }
1215
+
1216
+ function respondError(id, code, message) {
1217
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
1218
+ }
1219
+
1220
+ const rl = createInterface({ input: process.stdin, terminal: false });
1221
+
1222
+ rl.on('line', async (line) => {
1223
+ const trimmed = line.trim();
1224
+ if (!trimmed) return;
1225
+
1226
+ let msg;
1227
+ try { msg = JSON.parse(trimmed); } catch { return; }
1228
+
1229
+ const { id, method, params } = msg;
1230
+
1231
+ if (!method) return;
1232
+ if (id === undefined || id === null) return;
1233
+
1234
+ switch (method) {
1235
+ case 'initialize':
1236
+ respond(id, {
1237
+ protocolVersion: '2024-11-05',
1238
+ capabilities: { tools: {} },
1239
+ serverInfo: { name: 'image-generation', version: '1.0.0' },
1240
+ });
1241
+ break;
1242
+
1243
+ case 'tools/list':
1244
+ respond(id, { tools: TOOLS });
1245
+ break;
1246
+
1247
+ case 'tools/call': {
1248
+ const toolName = params?.name;
1249
+ const toolArgs = params?.arguments || {};
1250
+ const handler = TOOL_MAP[toolName];
1251
+ if (!handler) {
1252
+ respondError(id, -32601, `Unknown tool: ${toolName}`);
1253
+ break;
1254
+ }
1255
+ try {
1256
+ const text = await handler(toolArgs);
1257
+ respond(id, { content: [{ type: 'text', text }] });
1258
+ } catch (err) {
1259
+ respond(id, { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
1260
+ }
1261
+ break;
1262
+ }
1263
+
1264
+ default:
1265
+ respondError(id, -32601, `Method not found: ${method}`);
1266
+ }
1267
+ });
1268
+
1269
+ process.stdin.resume();