@pheem49/mint 1.8.2 → 1.9.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.
Files changed (44) hide show
  1. package/Cargo.toml +1 -1
  2. package/Release_Note.md +48 -3
  3. package/crates/mint-cli/src/agent.rs +95 -84
  4. package/crates/mint-cli/src/image.rs +8 -8
  5. package/crates/mint-cli/src/main.rs +806 -403
  6. package/crates/mint-cli/src/onboard.rs +108 -108
  7. package/crates/mint-cli/src/setup.rs +143 -143
  8. package/crates/mint-core/src/api_server.rs +57 -54
  9. package/crates/mint-core/src/browser.rs +1 -1
  10. package/crates/mint-core/src/chat.rs +123 -8
  11. package/crates/mint-core/src/code_tools.rs +18 -20
  12. package/crates/mint-core/src/config.rs +184 -17
  13. package/crates/mint-core/src/image_gen.rs +5 -5
  14. package/crates/mint-core/src/lib.rs +5 -5
  15. package/crates/mint-core/src/memory.rs +6 -1
  16. package/crates/mint-core/src/orchestration.rs +104 -85
  17. package/crates/mint-core/src/pictures.rs +3 -4
  18. package/crates/mint-core/src/semantic.rs +1 -1
  19. package/crates/mint-core/src/shell.rs +1 -1
  20. package/crates/mint-core/src/skills.rs +46 -19
  21. package/package.json +1 -1
  22. package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
  23. package/shared-modules/linux-audio/fluidsynth2.json +8 -2
  24. package/shared-modules/linux-audio/lilv.json +26 -19
  25. package/shared-modules/linux-audio/lv2.json +0 -1
  26. package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
  27. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  28. package/src/renderer/src/components/ChatPanel.tsx +25 -5
  29. package/src/renderer/src/components/MintDashboard.tsx +45 -2
  30. package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
  31. package/src/renderer/src/components/Settings/GeneralTab.tsx +256 -2
  32. package/src/renderer/src/components/SettingsWindow.tsx +68 -1
  33. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  34. package/src/renderer/src/css/styles.css +16 -0
  35. package/src/renderer/src-web/components/ChatPanel.tsx +25 -5
  36. package/src/renderer/src-web/components/MintDashboard.tsx +19 -1
  37. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
  38. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +254 -3
  39. package/src/renderer/src-web/components/SettingsWindow.tsx +75 -1
  40. package/src/renderer/src-web/css/styles.css +16 -0
  41. package/src-tauri/src/desktop.rs +18 -16
  42. package/src-tauri/src/discord_rpc.rs +1 -1
  43. package/src-tauri/src/lib.rs +41 -17
  44. package/src-tauri/tauri.conf.json +1 -1
@@ -213,16 +213,15 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
213
213
  new_title: String,
214
214
  }
215
215
 
216
- if let Ok(req) = serde_json::from_str::<RenameRequest>(body) {
217
- if let Ok(memory) = MemoryStore::open_default() {
218
- let updated = memory
219
- .rename_chat_session(&req.chat_id, &req.new_title)
220
- .unwrap_or(0);
221
- let response =
222
- serde_json::json!({ "status": "ok", "updated": updated });
223
- send_json_response(socket, "200 OK", &response.to_string()).await;
224
- return;
225
- }
216
+ if let Ok(req) = serde_json::from_str::<RenameRequest>(body)
217
+ && let Ok(memory) = MemoryStore::open_default()
218
+ {
219
+ let updated = memory
220
+ .rename_chat_session(&req.chat_id, &req.new_title)
221
+ .unwrap_or(0);
222
+ let response = serde_json::json!({ "status": "ok", "updated": updated });
223
+ send_json_response(socket, "200 OK", &response.to_string()).await;
224
+ return;
226
225
  }
227
226
  send_json_response(
228
227
  socket,
@@ -252,12 +251,12 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
252
251
  key: String,
253
252
  value: String,
254
253
  }
255
- if let Ok(req) = serde_json::from_str::<ProfileRequest>(body) {
256
- if let Ok(memory) = MemoryStore::open_default() {
257
- let _ = memory.set_profile(&req.key, &req.value);
258
- send_json_response(socket, "200 OK", "{\"status\":\"ok\"}").await;
259
- return;
260
- }
254
+ if let Ok(req) = serde_json::from_str::<ProfileRequest>(body)
255
+ && let Ok(memory) = MemoryStore::open_default()
256
+ {
257
+ let _ = memory.set_profile(&req.key, &req.value);
258
+ send_json_response(socket, "200 OK", "{\"status\":\"ok\"}").await;
259
+ return;
261
260
  }
262
261
  send_json_response(
263
262
  socket,
@@ -341,25 +340,18 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
341
340
  activity: Vec<AgentProgress>,
342
341
  }
343
342
 
344
- if let Ok(payload) = serde_json::from_str::<SaveAgentActivityRequest>(body) {
345
- if let Ok(memory) = MemoryStore::open_default() {
346
- match serde_json::to_string(&payload.activity) {
347
- Ok(activity_json) => {
348
- if memory
349
- .set_interaction_agent_activity_json(
350
- payload.interaction_id,
351
- &activity_json,
352
- )
353
- .is_ok()
354
- {
355
- send_json_response(socket, "200 OK", "{\"status\":\"ok\"}")
356
- .await;
357
- return;
358
- }
359
- }
360
- Err(_) => {}
361
- }
362
- }
343
+ if let Ok(payload) = serde_json::from_str::<SaveAgentActivityRequest>(body)
344
+ && let Ok(memory) = MemoryStore::open_default()
345
+ && let Ok(activity_json) = serde_json::to_string(&payload.activity)
346
+ && memory
347
+ .set_interaction_agent_activity_json(
348
+ payload.interaction_id,
349
+ &activity_json,
350
+ )
351
+ .is_ok()
352
+ {
353
+ send_json_response(socket, "200 OK", "{\"status\":\"ok\"}").await;
354
+ return;
363
355
  }
364
356
  send_json_response(
365
357
  socket,
@@ -406,11 +398,11 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
406
398
  }
407
399
  }
408
400
  ("POST", "/api/config") => {
409
- if let Ok(new_config) = serde_json::from_str::<MintConfig>(body) {
410
- if save_config(&new_config).is_ok() {
411
- send_json_response(socket, "200 OK", "{\"status\":\"ok\"}").await;
412
- return;
413
- }
401
+ if let Ok(new_config) = serde_json::from_str::<MintConfig>(body)
402
+ && save_config(&new_config).is_ok()
403
+ {
404
+ send_json_response(socket, "200 OK", "{\"status\":\"ok\"}").await;
405
+ return;
414
406
  }
415
407
  send_json_response(
416
408
  socket,
@@ -693,22 +685,27 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
693
685
  "text": format!("Error orchestrating chat: {e}")
694
686
  }
695
687
  });
696
- let _ = tx_done
697
- .send(format!("{}\n", err_json.to_string()));
688
+ let _ = tx_done.send(format!("{}\n", err_json));
698
689
  }
699
690
  }
700
691
  });
701
692
 
702
693
  let abort_handle = join_handle.abort_handle();
703
694
  if !chat_id_str.is_empty() {
704
- crate::ACTIVE_AGENTS.lock().unwrap().insert(chat_id_str.clone(), abort_handle);
695
+ crate::ACTIVE_AGENTS
696
+ .lock()
697
+ .unwrap()
698
+ .insert(chat_id_str.clone(), abort_handle);
705
699
  }
706
700
 
707
701
  let chat_id_str_cleanup = chat_id_str.clone();
708
702
  tokio::spawn(async move {
709
703
  let _ = join_handle.await;
710
704
  if !chat_id_str_cleanup.is_empty() {
711
- crate::ACTIVE_AGENTS.lock().unwrap().remove(&chat_id_str_cleanup);
705
+ crate::ACTIVE_AGENTS
706
+ .lock()
707
+ .unwrap()
708
+ .remove(&chat_id_str_cleanup);
712
709
  }
713
710
  });
714
711
  } else {
@@ -768,22 +765,27 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
768
765
  "text": format!("Error orchestrating agent: {e}")
769
766
  }
770
767
  });
771
- let _ = tx_done
772
- .send(format!("{}\n", err_json.to_string()));
768
+ let _ = tx_done.send(format!("{}\n", err_json));
773
769
  }
774
770
  }
775
771
  });
776
772
 
777
773
  let abort_handle = join_handle.abort_handle();
778
774
  if !chat_id_str.is_empty() {
779
- crate::ACTIVE_AGENTS.lock().unwrap().insert(chat_id_str.clone(), abort_handle);
775
+ crate::ACTIVE_AGENTS
776
+ .lock()
777
+ .unwrap()
778
+ .insert(chat_id_str.clone(), abort_handle);
780
779
  }
781
780
 
782
781
  let chat_id_str_cleanup = chat_id_str.clone();
783
782
  tokio::spawn(async move {
784
783
  let _ = join_handle.await;
785
784
  if !chat_id_str_cleanup.is_empty() {
786
- crate::ACTIVE_AGENTS.lock().unwrap().remove(&chat_id_str_cleanup);
785
+ crate::ACTIVE_AGENTS
786
+ .lock()
787
+ .unwrap()
788
+ .remove(&chat_id_str_cleanup);
787
789
  }
788
790
  });
789
791
  }
@@ -1135,12 +1137,13 @@ fn percent_decode(raw: &str) -> String {
1135
1137
  let mut output = Vec::with_capacity(bytes.len());
1136
1138
  let mut index = 0;
1137
1139
  while index < bytes.len() {
1138
- if bytes[index] == b'%' && index + 2 < bytes.len() {
1139
- if let Ok(hex) = u8::from_str_radix(&raw[index + 1..index + 3], 16) {
1140
- output.push(hex);
1141
- index += 3;
1142
- continue;
1143
- }
1140
+ if bytes[index] == b'%'
1141
+ && index + 2 < bytes.len()
1142
+ && let Ok(hex) = u8::from_str_radix(&raw[index + 1..index + 3], 16)
1143
+ {
1144
+ output.push(hex);
1145
+ index += 3;
1146
+ continue;
1144
1147
  }
1145
1148
  output.push(if bytes[index] == b'+' {
1146
1149
  b' '
@@ -243,7 +243,7 @@ pub async fn spawn_automation_browser(config: &MintConfig) -> Result<(), String>
243
243
  .unwrap_or("chromium");
244
244
 
245
245
  let profile_dir = dirs::config_dir()
246
- .unwrap_or_else(|| std::env::temp_dir())
246
+ .unwrap_or_else(std::env::temp_dir)
247
247
  .join("mint")
248
248
  .join("automation-profile");
249
249
  let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
@@ -19,13 +19,13 @@ pub async fn send_chat_with_fallback(
19
19
  Err(_) => {}
20
20
  }
21
21
  for provider in config.available_providers() {
22
- if provider == config.ai_provider.as_str() {
22
+ if provider.as_str() == config.ai_provider.as_str() {
23
23
  continue;
24
24
  }
25
- let alt = config_for_provider(config, provider);
25
+ let alt = config_for_provider(config, &provider);
26
26
  if let Ok(mut r) = send_chat(&alt, request).await {
27
27
  r.fallback_provider = Some(config.ai_provider.clone());
28
- return Ok((r, Some(provider.to_owned())));
28
+ return Ok((r, Some(provider)));
29
29
  }
30
30
  }
31
31
  // all fallbacks failed — retry primary to surface original error
@@ -50,13 +50,13 @@ where
50
50
  }
51
51
  }
52
52
  for provider in config.available_providers() {
53
- if provider == config.ai_provider.as_str() {
53
+ if provider.as_str() == config.ai_provider.as_str() {
54
54
  continue;
55
55
  }
56
- let alt = config_for_provider(config, provider);
56
+ let alt = config_for_provider(config, &provider);
57
57
  if let Ok(mut r) = stream_chat(&alt, request, &mut on_chunk).await {
58
58
  r.fallback_provider = Some(config.ai_provider.clone());
59
- return Ok((r, Some(provider.to_owned())));
59
+ return Ok((r, Some(provider)));
60
60
  }
61
61
  }
62
62
  stream_chat(config, request, &mut on_chunk)
@@ -230,6 +230,7 @@ pub async fn send_chat(
230
230
  "ollama" => call_ollama(&client, config, request).await?,
231
231
  "anthropic" => call_anthropic(&client, config, request).await?,
232
232
  "huggingface" => call_huggingface(&client, config, request).await?,
233
+ p if p.starts_with("custom:") => call_custom_provider(&client, config, request).await?,
233
234
  other => return Err(ChatError::UnsupportedProvider(other.into())),
234
235
  };
235
236
  Ok(ChatResponse {
@@ -259,6 +260,9 @@ where
259
260
  "ollama" => stream_ollama(&client, config, request, &mut on_chunk).await?,
260
261
  "anthropic" => stream_anthropic(&client, config, request, &mut on_chunk).await?,
261
262
  "huggingface" => stream_huggingface(&client, config, request, &mut on_chunk).await?,
263
+ p if p.starts_with("custom:") => {
264
+ stream_custom_provider(&client, config, request, &mut on_chunk).await?
265
+ }
262
266
  other => return Err(ChatError::UnsupportedProvider(other.into())),
263
267
  };
264
268
  Ok(ChatResponse {
@@ -708,6 +712,109 @@ fn required_key(provider: &str, key: &str) -> Result<(), ChatError> {
708
712
  }
709
713
  }
710
714
 
715
+ /// Resolve which model to use for the current custom provider.
716
+ ///
717
+ /// Checks `config.extra["customModelSelections"]` first (set by the UI when the
718
+ /// user picks a specific model from the multi-model dropdown), then falls back to
719
+ /// the first model defined in the `CustomProvider`.
720
+ fn resolve_custom_model(config: &crate::MintConfig) -> String {
721
+ let provider_id = config
722
+ .ai_provider
723
+ .strip_prefix("custom:")
724
+ .unwrap_or(&config.ai_provider);
725
+
726
+ // Check if a per-provider model selection was persisted in extra config.
727
+ if let Some(selections) = config
728
+ .extra
729
+ .get("customModelSelections")
730
+ .and_then(|v| v.as_object())
731
+ && let Some(model) = selections.get(provider_id).and_then(|v| v.as_str())
732
+ && !model.is_empty()
733
+ {
734
+ return model.to_owned();
735
+ }
736
+
737
+ // Fall back to the first model in the provider definition.
738
+ config
739
+ .resolve_custom_provider(&config.ai_provider)
740
+ .and_then(|cp| cp.models.first())
741
+ .map(|m| m.model_id.clone())
742
+ .unwrap_or_default()
743
+ }
744
+
745
+ async fn call_custom_provider(
746
+ client: &Client,
747
+ config: &crate::MintConfig,
748
+ request: &ChatRequest,
749
+ ) -> Result<(String, String), ChatError> {
750
+ let provider = config
751
+ .resolve_custom_provider(&config.ai_provider)
752
+ .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
753
+
754
+ let base_url = provider.base_url.trim_end_matches('/');
755
+ let model = resolve_custom_model(config);
756
+ let payload = openai_chat_payload(&model, request, false);
757
+
758
+ let mut builder = client
759
+ .post(format!("{base_url}/chat/completions"))
760
+ .json(&payload);
761
+
762
+ if !provider.api_key.is_empty() {
763
+ builder = builder.bearer_auth(&provider.api_key);
764
+ }
765
+ for h in &provider.headers {
766
+ if !h.name.is_empty() {
767
+ builder = builder.header(h.name.as_str(), h.value.as_str());
768
+ }
769
+ }
770
+
771
+ let response: serde_json::Value = builder.send().await?.error_for_status()?.json().await?;
772
+
773
+ Ok((
774
+ model,
775
+ response["choices"][0]["message"]["content"]
776
+ .as_str()
777
+ .ok_or(ChatError::MissingResponseText)?
778
+ .into(),
779
+ ))
780
+ }
781
+
782
+ async fn stream_custom_provider<F>(
783
+ client: &Client,
784
+ config: &crate::MintConfig,
785
+ request: &ChatRequest,
786
+ on_chunk: &mut F,
787
+ ) -> Result<(String, String), ChatError>
788
+ where
789
+ F: FnMut(String),
790
+ {
791
+ let provider = config
792
+ .resolve_custom_provider(&config.ai_provider)
793
+ .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
794
+
795
+ let base_url = provider.base_url.trim_end_matches('/');
796
+ let model = resolve_custom_model(config);
797
+ let payload = openai_chat_payload(&model, request, true);
798
+
799
+ let mut builder = client
800
+ .post(format!("{base_url}/chat/completions"))
801
+ .json(&payload);
802
+
803
+ if !provider.api_key.is_empty() {
804
+ builder = builder.bearer_auth(&provider.api_key);
805
+ }
806
+ for h in &provider.headers {
807
+ if !h.name.is_empty() {
808
+ builder = builder.header(h.name.as_str(), h.value.as_str());
809
+ }
810
+ }
811
+
812
+ let response = builder.send().await?.error_for_status()?;
813
+ collect_stream(response, StreamFormat::OpenAi, on_chunk)
814
+ .await
815
+ .map(|text| (model, text))
816
+ }
817
+
711
818
  fn provider_key(configured: &str, environment_variable: &str) -> String {
712
819
  if configured.trim().is_empty() {
713
820
  std::env::var(environment_variable).unwrap_or_default()
@@ -720,16 +827,24 @@ fn require_supported_attachments(provider: &str, request: &ChatRequest) -> Resul
720
827
  let has_image = request
721
828
  .image_data_uri
722
829
  .as_ref()
723
- .map_or(false, |s| !s.trim().is_empty());
830
+ .is_some_and(|s| !s.trim().is_empty());
724
831
  let has_audio = request
725
832
  .audio_data_uri
726
833
  .as_ref()
727
- .map_or(false, |s| !s.trim().is_empty());
834
+ .is_some_and(|s| !s.trim().is_empty());
728
835
 
729
836
  match provider {
730
837
  "gemini" => Ok(()),
731
838
  "ollama" if has_audio => Err(ChatError::UnsupportedAttachments(provider.into())),
732
839
  "ollama" => Ok(()),
840
+ // Custom providers are text-only for now; reject any multimodal content.
841
+ p if p.starts_with("custom:") => {
842
+ if has_image || has_audio {
843
+ Err(ChatError::UnsupportedAttachments(provider.into()))
844
+ } else {
845
+ Ok(())
846
+ }
847
+ }
733
848
  _ if has_image || has_audio => Err(ChatError::UnsupportedAttachments(provider.into())),
734
849
  _ => Ok(()),
735
850
  }
@@ -536,18 +536,17 @@ pub async fn fetch_github_repo_summary(owner: &str, repo: &str) -> Result<String
536
536
  .await
537
537
  .map_err(|e| e.to_string())?;
538
538
  let mut file_tree = String::from("Unavailable");
539
- if contents_resp.status().is_success() {
540
- if let Ok(contents_info) = contents_resp.json::<serde_json::Value>().await {
541
- if let Some(arr) = contents_info.as_array() {
542
- let mut files = Vec::new();
543
- for item in arr {
544
- let name = item["name"].as_str().unwrap_or("");
545
- let r#type = item["type"].as_str().unwrap_or("");
546
- files.push(format!("- {} ({})", name, r#type));
547
- }
548
- file_tree = files.join("\n");
549
- }
539
+ if contents_resp.status().is_success()
540
+ && let Ok(contents_info) = contents_resp.json::<serde_json::Value>().await
541
+ && let Some(arr) = contents_info.as_array()
542
+ {
543
+ let mut files = Vec::new();
544
+ for item in arr {
545
+ let name = item["name"].as_str().unwrap_or("");
546
+ let r#type = item["type"].as_str().unwrap_or("");
547
+ files.push(format!("- {} ({})", name, r#type));
550
548
  }
549
+ file_tree = files.join("\n");
551
550
  }
552
551
 
553
552
  // 3. Fetch README.md
@@ -558,15 +557,14 @@ pub async fn fetch_github_repo_summary(owner: &str, repo: &str) -> Result<String
558
557
  .await
559
558
  .map_err(|e| e.to_string())?;
560
559
  let mut readme_text = String::from("No README available.");
561
- if readme_resp.status().is_success() {
562
- if let Ok(readme_info) = readme_resp.json::<serde_json::Value>().await {
563
- if let Some(content_b64) = readme_info["content"].as_str() {
564
- let cleaned_b64 = content_b64.replace('\n', "").replace('\r', "");
565
- use base64::{Engine as _, engine::general_purpose::STANDARD};
566
- if let Ok(decoded_bytes) = STANDARD.decode(cleaned_b64) {
567
- readme_text = String::from_utf8_lossy(&decoded_bytes).to_string();
568
- }
569
- }
560
+ if readme_resp.status().is_success()
561
+ && let Ok(readme_info) = readme_resp.json::<serde_json::Value>().await
562
+ && let Some(content_b64) = readme_info["content"].as_str()
563
+ {
564
+ let cleaned_b64 = content_b64.replace(['\n', '\r'], "");
565
+ use base64::{Engine as _, engine::general_purpose::STANDARD};
566
+ if let Ok(decoded_bytes) = STANDARD.decode(cleaned_b64) {
567
+ readme_text = String::from_utf8_lossy(&decoded_bytes).to_string();
570
568
  }
571
569
  }
572
570