@pheem49/mint 1.9.0 → 1.10.0
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/Cargo.lock +55 -3
- package/Cargo.toml +2 -1
- package/Release_Note.md +285 -140
- package/bin/mint +0 -0
- package/crates/mint-cli/Cargo.toml +1 -0
- package/crates/mint-cli/src/actions.rs +233 -0
- package/crates/mint-cli/src/agent.rs +287 -43
- package/crates/mint-cli/src/gmail.rs +22 -2
- package/crates/mint-cli/src/interactive.rs +2888 -0
- package/crates/mint-cli/src/main.rs +336 -2854
- package/crates/mint-cli/src/markdown.rs +294 -0
- package/crates/mint-cli/src/onboard.rs +44 -1
- package/crates/mint-cli/src/updater.rs +48 -6
- package/crates/mint-core/Cargo.toml +1 -0
- package/crates/mint-core/src/agent_loop.rs +1 -0
- package/crates/mint-core/src/api_server.rs +376 -13
- package/crates/mint-core/src/browser.rs +629 -101
- package/crates/mint-core/src/channels.rs +105 -6
- package/crates/mint-core/src/chat.rs +52 -4
- package/crates/mint-core/src/code_tools.rs +29 -11
- package/crates/mint-core/src/config.rs +118 -0
- package/crates/mint-core/src/image_gen.rs +294 -33
- package/crates/mint-core/src/lib.rs +5 -2
- package/crates/mint-core/src/mcp.rs +119 -6
- package/crates/mint-core/src/orchestration.rs +133 -11
- package/crates/mint-core/src/pictures.rs +64 -1
- package/crates/mint-core/src/skills.rs +82 -6
- package/crates/mint-core/src/video_gen.rs +235 -0
- package/package.json +8 -2
- package/shared-modules/luajit/luajit.json +2 -2
- package/skills-lock.json +41 -0
- package/src/renderer/index-web.html +1 -1
- package/src/renderer/index.html +1 -1
- package/src/renderer/shared/constants/slashCommands.ts +27 -0
- package/src/renderer/shared/platform.ts +3 -0
- package/src/renderer/shared/types.ts +4 -0
- package/src/renderer/shared/utils/approval.tsx +86 -7
- package/src/renderer/shared/utils/markdown.tsx +217 -88
- package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
- package/src/renderer/src/components/ChatPanel.tsx +461 -64
- package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
- package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
- package/src/renderer/src/components/MintDashboard.tsx +150 -5
- package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
- package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
- package/src/renderer/src/components/Settings/GeneralTab.tsx +762 -634
- package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
- package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
- package/src/renderer/src/components/SettingsWindow.tsx +38 -2
- package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
- package/src/renderer/src/css/image-studio.css +106 -0
- package/src/renderer/src/css/settings.css +158 -0
- package/src/renderer/src/css/styles.css +352 -1
- package/src/renderer/src/css/veo-studio.css +626 -0
- package/src/renderer/src/index.css +1 -0
- package/src/renderer/src/tauri.ts +144 -17
- package/src/renderer/src-web/components/ChatPanel.tsx +360 -88
- package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
- package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
- package/src/renderer/src-web/components/MintDashboard.tsx +137 -4
- package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
- package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
- package/src/renderer/src-web/components/Settings/GeneralTab.tsx +762 -629
- package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
- package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
- package/src/renderer/src-web/components/SettingsWindow.tsx +35 -2
- package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
- package/src/renderer/src-web/css/image-studio.css +106 -0
- package/src/renderer/src-web/css/settings.css +158 -0
- package/src/renderer/src-web/css/styles.css +353 -1
- package/src/renderer/src-web/css/veo-studio.css +650 -0
- package/src/renderer/src-web/index.css +1 -0
- package/src/renderer/src-web/tauri.ts +135 -16
- package/src-tauri/src/lib.rs +114 -8
- package/src-tauri/src/webhooks.rs +45 -31
- package/src-tauri/src/workflows.rs +1 -0
- package/src-tauri/tauri.conf.json +1 -1
- package/temp_gridgeist/AGENTS.md +8 -0
- package/temp_gridgeist/CHANGELOG.md +130 -0
- package/temp_gridgeist/LICENSE +21 -0
- package/temp_gridgeist/README.md +323 -0
- package/temp_gridgeist/README.th.md +325 -0
- package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
- package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
- package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
- package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
- package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
- package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
- package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
- package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
|
@@ -10,8 +10,8 @@ use crate::MintConfig;
|
|
|
10
10
|
// Public request / response types
|
|
11
11
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
|
-
/// A request to generate images from any supported provider.
|
|
14
|
-
#[derive(Debug, Clone, Deserialize)]
|
|
13
|
+
/// A request to generate or edit images from any supported provider.
|
|
14
|
+
#[derive(Debug, Clone, Deserialize, Default)]
|
|
15
15
|
#[serde(rename_all = "camelCase")]
|
|
16
16
|
pub struct ImageGenRequest {
|
|
17
17
|
pub prompt: String,
|
|
@@ -30,6 +30,15 @@ pub struct ImageGenRequest {
|
|
|
30
30
|
/// Falls back to `config.image_gen_provider` when omitted.
|
|
31
31
|
#[serde(default)]
|
|
32
32
|
pub provider: Option<String>,
|
|
33
|
+
/// Optional input image data URI (`data:image/png;base64,...`) for Image-to-Image / Inpainting
|
|
34
|
+
#[serde(default)]
|
|
35
|
+
pub image_data_uri: Option<String>,
|
|
36
|
+
/// Optional mask image data URI for Inpainting
|
|
37
|
+
#[serde(default)]
|
|
38
|
+
pub mask_data_uri: Option<String>,
|
|
39
|
+
/// Optional mode: "generate" | "edit" | "inpaint". Inferred if omitted.
|
|
40
|
+
#[serde(default)]
|
|
41
|
+
pub mode: Option<String>,
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/// One generated image returned from the model.
|
|
@@ -76,6 +85,10 @@ pub enum ImageGenError {
|
|
|
76
85
|
// ── Generic ──────────────────────────────────────────────────────────────
|
|
77
86
|
#[error("unsupported image generation provider: {0}")]
|
|
78
87
|
UnsupportedProvider(String),
|
|
88
|
+
#[error(
|
|
89
|
+
"image-to-image editing is not supported by {0} — please use Replicate, Stability AI, or DALL·E"
|
|
90
|
+
)]
|
|
91
|
+
UnsupportedMode(String),
|
|
79
92
|
#[error("API request failed: {0}")]
|
|
80
93
|
Request(#[from] reqwest::Error),
|
|
81
94
|
#[error("the model returned no images for this prompt")]
|
|
@@ -129,6 +142,15 @@ async fn call_nanobanana(
|
|
|
129
142
|
config: &MintConfig,
|
|
130
143
|
request: &ImageGenRequest,
|
|
131
144
|
) -> Result<ImageGenResponse, ImageGenError> {
|
|
145
|
+
if request.image_data_uri.is_some()
|
|
146
|
+
|| request.mode.as_deref() == Some("edit")
|
|
147
|
+
|| request.mode.as_deref() == Some("inpaint")
|
|
148
|
+
{
|
|
149
|
+
return Err(ImageGenError::UnsupportedMode(
|
|
150
|
+
"NanoBanana (Gemini Text-to-Image)".to_owned(),
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
let api_key = if config.api_key.trim().is_empty() {
|
|
133
155
|
std::env::var("GEMINI_API_KEY").unwrap_or_default()
|
|
134
156
|
} else {
|
|
@@ -270,6 +292,105 @@ async fn call_dalle(
|
|
|
270
292
|
return Err(ImageGenError::MissingOpenAiKey);
|
|
271
293
|
}
|
|
272
294
|
|
|
295
|
+
if let Some(ref img_data_uri) = request.image_data_uri {
|
|
296
|
+
let (mime_type, img_bytes) = parse_data_uri(img_data_uri)
|
|
297
|
+
.ok_or_else(|| ImageGenError::ModelError("invalid base64 image data URI".to_owned()))?;
|
|
298
|
+
|
|
299
|
+
let size = aspect_ratio_to_dalle_size(request.aspect_ratio.as_deref());
|
|
300
|
+
let boundary = format!("MintBoundary{}", uuid_v4_hex());
|
|
301
|
+
|
|
302
|
+
let mut text_fields = vec![
|
|
303
|
+
("prompt".to_owned(), request.prompt.clone()),
|
|
304
|
+
("n".to_owned(), "1".to_owned()),
|
|
305
|
+
("size".to_owned(), size.to_owned()),
|
|
306
|
+
("response_format".to_owned(), "b64_json".to_owned()),
|
|
307
|
+
("model".to_owned(), "dall-e-2".to_owned()),
|
|
308
|
+
];
|
|
309
|
+
if let Some(ref neg) = request.negative_prompt {
|
|
310
|
+
if !neg.trim().is_empty() {
|
|
311
|
+
text_fields.push((
|
|
312
|
+
"prompt".to_owned(),
|
|
313
|
+
format!("{} (avoid: {})", request.prompt, neg),
|
|
314
|
+
));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let mut file_fields = vec![(
|
|
319
|
+
"image",
|
|
320
|
+
"image.png",
|
|
321
|
+
mime_type.as_str(),
|
|
322
|
+
img_bytes.as_slice(),
|
|
323
|
+
)];
|
|
324
|
+
|
|
325
|
+
let mask_bytes_storage;
|
|
326
|
+
if let Some(ref mask_data_uri) = request.mask_data_uri {
|
|
327
|
+
if let Some((mask_mime, mask_bytes)) = parse_data_uri(mask_data_uri) {
|
|
328
|
+
mask_bytes_storage = (mask_mime, mask_bytes);
|
|
329
|
+
file_fields.push((
|
|
330
|
+
"mask",
|
|
331
|
+
"mask.png",
|
|
332
|
+
mask_bytes_storage.0.as_str(),
|
|
333
|
+
mask_bytes_storage.1.as_slice(),
|
|
334
|
+
));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let body = build_multipart_binary(&boundary, &text_fields, &file_fields);
|
|
339
|
+
|
|
340
|
+
let response: Value = client
|
|
341
|
+
.post("https://api.openai.com/v1/images/edits")
|
|
342
|
+
.bearer_auth(&api_key)
|
|
343
|
+
.header(
|
|
344
|
+
"content-type",
|
|
345
|
+
format!("multipart/form-data; boundary={boundary}"),
|
|
346
|
+
)
|
|
347
|
+
.body(body)
|
|
348
|
+
.send()
|
|
349
|
+
.await?
|
|
350
|
+
.error_for_status()
|
|
351
|
+
.map_err(ImageGenError::Request)?
|
|
352
|
+
.json()
|
|
353
|
+
.await?;
|
|
354
|
+
|
|
355
|
+
if let Some(err) = response.get("error") {
|
|
356
|
+
let msg = err["message"]
|
|
357
|
+
.as_str()
|
|
358
|
+
.unwrap_or("unknown OpenAI API error");
|
|
359
|
+
return Err(ImageGenError::ModelError(msg.to_owned()));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let data = response["data"]
|
|
363
|
+
.as_array()
|
|
364
|
+
.ok_or(ImageGenError::UnexpectedResponse)?;
|
|
365
|
+
|
|
366
|
+
let images: Vec<GeneratedImage> = data
|
|
367
|
+
.iter()
|
|
368
|
+
.filter_map(|item| {
|
|
369
|
+
let b64 = item["b64_json"].as_str()?;
|
|
370
|
+
if b64.is_empty() || STANDARD.decode(b64).is_err() {
|
|
371
|
+
return None;
|
|
372
|
+
}
|
|
373
|
+
let data_uri = format!("data:image/png;base64,{b64}");
|
|
374
|
+
Some(GeneratedImage {
|
|
375
|
+
data_uri,
|
|
376
|
+
mime_type: "image/png".to_owned(),
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
.collect();
|
|
380
|
+
|
|
381
|
+
if images.is_empty() {
|
|
382
|
+
return Err(ImageGenError::NoImagesReturned);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return Ok(ImageGenResponse {
|
|
386
|
+
images,
|
|
387
|
+
model: "dall-e-2".to_owned(),
|
|
388
|
+
provider: "dalle".to_owned(),
|
|
389
|
+
prompt: request.prompt.clone(),
|
|
390
|
+
description: None,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
273
394
|
let model = request
|
|
274
395
|
.model
|
|
275
396
|
.as_deref()
|
|
@@ -393,42 +514,98 @@ async fn call_stability(
|
|
|
393
514
|
}
|
|
394
515
|
});
|
|
395
516
|
|
|
396
|
-
|
|
397
|
-
|
|
517
|
+
let boundary = format!("MintBoundary{}", uuid_v4_hex());
|
|
518
|
+
|
|
519
|
+
let (endpoint, raw_body) = if let Some(ref img_uri) = request.image_data_uri {
|
|
520
|
+
let (mime_type, img_bytes) = parse_data_uri(img_uri)
|
|
521
|
+
.ok_or_else(|| ImageGenError::ModelError("invalid base64 image data URI".to_owned()))?;
|
|
522
|
+
|
|
523
|
+
let is_inpaint =
|
|
524
|
+
request.mask_data_uri.is_some() || request.mode.as_deref() == Some("inpaint");
|
|
525
|
+
let endpoint = if is_inpaint {
|
|
526
|
+
"https://api.stability.ai/v2beta/stable-image/edit/inpaint".to_owned()
|
|
527
|
+
} else {
|
|
528
|
+
"https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned()
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
let mut body_parts: Vec<(String, String)> = vec![
|
|
532
|
+
("prompt".into(), request.prompt.clone()),
|
|
533
|
+
("output_format".into(), "png".to_owned()),
|
|
534
|
+
];
|
|
535
|
+
if !is_inpaint && model != "core" {
|
|
536
|
+
body_parts.push(("model".into(), model.clone()));
|
|
537
|
+
body_parts.push(("mode".into(), "image-to-image".to_owned()));
|
|
538
|
+
body_parts.push(("strength".into(), "0.7".to_owned()));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if let Some(neg) = &request.negative_prompt {
|
|
542
|
+
let neg = neg.trim();
|
|
543
|
+
if !neg.is_empty() {
|
|
544
|
+
body_parts.push(("negative_prompt".into(), neg.to_owned()));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let mut file_fields = vec![(
|
|
549
|
+
"image",
|
|
550
|
+
"input.png",
|
|
551
|
+
mime_type.as_str(),
|
|
552
|
+
img_bytes.as_slice(),
|
|
553
|
+
)];
|
|
554
|
+
let mask_storage;
|
|
555
|
+
if let Some(ref mask_uri) = request.mask_data_uri {
|
|
556
|
+
if let Some((mask_mime, mask_bytes)) = parse_data_uri(mask_uri) {
|
|
557
|
+
mask_storage = (mask_mime, mask_bytes);
|
|
558
|
+
file_fields.push((
|
|
559
|
+
"mask",
|
|
560
|
+
"mask.png",
|
|
561
|
+
mask_storage.0.as_str(),
|
|
562
|
+
mask_storage.1.as_slice(),
|
|
563
|
+
));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
398
567
|
(
|
|
399
|
-
|
|
400
|
-
|
|
568
|
+
endpoint,
|
|
569
|
+
build_multipart_binary(&boundary, &body_parts, &file_fields),
|
|
401
570
|
)
|
|
402
571
|
} else {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
572
|
+
// Route: Stable Image Core uses /core; SD3.x models use /sd3
|
|
573
|
+
let (endpoint, model_param) = if model == "core" {
|
|
574
|
+
(
|
|
575
|
+
"https://api.stability.ai/v2beta/stable-image/generate/core".to_owned(),
|
|
576
|
+
None,
|
|
577
|
+
)
|
|
578
|
+
} else {
|
|
579
|
+
(
|
|
580
|
+
"https://api.stability.ai/v2beta/stable-image/generate/sd3".to_owned(),
|
|
581
|
+
Some(model.clone()),
|
|
582
|
+
)
|
|
583
|
+
};
|
|
410
584
|
|
|
411
|
-
|
|
412
|
-
let boundary = format!("MintBoundary{}", uuid_v4_hex());
|
|
585
|
+
let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
|
|
413
586
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
587
|
+
let mut body_parts: Vec<(String, String)> = vec![
|
|
588
|
+
("prompt".into(), request.prompt.clone()),
|
|
589
|
+
("aspect_ratio".into(), aspect_ratio.to_owned()),
|
|
590
|
+
("output_format".into(), "png".to_owned()),
|
|
591
|
+
];
|
|
419
592
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
593
|
+
if let Some(neg) = &request.negative_prompt {
|
|
594
|
+
let neg = neg.trim();
|
|
595
|
+
if !neg.is_empty() {
|
|
596
|
+
body_parts.push(("negative_prompt".into(), neg.to_owned()));
|
|
597
|
+
}
|
|
424
598
|
}
|
|
425
|
-
}
|
|
426
599
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
600
|
+
if let Some(m) = model_param {
|
|
601
|
+
body_parts.push(("model".into(), m));
|
|
602
|
+
}
|
|
430
603
|
|
|
431
|
-
|
|
604
|
+
(
|
|
605
|
+
endpoint,
|
|
606
|
+
build_multipart_text(&boundary, &body_parts).into_bytes(),
|
|
607
|
+
)
|
|
608
|
+
};
|
|
432
609
|
|
|
433
610
|
let response = client
|
|
434
611
|
.post(&endpoint)
|
|
@@ -604,17 +781,23 @@ async fn call_replicate(
|
|
|
604
781
|
.map(str::to_owned)
|
|
605
782
|
.unwrap_or_else(|| {
|
|
606
783
|
let m = config.replicate_model.trim();
|
|
607
|
-
if m.is_empty() {
|
|
608
|
-
"black-forest-labs/flux-1.1-pro".to_owned()
|
|
609
|
-
} else {
|
|
784
|
+
if !m.is_empty() {
|
|
610
785
|
m.to_owned()
|
|
786
|
+
} else if request.image_data_uri.is_some() {
|
|
787
|
+
if request.mask_data_uri.is_some() {
|
|
788
|
+
"black-forest-labs/flux-fill-dev".to_owned()
|
|
789
|
+
} else {
|
|
790
|
+
"timbrooks/instruct-pix2pix".to_owned()
|
|
791
|
+
}
|
|
792
|
+
} else {
|
|
793
|
+
"black-forest-labs/flux-1.1-pro".to_owned()
|
|
611
794
|
}
|
|
612
795
|
});
|
|
613
796
|
|
|
614
797
|
let num_images = request.num_images.unwrap_or(1).clamp(1, 4);
|
|
615
798
|
let aspect_ratio = request.aspect_ratio.as_deref().unwrap_or("1:1");
|
|
616
799
|
|
|
617
|
-
// Build input — common fields used by FLUX and SDXL families
|
|
800
|
+
// Build input — common fields used by FLUX, InstructPix2Pix, and SDXL families
|
|
618
801
|
let mut input = json!({
|
|
619
802
|
"prompt": request.prompt,
|
|
620
803
|
"num_outputs": num_images,
|
|
@@ -623,6 +806,13 @@ async fn call_replicate(
|
|
|
623
806
|
"output_quality": 90
|
|
624
807
|
});
|
|
625
808
|
|
|
809
|
+
if let Some(ref img_uri) = request.image_data_uri {
|
|
810
|
+
input["image"] = json!(img_uri);
|
|
811
|
+
if let Some(ref mask_uri) = request.mask_data_uri {
|
|
812
|
+
input["mask"] = json!(mask_uri);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
626
816
|
if let Some(neg) = &request.negative_prompt {
|
|
627
817
|
let neg = neg.trim();
|
|
628
818
|
if !neg.is_empty() {
|
|
@@ -790,6 +980,58 @@ fn build_multipart_text(boundary: &str, fields: &[(String, String)]) -> String {
|
|
|
790
980
|
body
|
|
791
981
|
}
|
|
792
982
|
|
|
983
|
+
/// Parse base64 data URI (e.g. data:image/png;base64,...) into (mime_type, bytes).
|
|
984
|
+
fn parse_data_uri(data_uri: &str) -> Option<(String, Vec<u8>)> {
|
|
985
|
+
if !data_uri.starts_with("data:") {
|
|
986
|
+
return None;
|
|
987
|
+
}
|
|
988
|
+
let parts: Vec<&str> = data_uri.splitn(2, ',').collect();
|
|
989
|
+
if parts.len() != 2 {
|
|
990
|
+
return None;
|
|
991
|
+
}
|
|
992
|
+
let header = parts[0];
|
|
993
|
+
let b64 = parts[1];
|
|
994
|
+
|
|
995
|
+
let mime_type = header
|
|
996
|
+
.trim_start_matches("data:")
|
|
997
|
+
.split(';')
|
|
998
|
+
.next()
|
|
999
|
+
.unwrap_or("image/png")
|
|
1000
|
+
.trim()
|
|
1001
|
+
.to_string();
|
|
1002
|
+
|
|
1003
|
+
let bytes = STANDARD.decode(b64.trim()).ok()?;
|
|
1004
|
+
Some((mime_type, bytes))
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/// Build multipart body containing both text fields and binary file fields.
|
|
1008
|
+
fn build_multipart_binary(
|
|
1009
|
+
boundary: &str,
|
|
1010
|
+
text_fields: &[(String, String)],
|
|
1011
|
+
file_fields: &[(&str, &str, &str, &[u8])],
|
|
1012
|
+
) -> Vec<u8> {
|
|
1013
|
+
let mut body = Vec::new();
|
|
1014
|
+
for (name, value) in text_fields {
|
|
1015
|
+
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
1016
|
+
body.extend_from_slice(
|
|
1017
|
+
format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
|
|
1018
|
+
);
|
|
1019
|
+
body.extend_from_slice(value.as_bytes());
|
|
1020
|
+
body.extend_from_slice(b"\r\n");
|
|
1021
|
+
}
|
|
1022
|
+
for (param_name, file_name, mime_type, bytes) in file_fields {
|
|
1023
|
+
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
1024
|
+
body.extend_from_slice(
|
|
1025
|
+
format!("Content-Disposition: form-data; name=\"{param_name}\"; filename=\"{file_name}\"\r\n").as_bytes(),
|
|
1026
|
+
);
|
|
1027
|
+
body.extend_from_slice(format!("Content-Type: {mime_type}\r\n\r\n").as_bytes());
|
|
1028
|
+
body.extend_from_slice(bytes);
|
|
1029
|
+
body.extend_from_slice(b"\r\n");
|
|
1030
|
+
}
|
|
1031
|
+
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
|
1032
|
+
body
|
|
1033
|
+
}
|
|
1034
|
+
|
|
793
1035
|
/// Build the final prompt string, optionally embedding aspect ratio and negative
|
|
794
1036
|
/// prompt guidance. Used by providers that accept a plain text prompt.
|
|
795
1037
|
fn build_prompt(request: &ImageGenRequest) -> String {
|
|
@@ -832,6 +1074,9 @@ mod tests {
|
|
|
832
1074
|
num_images: None,
|
|
833
1075
|
model: None,
|
|
834
1076
|
provider: None,
|
|
1077
|
+
image_data_uri: None,
|
|
1078
|
+
mask_data_uri: None,
|
|
1079
|
+
mode: None,
|
|
835
1080
|
}
|
|
836
1081
|
}
|
|
837
1082
|
|
|
@@ -850,6 +1095,9 @@ mod tests {
|
|
|
850
1095
|
num_images: Some(2),
|
|
851
1096
|
model: None,
|
|
852
1097
|
provider: None,
|
|
1098
|
+
image_data_uri: None,
|
|
1099
|
+
mask_data_uri: None,
|
|
1100
|
+
mode: None,
|
|
853
1101
|
};
|
|
854
1102
|
let result = build_prompt(&req);
|
|
855
1103
|
assert!(result.contains("a cat"));
|
|
@@ -866,6 +1114,9 @@ mod tests {
|
|
|
866
1114
|
num_images: None,
|
|
867
1115
|
model: None,
|
|
868
1116
|
provider: None,
|
|
1117
|
+
image_data_uri: None,
|
|
1118
|
+
mask_data_uri: None,
|
|
1119
|
+
mode: None,
|
|
869
1120
|
};
|
|
870
1121
|
assert!(!build_prompt(&req).contains("aspect ratio"));
|
|
871
1122
|
}
|
|
@@ -892,4 +1143,14 @@ mod tests {
|
|
|
892
1143
|
assert_eq!(aspect_ratio_to_ideogram(Some("1:1")), "ASPECT_1_1");
|
|
893
1144
|
assert_eq!(aspect_ratio_to_ideogram(None), "ASPECT_1_1");
|
|
894
1145
|
}
|
|
1146
|
+
|
|
1147
|
+
#[test]
|
|
1148
|
+
fn parse_data_uri_valid() {
|
|
1149
|
+
let uri = "data:image/png;base64,aGVsbG8=";
|
|
1150
|
+
let parsed = parse_data_uri(uri);
|
|
1151
|
+
assert!(parsed.is_some());
|
|
1152
|
+
let (mime, bytes) = parsed.unwrap();
|
|
1153
|
+
assert_eq!(mime, "image/png");
|
|
1154
|
+
assert_eq!(bytes, b"hello");
|
|
1155
|
+
}
|
|
895
1156
|
}
|
|
@@ -20,6 +20,7 @@ pub mod skills;
|
|
|
20
20
|
pub mod symbols;
|
|
21
21
|
pub mod tasks;
|
|
22
22
|
pub mod tts;
|
|
23
|
+
pub mod video_gen;
|
|
23
24
|
pub mod weather;
|
|
24
25
|
pub mod web_search;
|
|
25
26
|
pub mod workflows;
|
|
@@ -31,8 +32,9 @@ pub use chat::{
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
pub use browser::{
|
|
34
|
-
BrowserTab, click,
|
|
35
|
-
|
|
35
|
+
BrowserTab, click, get_element_coordinates, is_browser_running, key_press, list_tabs,
|
|
36
|
+
mouse_click, mouse_move, navigate, read_page_text, screenshot, spawn_automation_browser,
|
|
37
|
+
type_text, type_text_native,
|
|
36
38
|
};
|
|
37
39
|
pub use code_tools::{
|
|
38
40
|
AppliedCodeEdit, CodeEdit, CodeEditPreview, CodeEditProposal, CodeFile, CodeInspectionError,
|
|
@@ -82,6 +84,7 @@ pub use skills::{SkillError, learn_skill, learned_skills_context};
|
|
|
82
84
|
pub use symbols::{CodeSymbol, SymbolError, SymbolIndex, build_symbol_index};
|
|
83
85
|
pub use tasks::{Task, TaskError, TaskStore, tasks_path};
|
|
84
86
|
pub use tts::{TtsUrl, google_tts_urls};
|
|
87
|
+
pub use video_gen::{VideoGenError, VideoGenRequest, VideoGenResponse, generate_video};
|
|
85
88
|
pub use weather::{WeatherError, WeatherReport, weather};
|
|
86
89
|
pub use workflows::{WorkflowError, load_workflows, save_workflows, workflows_path};
|
|
87
90
|
pub mod api_server;
|
|
@@ -2,7 +2,10 @@ use std::{
|
|
|
2
2
|
collections::BTreeMap,
|
|
3
3
|
io::{BufRead, BufReader, Write},
|
|
4
4
|
process::{Child, Command, Stdio},
|
|
5
|
-
sync::
|
|
5
|
+
sync::{
|
|
6
|
+
atomic::{AtomicBool, Ordering},
|
|
7
|
+
mpsc,
|
|
8
|
+
},
|
|
6
9
|
time::{Duration, Instant},
|
|
7
10
|
};
|
|
8
11
|
|
|
@@ -12,7 +15,8 @@ use thiserror::Error;
|
|
|
12
15
|
|
|
13
16
|
use crate::{ConfigError, MintConfig, load_config, save_config};
|
|
14
17
|
|
|
15
|
-
const MCP_TIMEOUT: Duration = Duration::from_secs(
|
|
18
|
+
const MCP_TIMEOUT: Duration = Duration::from_secs(30);
|
|
19
|
+
static OAUTH_DETECTED: AtomicBool = AtomicBool::new(false);
|
|
16
20
|
|
|
17
21
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
18
22
|
pub struct McpServer {
|
|
@@ -21,6 +25,8 @@ pub struct McpServer {
|
|
|
21
25
|
pub args: Vec<String>,
|
|
22
26
|
#[serde(default)]
|
|
23
27
|
pub env: BTreeMap<String, String>,
|
|
28
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
29
|
+
pub icon: Option<String>,
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
#[derive(Debug, Error)]
|
|
@@ -33,7 +39,9 @@ pub enum McpError {
|
|
|
33
39
|
InvalidEnvironment,
|
|
34
40
|
#[error("MCP server '{0}' is not configured")]
|
|
35
41
|
MissingServer(String),
|
|
36
|
-
#[error(
|
|
42
|
+
#[error(
|
|
43
|
+
"MCP tool '{server}/{tool}' is not allowed by policy. To allow it, please run: /mcp allow {server} {tool} (or /mcp allow {server} * to allow all tools on this server)"
|
|
44
|
+
)]
|
|
37
45
|
NotAllowed { server: String, tool: String },
|
|
38
46
|
#[error("unable to start MCP server '{command}': {source}")]
|
|
39
47
|
Start {
|
|
@@ -82,6 +90,7 @@ pub fn add_mcp_server(
|
|
|
82
90
|
command: command.into(),
|
|
83
91
|
args,
|
|
84
92
|
env: parse_env(env)?,
|
|
93
|
+
icon: None,
|
|
85
94
|
},
|
|
86
95
|
);
|
|
87
96
|
save_mcp_servers(&mut config, servers)
|
|
@@ -186,18 +195,86 @@ fn parse_env(values: Vec<String>) -> Result<BTreeMap<String, String>, McpError>
|
|
|
186
195
|
.collect()
|
|
187
196
|
}
|
|
188
197
|
|
|
198
|
+
fn find_url(line: &str) -> Option<String> {
|
|
199
|
+
let start_idx = line.find("http://").or_else(|| line.find("https://"))?;
|
|
200
|
+
let rest = &line[start_idx..];
|
|
201
|
+
let end_idx = rest
|
|
202
|
+
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '<' || c == '>')
|
|
203
|
+
.unwrap_or(rest.len());
|
|
204
|
+
let url = rest[..end_idx].to_string();
|
|
205
|
+
|
|
206
|
+
let lower = url.to_lowercase();
|
|
207
|
+
if lower.contains("registry.npmjs.org")
|
|
208
|
+
|| lower.contains("npmjs.com")
|
|
209
|
+
|| lower.contains("github.com/modelcontextprotocol")
|
|
210
|
+
{
|
|
211
|
+
return None;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if lower.contains("oauth")
|
|
215
|
+
|| lower.contains("auth")
|
|
216
|
+
|| lower.contains("login")
|
|
217
|
+
|| lower.contains("google.com")
|
|
218
|
+
|| lower.contains("authorize")
|
|
219
|
+
{
|
|
220
|
+
Some(url)
|
|
221
|
+
} else {
|
|
222
|
+
None
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn open_url_in_browser(url: &str) -> std::io::Result<()> {
|
|
227
|
+
#[cfg(target_os = "linux")]
|
|
228
|
+
{
|
|
229
|
+
Command::new("xdg-open").arg(url).spawn().map(|_| ())
|
|
230
|
+
}
|
|
231
|
+
#[cfg(target_os = "macos")]
|
|
232
|
+
{
|
|
233
|
+
Command::new("open").arg(url).spawn().map(|_| ())
|
|
234
|
+
}
|
|
235
|
+
#[cfg(target_os = "windows")]
|
|
236
|
+
{
|
|
237
|
+
Command::new("cmd")
|
|
238
|
+
.args(&["/C", "start", url])
|
|
239
|
+
.spawn()
|
|
240
|
+
.map(|_| ())
|
|
241
|
+
}
|
|
242
|
+
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
|
243
|
+
{
|
|
244
|
+
let _ = url;
|
|
245
|
+
Ok(())
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
189
249
|
fn start_server(server: &McpServer) -> Result<Child, McpError> {
|
|
190
250
|
let mut process = Command::new(&server.command)
|
|
191
251
|
.args(&server.args)
|
|
192
252
|
.envs(&server.env)
|
|
193
253
|
.stdin(Stdio::piped())
|
|
194
254
|
.stdout(Stdio::piped())
|
|
195
|
-
.stderr(Stdio::
|
|
255
|
+
.stderr(Stdio::piped())
|
|
196
256
|
.spawn()
|
|
197
257
|
.map_err(|source| McpError::Start {
|
|
198
258
|
command: server.command.clone(),
|
|
199
259
|
source,
|
|
200
260
|
})?;
|
|
261
|
+
|
|
262
|
+
if let Some(stderr) = process.stderr.take() {
|
|
263
|
+
std::thread::spawn(move || {
|
|
264
|
+
let reader = BufReader::new(stderr);
|
|
265
|
+
for line in reader.lines().map_while(Result::ok) {
|
|
266
|
+
if let Some(url) = find_url(&line) {
|
|
267
|
+
println!(
|
|
268
|
+
"\n\x1b[1;33m[MCP Authorization Needed]\x1b[0m Opening browser to authenticate: {}\n",
|
|
269
|
+
url
|
|
270
|
+
);
|
|
271
|
+
OAUTH_DETECTED.store(true, Ordering::Relaxed);
|
|
272
|
+
let _ = open_url_in_browser(&url);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
201
278
|
write_message(
|
|
202
279
|
&mut process,
|
|
203
280
|
&json!({
|
|
@@ -224,26 +301,62 @@ fn exchange(process: &mut Child, request: Value) -> Result<Value, McpError> {
|
|
|
224
301
|
let (sender, receiver) = mpsc::channel();
|
|
225
302
|
std::thread::spawn(move || {
|
|
226
303
|
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
|
304
|
+
if let Some(url) = find_url(&line) {
|
|
305
|
+
println!(
|
|
306
|
+
"\n\x1b[1;33m[MCP Authorization Needed]\x1b[0m Opening browser to authenticate: {}\n",
|
|
307
|
+
url
|
|
308
|
+
);
|
|
309
|
+
OAUTH_DETECTED.store(true, Ordering::Relaxed);
|
|
310
|
+
let _ = open_url_in_browser(&url);
|
|
311
|
+
}
|
|
227
312
|
if let Ok(value) = serde_json::from_str::<Value>(&line) {
|
|
228
313
|
let _ = sender.send(value);
|
|
229
314
|
}
|
|
230
315
|
}
|
|
231
316
|
});
|
|
317
|
+
|
|
318
|
+
let timeout = if OAUTH_DETECTED.load(Ordering::Relaxed) {
|
|
319
|
+
Duration::from_secs(120)
|
|
320
|
+
} else {
|
|
321
|
+
MCP_TIMEOUT
|
|
322
|
+
};
|
|
323
|
+
|
|
232
324
|
let started = Instant::now();
|
|
233
|
-
while started.elapsed() <
|
|
325
|
+
while started.elapsed() < timeout {
|
|
234
326
|
let response = receiver
|
|
235
|
-
.recv_timeout(
|
|
327
|
+
.recv_timeout(timeout.saturating_sub(started.elapsed()))
|
|
236
328
|
.map_err(|_| McpError::Timeout)?;
|
|
237
329
|
if response["id"] == 2 {
|
|
330
|
+
OAUTH_DETECTED.store(false, Ordering::Relaxed);
|
|
238
331
|
if let Some(error) = response.get("error") {
|
|
239
332
|
return Err(McpError::Tool(error.clone()));
|
|
240
333
|
}
|
|
241
334
|
return Ok(response.get("result").cloned().unwrap_or(Value::Null));
|
|
242
335
|
}
|
|
243
336
|
}
|
|
337
|
+
OAUTH_DETECTED.store(false, Ordering::Relaxed);
|
|
244
338
|
Err(McpError::Timeout)
|
|
245
339
|
}
|
|
246
340
|
|
|
341
|
+
pub fn list_server_tools(config: &MintConfig, server_name: &str) -> Result<Value, McpError> {
|
|
342
|
+
let servers = configured_mcp_servers(config)?;
|
|
343
|
+
let server = servers
|
|
344
|
+
.get(server_name)
|
|
345
|
+
.ok_or_else(|| McpError::MissingServer(server_name.into()))?;
|
|
346
|
+
let mut process = start_server(server)?;
|
|
347
|
+
let result = exchange(
|
|
348
|
+
&mut process,
|
|
349
|
+
json!({
|
|
350
|
+
"jsonrpc": "2.0",
|
|
351
|
+
"id": 2,
|
|
352
|
+
"method": "tools/list",
|
|
353
|
+
"params": {}
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
let _ = process.kill();
|
|
357
|
+
result
|
|
358
|
+
}
|
|
359
|
+
|
|
247
360
|
fn write_message(process: &mut Child, message: &Value) -> Result<(), McpError> {
|
|
248
361
|
let stdin = process.stdin.as_mut().ok_or(McpError::MissingStdin)?;
|
|
249
362
|
writeln!(stdin, "{message}").map_err(McpError::Write)?;
|