@pheem49/mint 1.6.3 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/Cargo.lock +3 -3
  2. package/Cargo.toml +1 -1
  3. package/README.md +82 -21
  4. package/assets/Mint_CLI.png +0 -0
  5. package/assets/Mint_Desktop.png +0 -0
  6. package/assets/Mint_Web.png +0 -0
  7. package/assets/Mint_WorkSpace.png +0 -0
  8. package/assets/Setting.png +0 -0
  9. package/assets/bridges.svg +4 -0
  10. package/assets/chat.svg +3 -0
  11. package/assets/code.svg +4 -0
  12. package/assets/features.svg +3 -0
  13. package/assets/imagegen.svg +5 -0
  14. package/assets/live2d.svg +6 -0
  15. package/assets/memory.svg +5 -0
  16. package/assets/screencapture.svg +4 -0
  17. package/assets/setup.svg +3 -0
  18. package/assets/tools.svg +4 -0
  19. package/bin/mint +0 -0
  20. package/crates/mint-cli/src/agent.rs +6 -2
  21. package/crates/mint-cli/src/main.rs +145 -5
  22. package/crates/mint-cli/src/onboard.rs +134 -1
  23. package/crates/mint-core/src/api_server.rs +108 -3
  24. package/crates/mint-core/src/config.rs +20 -1
  25. package/crates/mint-core/src/image_gen.rs +895 -0
  26. package/crates/mint-core/src/lib.rs +4 -0
  27. package/package.json +1 -1
  28. package/src/bin/index.js +0 -0
  29. package/src/renderer/src/components/ChatPanel.tsx +252 -26
  30. package/src/renderer/src/components/DashboardSidebar.tsx +9 -1
  31. package/src/renderer/src/components/ImageStudioPanel.tsx +638 -0
  32. package/src/renderer/src/components/MintDashboard.tsx +13 -4
  33. package/src/renderer/src/components/SettingsWindow.tsx +29 -20
  34. package/src/renderer/src/css/image-studio.css +722 -0
  35. package/src/renderer/src/css/settings.css +13 -3
  36. package/src/renderer/src/css/styles.css +50 -26
  37. package/src/renderer/src/index.css +106 -0
  38. package/src/renderer/src/tauri.ts +123 -0
  39. package/src/renderer/src-web/components/ChatPanel.tsx +272 -24
  40. package/src/renderer/src-web/components/DashboardSidebar.tsx +9 -1
  41. package/src/renderer/src-web/components/ImageStudioPanel.tsx +638 -0
  42. package/src/renderer/src-web/components/MintDashboard.tsx +12 -3
  43. package/src/renderer/src-web/components/SettingsWindow.tsx +29 -20
  44. package/src/renderer/src-web/css/image-studio.css +722 -0
  45. package/src/renderer/src-web/css/settings.css +14 -4
  46. package/src/renderer/src-web/css/styles.css +50 -26
  47. package/src/renderer/src-web/index.css +106 -0
  48. package/src/renderer/src-web/tauri.ts +124 -0
  49. package/src-tauri/src/lib.rs +48 -5
  50. package/src-tauri/tauri.conf.json +1 -1
@@ -0,0 +1,895 @@
1
+ use base64::{Engine as _, engine::general_purpose::STANDARD};
2
+ use reqwest::Client;
3
+ use serde::{Deserialize, Serialize};
4
+ use serde_json::{Value, json};
5
+ use thiserror::Error;
6
+
7
+ use crate::MintConfig;
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ // Public request / response types
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+
13
+ /// A request to generate images from any supported provider.
14
+ #[derive(Debug, Clone, Deserialize)]
15
+ #[serde(rename_all = "camelCase")]
16
+ pub struct ImageGenRequest {
17
+ pub prompt: String,
18
+ #[serde(default)]
19
+ pub negative_prompt: Option<String>,
20
+ /// Aspect ratio hint: "1:1", "16:9", "9:16", "4:3". Default: "1:1"
21
+ #[serde(default)]
22
+ pub aspect_ratio: Option<String>,
23
+ /// Number of images to return (1–4). Default: 1
24
+ #[serde(default)]
25
+ pub num_images: Option<u8>,
26
+ /// Override the model for the selected provider.
27
+ #[serde(default)]
28
+ pub model: Option<String>,
29
+ /// Which provider to use: "nanobanana" | "dalle" | "stability" | "ideogram" | "replicate".
30
+ /// Falls back to `config.image_gen_provider` when omitted.
31
+ #[serde(default)]
32
+ pub provider: Option<String>,
33
+ }
34
+
35
+ /// One generated image returned from the model.
36
+ #[derive(Debug, Clone, Serialize)]
37
+ #[serde(rename_all = "camelCase")]
38
+ pub struct GeneratedImage {
39
+ /// Full data URI: `data:image/png;base64,...`
40
+ pub data_uri: String,
41
+ pub mime_type: String,
42
+ }
43
+
44
+ /// The complete response from an image generation call.
45
+ #[derive(Debug, Clone, Serialize)]
46
+ #[serde(rename_all = "camelCase")]
47
+ pub struct ImageGenResponse {
48
+ pub images: Vec<GeneratedImage>,
49
+ pub model: String,
50
+ pub provider: String,
51
+ pub prompt: String,
52
+ /// Optional descriptive text returned alongside the image by some models.
53
+ #[serde(skip_serializing_if = "Option::is_none")]
54
+ pub description: Option<String>,
55
+ }
56
+
57
+ #[derive(Debug, Error)]
58
+ pub enum ImageGenError {
59
+ // ── NanoBanana ──────────────────────────────────────────────────────────
60
+ #[error(
61
+ "missing Gemini API key — set GEMINI_API_KEY or configure api_key for NanoBanana image generation"
62
+ )]
63
+ MissingApiKey,
64
+ // ── DALL·E ───────────────────────────────────────────────────────────────
65
+ #[error("missing OpenAI API key — configure openai_api_key to use DALL·E")]
66
+ MissingOpenAiKey,
67
+ // ── Stability AI ─────────────────────────────────────────────────────────
68
+ #[error("missing Stability AI API key — configure stability_api_key")]
69
+ MissingStabilityKey,
70
+ // ── Ideogram ─────────────────────────────────────────────────────────────
71
+ #[error("missing Ideogram API key — configure ideogram_api_key")]
72
+ MissingIdeogramKey,
73
+ // ── Replicate ────────────────────────────────────────────────────────────
74
+ #[error("missing Replicate API key — configure replicate_api_key")]
75
+ MissingReplicateKey,
76
+ // ── Generic ──────────────────────────────────────────────────────────────
77
+ #[error("unsupported image generation provider: {0}")]
78
+ UnsupportedProvider(String),
79
+ #[error("API request failed: {0}")]
80
+ Request(#[from] reqwest::Error),
81
+ #[error("the model returned no images for this prompt")]
82
+ NoImagesReturned,
83
+ #[error("model error: {0}")]
84
+ ModelError(String),
85
+ #[error("unexpected response structure from the image model")]
86
+ UnexpectedResponse,
87
+ }
88
+
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ // Main entry-point — dispatch to the correct provider
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+
93
+ /// Generate images using whichever provider is specified in the request or config.
94
+ pub async fn generate_images(
95
+ config: &MintConfig,
96
+ request: &ImageGenRequest,
97
+ ) -> Result<ImageGenResponse, ImageGenError> {
98
+ let client = crate::HTTP_CLIENT.clone();
99
+
100
+ let provider = request
101
+ .provider
102
+ .as_deref()
103
+ .map(str::trim)
104
+ .filter(|s| !s.is_empty())
105
+ .unwrap_or_else(|| config.image_gen_provider.trim());
106
+
107
+ let provider = if provider.is_empty() {
108
+ "nanobanana"
109
+ } else {
110
+ provider
111
+ };
112
+
113
+ match provider {
114
+ "nanobanana" => call_nanobanana(&client, config, request).await,
115
+ "dalle" => call_dalle(&client, config, request).await,
116
+ "stability" => call_stability(&client, config, request).await,
117
+ "ideogram" => call_ideogram(&client, config, request).await,
118
+ "replicate" => call_replicate(&client, config, request).await,
119
+ other => Err(ImageGenError::UnsupportedProvider(other.to_owned())),
120
+ }
121
+ }
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // NanoBanana — Google Gemini image generation
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ async fn call_nanobanana(
128
+ client: &Client,
129
+ config: &MintConfig,
130
+ request: &ImageGenRequest,
131
+ ) -> Result<ImageGenResponse, ImageGenError> {
132
+ let api_key = if config.api_key.trim().is_empty() {
133
+ std::env::var("GEMINI_API_KEY").unwrap_or_default()
134
+ } else {
135
+ config.api_key.clone()
136
+ };
137
+ if api_key.trim().is_empty() {
138
+ return Err(ImageGenError::MissingApiKey);
139
+ }
140
+
141
+ let model = request
142
+ .model
143
+ .as_deref()
144
+ .map(str::trim)
145
+ .filter(|m| !m.is_empty())
146
+ .map(str::to_owned)
147
+ .unwrap_or_else(|| {
148
+ let m = config.nanobanana_model.trim();
149
+ if m.is_empty() {
150
+ "gemini-2.5-flash-image".to_owned()
151
+ } else {
152
+ m.to_owned()
153
+ }
154
+ });
155
+
156
+ let url =
157
+ format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent");
158
+
159
+ let full_prompt = build_prompt(request);
160
+ let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
161
+
162
+ let payload = json!({
163
+ "contents": [{
164
+ "parts": [{ "text": full_prompt }]
165
+ }],
166
+ "generationConfig": {
167
+ "responseModalities": ["TEXT", "IMAGE"],
168
+ "candidateCount": num_images
169
+ }
170
+ });
171
+
172
+ let response: Value = client
173
+ .post(&url)
174
+ .header("x-goog-api-key", &api_key)
175
+ .json(&payload)
176
+ .send()
177
+ .await?
178
+ .error_for_status()
179
+ .map_err(ImageGenError::Request)?
180
+ .json()
181
+ .await?;
182
+
183
+ if let Some(err) = response.get("error") {
184
+ let msg = err["message"].as_str().unwrap_or("unknown API error");
185
+ return Err(ImageGenError::ModelError(msg.to_owned()));
186
+ }
187
+
188
+ parse_nanobanana_response(&response, &model, &request.prompt)
189
+ }
190
+
191
+ fn parse_nanobanana_response(
192
+ response: &Value,
193
+ model: &str,
194
+ original_prompt: &str,
195
+ ) -> Result<ImageGenResponse, ImageGenError> {
196
+ let candidates = response["candidates"]
197
+ .as_array()
198
+ .ok_or(ImageGenError::UnexpectedResponse)?;
199
+
200
+ let mut images = Vec::new();
201
+ let mut description: Option<String> = None;
202
+
203
+ for candidate in candidates {
204
+ let parts_owned: Vec<Value>;
205
+ let parts = match candidate["content"]["parts"].as_array() {
206
+ Some(p) => p,
207
+ None => {
208
+ parts_owned = vec![];
209
+ &parts_owned
210
+ }
211
+ };
212
+
213
+ for part in parts {
214
+ if let Some(text) = part["text"].as_str() {
215
+ let text = text.trim();
216
+ if !text.is_empty() {
217
+ description.get_or_insert_with(String::new).push_str(text);
218
+ }
219
+ }
220
+
221
+ if let Some(inline) = part.get("inlineData") {
222
+ let mime_type = inline["mimeType"]
223
+ .as_str()
224
+ .unwrap_or("image/png")
225
+ .to_owned();
226
+ let b64 = inline["data"].as_str().unwrap_or("");
227
+ if b64.is_empty() {
228
+ continue;
229
+ }
230
+ if STANDARD.decode(b64).is_err() {
231
+ continue;
232
+ }
233
+ let data_uri = format!("data:{mime_type};base64,{b64}");
234
+ images.push(GeneratedImage {
235
+ data_uri,
236
+ mime_type,
237
+ });
238
+ }
239
+ }
240
+ }
241
+
242
+ if images.is_empty() {
243
+ return Err(ImageGenError::NoImagesReturned);
244
+ }
245
+
246
+ Ok(ImageGenResponse {
247
+ images,
248
+ model: model.to_owned(),
249
+ provider: "nanobanana".to_owned(),
250
+ prompt: original_prompt.to_owned(),
251
+ description,
252
+ })
253
+ }
254
+
255
+ // ─────────────────────────────────────────────────────────────────────────────
256
+ // DALL·E — OpenAI Images API
257
+ // ─────────────────────────────────────────────────────────────────────────────
258
+
259
+ async fn call_dalle(
260
+ client: &Client,
261
+ config: &MintConfig,
262
+ request: &ImageGenRequest,
263
+ ) -> Result<ImageGenResponse, ImageGenError> {
264
+ let api_key = if config.openai_api_key.trim().is_empty() {
265
+ std::env::var("OPENAI_API_KEY").unwrap_or_default()
266
+ } else {
267
+ config.openai_api_key.clone()
268
+ };
269
+ if api_key.trim().is_empty() {
270
+ return Err(ImageGenError::MissingOpenAiKey);
271
+ }
272
+
273
+ let model = request
274
+ .model
275
+ .as_deref()
276
+ .map(str::trim)
277
+ .filter(|m| !m.is_empty())
278
+ .map(str::to_owned)
279
+ .unwrap_or_else(|| {
280
+ let m = config.dalle_model.trim();
281
+ if m.is_empty() {
282
+ "dall-e-3".to_owned()
283
+ } else {
284
+ m.to_owned()
285
+ }
286
+ });
287
+
288
+ // DALL·E 3 supports only n=1; DALL·E 2 supports up to 10.
289
+ let n = if model.starts_with("dall-e-3") || model == "gpt-image-1" {
290
+ 1u8
291
+ } else {
292
+ request.num_images.unwrap_or(1).clamp(1, 10)
293
+ };
294
+
295
+ let size = aspect_ratio_to_dalle_size(request.aspect_ratio.as_deref());
296
+
297
+ let payload = json!({
298
+ "model": model,
299
+ "prompt": request.prompt,
300
+ "n": n,
301
+ "size": size,
302
+ "response_format": "b64_json"
303
+ });
304
+
305
+ let response: Value = client
306
+ .post("https://api.openai.com/v1/images/generations")
307
+ .bearer_auth(&api_key)
308
+ .json(&payload)
309
+ .send()
310
+ .await?
311
+ .error_for_status()
312
+ .map_err(ImageGenError::Request)?
313
+ .json()
314
+ .await?;
315
+
316
+ if let Some(err) = response.get("error") {
317
+ let msg = err["message"]
318
+ .as_str()
319
+ .unwrap_or("unknown OpenAI API error");
320
+ return Err(ImageGenError::ModelError(msg.to_owned()));
321
+ }
322
+
323
+ let data = response["data"]
324
+ .as_array()
325
+ .ok_or(ImageGenError::UnexpectedResponse)?;
326
+
327
+ let images: Vec<GeneratedImage> = data
328
+ .iter()
329
+ .filter_map(|item| {
330
+ let b64 = item["b64_json"].as_str()?;
331
+ if b64.is_empty() || STANDARD.decode(b64).is_err() {
332
+ return None;
333
+ }
334
+ let data_uri = format!("data:image/png;base64,{b64}");
335
+ Some(GeneratedImage {
336
+ data_uri,
337
+ mime_type: "image/png".to_owned(),
338
+ })
339
+ })
340
+ .collect();
341
+
342
+ if images.is_empty() {
343
+ return Err(ImageGenError::NoImagesReturned);
344
+ }
345
+
346
+ Ok(ImageGenResponse {
347
+ images,
348
+ model,
349
+ provider: "dalle".to_owned(),
350
+ prompt: request.prompt.clone(),
351
+ description: None,
352
+ })
353
+ }
354
+
355
+ fn aspect_ratio_to_dalle_size(ratio: Option<&str>) -> &'static str {
356
+ match ratio.unwrap_or("1:1") {
357
+ "16:9" => "1792x1024",
358
+ "9:16" => "1024x1792",
359
+ _ => "1024x1024",
360
+ }
361
+ }
362
+
363
+ // ─────────────────────────────────────────────────────────────────────────────
364
+ // Stability AI — Stable Image Core / SD3.x REST API
365
+ // ─────────────────────────────────────────────────────────────────────────────
366
+
367
+ async fn call_stability(
368
+ client: &Client,
369
+ config: &MintConfig,
370
+ request: &ImageGenRequest,
371
+ ) -> Result<ImageGenResponse, ImageGenError> {
372
+ let api_key = if config.stability_api_key.trim().is_empty() {
373
+ std::env::var("STABILITY_API_KEY").unwrap_or_default()
374
+ } else {
375
+ config.stability_api_key.clone()
376
+ };
377
+ if api_key.trim().is_empty() {
378
+ return Err(ImageGenError::MissingStabilityKey);
379
+ }
380
+
381
+ let model = request
382
+ .model
383
+ .as_deref()
384
+ .map(str::trim)
385
+ .filter(|m| !m.is_empty())
386
+ .map(str::to_owned)
387
+ .unwrap_or_else(|| {
388
+ let m = config.stability_model.trim();
389
+ if m.is_empty() {
390
+ "sd3.5-large".to_owned()
391
+ } else {
392
+ m.to_owned()
393
+ }
394
+ });
395
+
396
+ // Route: Stable Image Core uses /core; SD3.x models use /sd3
397
+ let (endpoint, model_param) = if model == "core" {
398
+ (
399
+ "https://api.stability.ai/v2beta/stable-image/generate/core".to_owned(),
400
+ None,
401
+ )
402
+ } else {
403
+ (
404
+ "https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned(),
405
+ Some(model.clone()),
406
+ )
407
+ };
408
+
409
+ let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
410
+
411
+ // Build multipart/form-data manually (reqwest multipart feature not enabled).
412
+ let boundary = format!("MintBoundary{}", uuid_v4_hex());
413
+
414
+ let mut body_parts: Vec<(String, String)> = vec![
415
+ ("prompt".into(), request.prompt.clone()),
416
+ ("aspect_ratio".into(), aspect_ratio.to_owned()),
417
+ ("output_format".into(), "png".to_owned()),
418
+ ];
419
+
420
+ if let Some(neg) = &request.negative_prompt {
421
+ let neg = neg.trim();
422
+ if !neg.is_empty() {
423
+ body_parts.push(("negative_prompt".into(), neg.to_owned()));
424
+ }
425
+ }
426
+
427
+ if let Some(m) = model_param {
428
+ body_parts.push(("model".into(), m));
429
+ }
430
+
431
+ let raw_body = build_multipart_text(&boundary, &body_parts);
432
+
433
+ let response = client
434
+ .post(&endpoint)
435
+ .bearer_auth(&api_key)
436
+ .header("accept", "image/*")
437
+ .header(
438
+ "content-type",
439
+ format!("multipart/form-data; boundary={boundary}"),
440
+ )
441
+ .body(raw_body)
442
+ .send()
443
+ .await?
444
+ .error_for_status()
445
+ .map_err(ImageGenError::Request)?;
446
+
447
+ let bytes = response.bytes().await?;
448
+ if bytes.is_empty() {
449
+ return Err(ImageGenError::NoImagesReturned);
450
+ }
451
+
452
+ let b64 = STANDARD.encode(&bytes);
453
+ let data_uri = format!("data:image/png;base64,{b64}");
454
+
455
+ Ok(ImageGenResponse {
456
+ images: vec![GeneratedImage {
457
+ data_uri,
458
+ mime_type: "image/png".to_owned(),
459
+ }],
460
+ model,
461
+ provider: "stability".to_owned(),
462
+ prompt: request.prompt.clone(),
463
+ description: None,
464
+ })
465
+ }
466
+
467
+ // ─────────────────────────────────────────────────────────────────────────────
468
+ // Ideogram — Ideogram v3 REST API
469
+ // ─────────────────────────────────────────────────────────────────────────────
470
+
471
+ async fn call_ideogram(
472
+ client: &Client,
473
+ config: &MintConfig,
474
+ request: &ImageGenRequest,
475
+ ) -> Result<ImageGenResponse, ImageGenError> {
476
+ let api_key = if config.ideogram_api_key.trim().is_empty() {
477
+ std::env::var("IDEOGRAM_API_KEY").unwrap_or_default()
478
+ } else {
479
+ config.ideogram_api_key.clone()
480
+ };
481
+ if api_key.trim().is_empty() {
482
+ return Err(ImageGenError::MissingIdeogramKey);
483
+ }
484
+
485
+ let model = request
486
+ .model
487
+ .as_deref()
488
+ .map(str::trim)
489
+ .filter(|m| !m.is_empty())
490
+ .map(str::to_owned)
491
+ .unwrap_or_else(|| {
492
+ let m = config.ideogram_model.trim();
493
+ if m.is_empty() {
494
+ "V_3".to_owned()
495
+ } else {
496
+ m.to_owned()
497
+ }
498
+ });
499
+
500
+ let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
501
+ let aspect_ratio = aspect_ratio_to_ideogram(request.aspect_ratio.as_deref());
502
+
503
+ let mut body = json!({
504
+ "image_request": {
505
+ "prompt": request.prompt,
506
+ "model": model,
507
+ "num_images": num_images,
508
+ "aspect_ratio": aspect_ratio,
509
+ "magic_prompt_option": "AUTO"
510
+ }
511
+ });
512
+
513
+ if let Some(neg) = &request.negative_prompt {
514
+ let neg = neg.trim();
515
+ if !neg.is_empty() {
516
+ body["image_request"]["negative_prompt"] = json!(neg);
517
+ }
518
+ }
519
+
520
+ let response: Value = client
521
+ .post("https://api.ideogram.ai/generate")
522
+ .header("Api-Key", &api_key)
523
+ .json(&body)
524
+ .send()
525
+ .await?
526
+ .error_for_status()
527
+ .map_err(ImageGenError::Request)?
528
+ .json()
529
+ .await?;
530
+
531
+ if let Some(err) = response.get("error") {
532
+ let msg = err.as_str().unwrap_or("unknown Ideogram API error");
533
+ return Err(ImageGenError::ModelError(msg.to_owned()));
534
+ }
535
+
536
+ // Response shape: { data: [ { url: "..." }, ... ] }
537
+ let data = response["data"]
538
+ .as_array()
539
+ .ok_or(ImageGenError::UnexpectedResponse)?;
540
+
541
+ let mut images = Vec::new();
542
+ for item in data {
543
+ if let Some(url) = item["url"].as_str() {
544
+ if url.is_empty() {
545
+ continue;
546
+ }
547
+ // Fetch the image bytes and convert to base64 data URI
548
+ match fetch_url_as_data_uri(client, url).await {
549
+ Ok(data_uri) => images.push(GeneratedImage {
550
+ data_uri,
551
+ mime_type: "image/jpeg".to_owned(),
552
+ }),
553
+ Err(_) => continue,
554
+ }
555
+ }
556
+ }
557
+
558
+ if images.is_empty() {
559
+ return Err(ImageGenError::NoImagesReturned);
560
+ }
561
+
562
+ Ok(ImageGenResponse {
563
+ images,
564
+ model,
565
+ provider: "ideogram".to_owned(),
566
+ prompt: request.prompt.clone(),
567
+ description: None,
568
+ })
569
+ }
570
+
571
+ fn aspect_ratio_to_ideogram(ratio: Option<&str>) -> &'static str {
572
+ match ratio.unwrap_or("1:1") {
573
+ "16:9" => "ASPECT_16_9",
574
+ "9:16" => "ASPECT_9_16",
575
+ "4:3" => "ASPECT_4_3",
576
+ "3:4" => "ASPECT_3_4",
577
+ _ => "ASPECT_1_1",
578
+ }
579
+ }
580
+
581
+ // ─────────────────────────────────────────────────────────────────────────────
582
+ // Replicate — Predictions API (supports FLUX, SDXL, etc.)
583
+ // ─────────────────────────────────────────────────────────────────────────────
584
+
585
+ async fn call_replicate(
586
+ client: &Client,
587
+ config: &MintConfig,
588
+ request: &ImageGenRequest,
589
+ ) -> Result<ImageGenResponse, ImageGenError> {
590
+ let api_key = if config.replicate_api_key.trim().is_empty() {
591
+ std::env::var("REPLICATE_API_TOKEN").unwrap_or_default()
592
+ } else {
593
+ config.replicate_api_key.clone()
594
+ };
595
+ if api_key.trim().is_empty() {
596
+ return Err(ImageGenError::MissingReplicateKey);
597
+ }
598
+
599
+ let model = request
600
+ .model
601
+ .as_deref()
602
+ .map(str::trim)
603
+ .filter(|m| !m.is_empty())
604
+ .map(str::to_owned)
605
+ .unwrap_or_else(|| {
606
+ let m = config.replicate_model.trim();
607
+ if m.is_empty() {
608
+ "black-forest-labs/flux-1.1-pro".to_owned()
609
+ } else {
610
+ m.to_owned()
611
+ }
612
+ });
613
+
614
+ let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
615
+ let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
616
+
617
+ // Build input — common fields used by FLUX and SDXL families
618
+ let mut input = json!({
619
+ "prompt": request.prompt,
620
+ "num_outputs": num_images,
621
+ "aspect_ratio": aspect_ratio,
622
+ "output_format": "png",
623
+ "output_quality": 90
624
+ });
625
+
626
+ if let Some(neg) = &request.negative_prompt {
627
+ let neg = neg.trim();
628
+ if !neg.is_empty() {
629
+ input["negative_prompt"] = json!(neg);
630
+ }
631
+ }
632
+
633
+ // POST to /v1/models/{model}/predictions
634
+ let create_url = format!("https://api.replicate.com/v1/models/{model}/predictions");
635
+ let create_payload = json!({ "input": input });
636
+
637
+ let create_response: Value = client
638
+ .post(&create_url)
639
+ .bearer_auth(&api_key)
640
+ .header("Prefer", "wait=60") // ask Replicate to wait up to 60s synchronously
641
+ .json(&create_payload)
642
+ .send()
643
+ .await?
644
+ .error_for_status()
645
+ .map_err(ImageGenError::Request)?
646
+ .json()
647
+ .await?;
648
+
649
+ // If already succeeded (Prefer: wait worked), parse immediately
650
+ let prediction = if create_response["status"].as_str() == Some("succeeded") {
651
+ create_response
652
+ } else {
653
+ // Fall back to polling
654
+ let prediction_url = create_response["urls"]["get"]
655
+ .as_str()
656
+ .ok_or(ImageGenError::UnexpectedResponse)?
657
+ .to_owned();
658
+
659
+ poll_replicate(client, &api_key, &prediction_url).await?
660
+ };
661
+
662
+ if let Some(err) = prediction.get("error") {
663
+ if !err.is_null() {
664
+ let msg = err.as_str().unwrap_or("unknown Replicate error");
665
+ return Err(ImageGenError::ModelError(msg.to_owned()));
666
+ }
667
+ }
668
+
669
+ // Output is an array of image URLs
670
+ let output = prediction["output"]
671
+ .as_array()
672
+ .ok_or(ImageGenError::UnexpectedResponse)?;
673
+
674
+ let mut images = Vec::new();
675
+ for url_val in output {
676
+ if let Some(url) = url_val.as_str() {
677
+ match fetch_url_as_data_uri(client, url).await {
678
+ Ok(data_uri) => images.push(GeneratedImage {
679
+ data_uri,
680
+ mime_type: "image/png".to_owned(),
681
+ }),
682
+ Err(_) => continue,
683
+ }
684
+ }
685
+ }
686
+
687
+ if images.is_empty() {
688
+ return Err(ImageGenError::NoImagesReturned);
689
+ }
690
+
691
+ Ok(ImageGenResponse {
692
+ images,
693
+ model,
694
+ provider: "replicate".to_owned(),
695
+ prompt: request.prompt.clone(),
696
+ description: None,
697
+ })
698
+ }
699
+
700
+ /// Poll a Replicate prediction URL until it reaches a terminal state.
701
+ async fn poll_replicate(client: &Client, api_key: &str, url: &str) -> Result<Value, ImageGenError> {
702
+ for _ in 0..60 {
703
+ tokio::time::sleep(std::time::Duration::from_secs(2)).await;
704
+
705
+ let response: Value = client
706
+ .get(url)
707
+ .bearer_auth(api_key)
708
+ .send()
709
+ .await?
710
+ .error_for_status()
711
+ .map_err(ImageGenError::Request)?
712
+ .json()
713
+ .await?;
714
+
715
+ match response["status"].as_str() {
716
+ Some("succeeded") | Some("failed") | Some("canceled") => return Ok(response),
717
+ _ => continue,
718
+ }
719
+ }
720
+
721
+ Err(ImageGenError::ModelError(
722
+ "Replicate prediction timed out after 120 seconds".to_owned(),
723
+ ))
724
+ }
725
+
726
+ // ─────────────────────────────────────────────────────────────────────────────
727
+ // Shared helpers
728
+ // ─────────────────────────────────────────────────────────────────────────────
729
+
730
+ /// Download an image URL and return it as a `data:<mime>;base64,<data>` URI.
731
+ async fn fetch_url_as_data_uri(client: &Client, url: &str) -> Result<String, ImageGenError> {
732
+ let resp = client
733
+ .get(url)
734
+ .send()
735
+ .await?
736
+ .error_for_status()
737
+ .map_err(ImageGenError::Request)?;
738
+
739
+ let mime_type = resp
740
+ .headers()
741
+ .get("content-type")
742
+ .and_then(|v| v.to_str().ok())
743
+ .map(|ct| {
744
+ ct.split(';')
745
+ .next()
746
+ .unwrap_or("image/jpeg")
747
+ .trim()
748
+ .to_owned()
749
+ })
750
+ .unwrap_or_else(|| "image/jpeg".to_owned());
751
+
752
+ let bytes = resp.bytes().await?;
753
+ if bytes.is_empty() {
754
+ return Err(ImageGenError::NoImagesReturned);
755
+ }
756
+
757
+ let b64 = STANDARD.encode(&bytes);
758
+ Ok(format!("data:{mime_type};base64,{b64}"))
759
+ }
760
+
761
+ /// Generate a short random hex string suitable for a multipart boundary.
762
+ /// Uses `/dev/urandom` on Linux/macOS via the `sha2` crate's internal
763
+ /// randomness; falls back to a time-based seed. No extra crate required.
764
+ fn uuid_v4_hex() -> String {
765
+ use std::time::{SystemTime, UNIX_EPOCH};
766
+ // Mix nanos + a simple linear-congruential step for fast unique strings
767
+ let seed = SystemTime::now()
768
+ .duration_since(UNIX_EPOCH)
769
+ .map(|d| d.subsec_nanos())
770
+ .unwrap_or(12345);
771
+ format!(
772
+ "{:016x}{:016x}",
773
+ seed as u64 ^ 0xDEAD_BEEF_CAFE_BABE,
774
+ seed as u64
775
+ )
776
+ }
777
+
778
+ /// Encode `fields` as a `multipart/form-data` text-only body for the given boundary.
779
+ fn build_multipart_text(boundary: &str, fields: &[(String, String)]) -> String {
780
+ let mut body = String::new();
781
+ for (name, value) in fields {
782
+ body.push_str(&format!("--{boundary}\r\n"));
783
+ body.push_str(&format!(
784
+ "Content-Disposition: form-data; name=\"{name}\"\r\n\r\n"
785
+ ));
786
+ body.push_str(value);
787
+ body.push_str("\r\n");
788
+ }
789
+ body.push_str(&format!("--{boundary}--\r\n"));
790
+ body
791
+ }
792
+
793
+ /// Build the final prompt string, optionally embedding aspect ratio and negative
794
+ /// prompt guidance. Used by providers that accept a plain text prompt.
795
+ fn build_prompt(request: &ImageGenRequest) -> String {
796
+ let mut prompt = request.prompt.trim().to_owned();
797
+
798
+ if let Some(ref neg) = request.negative_prompt {
799
+ let neg = neg.trim();
800
+ if !neg.is_empty() {
801
+ prompt.push_str(&format!(
802
+ "\n\nNegative prompt (avoid these elements): {neg}"
803
+ ));
804
+ }
805
+ }
806
+
807
+ if let Some(ref ratio) = request.aspect_ratio {
808
+ let ratio = ratio.trim();
809
+ if !ratio.is_empty() && ratio != "1:1" {
810
+ prompt.push_str(&format!(
811
+ "\n\nGenerate the image with a {ratio} aspect ratio."
812
+ ));
813
+ }
814
+ }
815
+
816
+ prompt
817
+ }
818
+
819
+ // ─────────────────────────────────────────────────────────────────────────────
820
+ // Tests
821
+ // ─────────────────────────────────────────────────────────────────────────────
822
+
823
+ #[cfg(test)]
824
+ mod tests {
825
+ use super::*;
826
+
827
+ fn make_request(prompt: &str) -> ImageGenRequest {
828
+ ImageGenRequest {
829
+ prompt: prompt.to_owned(),
830
+ negative_prompt: None,
831
+ aspect_ratio: None,
832
+ num_images: None,
833
+ model: None,
834
+ provider: None,
835
+ }
836
+ }
837
+
838
+ #[test]
839
+ fn build_prompt_plain() {
840
+ let req = make_request("a red apple");
841
+ assert_eq!(build_prompt(&req), "a red apple");
842
+ }
843
+
844
+ #[test]
845
+ fn build_prompt_with_negative_and_ratio() {
846
+ let req = ImageGenRequest {
847
+ prompt: "a cat".to_owned(),
848
+ negative_prompt: Some("blurry, low quality".to_owned()),
849
+ aspect_ratio: Some("16:9".to_owned()),
850
+ num_images: Some(2),
851
+ model: None,
852
+ provider: None,
853
+ };
854
+ let result = build_prompt(&req);
855
+ assert!(result.contains("a cat"));
856
+ assert!(result.contains("Negative prompt"));
857
+ assert!(result.contains("16:9"));
858
+ }
859
+
860
+ #[test]
861
+ fn build_prompt_default_ratio_not_added() {
862
+ let req = ImageGenRequest {
863
+ prompt: "a dog".to_owned(),
864
+ negative_prompt: None,
865
+ aspect_ratio: Some("1:1".to_owned()),
866
+ num_images: None,
867
+ model: None,
868
+ provider: None,
869
+ };
870
+ assert!(!build_prompt(&req).contains("aspect ratio"));
871
+ }
872
+
873
+ #[test]
874
+ fn num_images_clamped() {
875
+ assert_eq!(5u8.clamp(1, 4), 4);
876
+ assert_eq!(0u8.clamp(1, 4), 1);
877
+ }
878
+
879
+ #[test]
880
+ fn dalle_size_mapping() {
881
+ assert_eq!(aspect_ratio_to_dalle_size(Some("16:9")), "1792x1024");
882
+ assert_eq!(aspect_ratio_to_dalle_size(Some("9:16")), "1024x1792");
883
+ assert_eq!(aspect_ratio_to_dalle_size(Some("1:1")), "1024x1024");
884
+ assert_eq!(aspect_ratio_to_dalle_size(None), "1024x1024");
885
+ }
886
+
887
+ #[test]
888
+ fn ideogram_ratio_mapping() {
889
+ assert_eq!(aspect_ratio_to_ideogram(Some("16:9")), "ASPECT_16_9");
890
+ assert_eq!(aspect_ratio_to_ideogram(Some("9:16")), "ASPECT_9_16");
891
+ assert_eq!(aspect_ratio_to_ideogram(Some("4:3")), "ASPECT_4_3");
892
+ assert_eq!(aspect_ratio_to_ideogram(Some("1:1")), "ASPECT_1_1");
893
+ assert_eq!(aspect_ratio_to_ideogram(None), "ASPECT_1_1");
894
+ }
895
+ }