@pheem49/mint 1.8.2 → 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.
Files changed (106) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +297 -107
  4. package/bin/mint +0 -0
  5. package/crates/mint-cli/Cargo.toml +1 -0
  6. package/crates/mint-cli/src/actions.rs +233 -0
  7. package/crates/mint-cli/src/agent.rs +382 -127
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/image.rs +8 -8
  10. package/crates/mint-cli/src/interactive.rs +2888 -0
  11. package/crates/mint-cli/src/main.rs +391 -2506
  12. package/crates/mint-cli/src/markdown.rs +294 -0
  13. package/crates/mint-cli/src/onboard.rs +152 -109
  14. package/crates/mint-cli/src/setup.rs +143 -143
  15. package/crates/mint-cli/src/updater.rs +48 -6
  16. package/crates/mint-core/Cargo.toml +1 -0
  17. package/crates/mint-core/src/agent_loop.rs +1 -0
  18. package/crates/mint-core/src/api_server.rs +431 -65
  19. package/crates/mint-core/src/browser.rs +630 -102
  20. package/crates/mint-core/src/channels.rs +105 -6
  21. package/crates/mint-core/src/chat.rs +174 -11
  22. package/crates/mint-core/src/code_tools.rs +47 -31
  23. package/crates/mint-core/src/config.rs +302 -17
  24. package/crates/mint-core/src/image_gen.rs +299 -38
  25. package/crates/mint-core/src/lib.rs +10 -7
  26. package/crates/mint-core/src/mcp.rs +119 -6
  27. package/crates/mint-core/src/memory.rs +6 -1
  28. package/crates/mint-core/src/orchestration.rs +237 -96
  29. package/crates/mint-core/src/pictures.rs +67 -5
  30. package/crates/mint-core/src/semantic.rs +1 -1
  31. package/crates/mint-core/src/shell.rs +1 -1
  32. package/crates/mint-core/src/skills.rs +128 -25
  33. package/crates/mint-core/src/video_gen.rs +235 -0
  34. package/package.json +8 -2
  35. package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
  36. package/shared-modules/linux-audio/fluidsynth2.json +8 -2
  37. package/shared-modules/linux-audio/lilv.json +26 -19
  38. package/shared-modules/linux-audio/lv2.json +0 -1
  39. package/shared-modules/luajit/luajit.json +2 -2
  40. package/skills-lock.json +41 -0
  41. package/src/renderer/index-web.html +1 -1
  42. package/src/renderer/index.html +1 -1
  43. package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
  44. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  45. package/src/renderer/shared/platform.ts +3 -0
  46. package/src/renderer/shared/types.ts +4 -0
  47. package/src/renderer/shared/utils/approval.tsx +86 -7
  48. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  49. package/src/renderer/shared/utils/markdown.tsx +217 -88
  50. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  51. package/src/renderer/src/components/ChatPanel.tsx +486 -69
  52. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  53. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  54. package/src/renderer/src/components/MintDashboard.tsx +195 -7
  55. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  56. package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
  57. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  58. package/src/renderer/src/components/Settings/GeneralTab.tsx +795 -413
  59. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  60. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  61. package/src/renderer/src/components/SettingsWindow.tsx +106 -3
  62. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  63. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  64. package/src/renderer/src/css/image-studio.css +106 -0
  65. package/src/renderer/src/css/settings.css +158 -0
  66. package/src/renderer/src/css/styles.css +368 -1
  67. package/src/renderer/src/css/veo-studio.css +626 -0
  68. package/src/renderer/src/index.css +1 -0
  69. package/src/renderer/src/tauri.ts +144 -17
  70. package/src/renderer/src-web/components/ChatPanel.tsx +385 -93
  71. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  72. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  73. package/src/renderer/src-web/components/MintDashboard.tsx +156 -5
  74. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  75. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
  76. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  77. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +795 -411
  78. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  79. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  80. package/src/renderer/src-web/components/SettingsWindow.tsx +110 -3
  81. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  82. package/src/renderer/src-web/css/image-studio.css +106 -0
  83. package/src/renderer/src-web/css/settings.css +158 -0
  84. package/src/renderer/src-web/css/styles.css +369 -1
  85. package/src/renderer/src-web/css/veo-studio.css +650 -0
  86. package/src/renderer/src-web/index.css +1 -0
  87. package/src/renderer/src-web/tauri.ts +135 -16
  88. package/src-tauri/src/desktop.rs +18 -16
  89. package/src-tauri/src/discord_rpc.rs +1 -1
  90. package/src-tauri/src/lib.rs +155 -25
  91. package/src-tauri/src/webhooks.rs +45 -31
  92. package/src-tauri/src/workflows.rs +1 -0
  93. package/src-tauri/tauri.conf.json +1 -1
  94. package/temp_gridgeist/AGENTS.md +8 -0
  95. package/temp_gridgeist/CHANGELOG.md +130 -0
  96. package/temp_gridgeist/LICENSE +21 -0
  97. package/temp_gridgeist/README.md +323 -0
  98. package/temp_gridgeist/README.th.md +325 -0
  99. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  100. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  101. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  102. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  103. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  104. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  105. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  106. package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
@@ -61,7 +61,24 @@ async fn telegram_loop() -> Result<(), String> {
61
61
  ) else {
62
62
  continue;
63
63
  };
64
- let answer = answer(text, "Reply concisely for a Telegram chat.").await;
64
+ let formatted_chat_id = format!("telegram:{chat_id}");
65
+ if let Ok(config) = load_config() {
66
+ if config.bridge_ack_enabled() {
67
+ let _ = client
68
+ .post(format!(
69
+ "https://api.telegram.org/bot{token}/sendChatAction"
70
+ ))
71
+ .json(&json!({ "chat_id": chat_id, "action": "typing" }))
72
+ .send()
73
+ .await;
74
+ }
75
+ }
76
+ let answer = answer_channel(
77
+ text,
78
+ "Reply concisely for a Telegram chat.",
79
+ Some(formatted_chat_id),
80
+ )
81
+ .await;
65
82
  let _ = client
66
83
  .post(format!("https://api.telegram.org/bot{token}/sendMessage"))
67
84
  .json(&json!({ "chat_id": chat_id, "text": answer }))
@@ -116,7 +133,14 @@ async fn discord_loop() -> Result<(), String> {
116
133
  mentions.iter().any(|mention| mention["id"].as_str() == Some(&bot_id))
117
134
  });
118
135
  if !direct_message && !mentioned { continue }
119
- let reply = answer(text, "Reply concisely for a Discord chat.").await;
136
+ if let Ok(config) = load_config() {
137
+ if config.bridge_ack_enabled() {
138
+ let _ = crate::HTTP_CLIENT.clone().post(format!("https://discord.com/api/v10/channels/{channel}/typing"))
139
+ .header("Authorization", format!("Bot {token}")).send().await;
140
+ }
141
+ }
142
+ let formatted_chat_id = format!("discord:{channel}");
143
+ let reply = answer_channel(text, "Reply concisely for a Discord chat.", Some(formatted_chat_id)).await;
120
144
  let _ = crate::HTTP_CLIENT.clone().post(format!("https://discord.com/api/v10/channels/{channel}/messages"))
121
145
  .header("Authorization", format!("Bot {token}")).json(&json!({ "content": reply })).send().await;
122
146
  }
@@ -168,7 +192,13 @@ async fn slack_loop() -> Result<(), String> {
168
192
  else {
169
193
  continue;
170
194
  };
171
- let reply = answer(text, "Reply concisely for a Slack chat.").await;
195
+ let formatted_chat_id = format!("slack:{channel}");
196
+ let reply = answer_channel(
197
+ text,
198
+ "Reply concisely for a Slack chat.",
199
+ Some(formatted_chat_id),
200
+ )
201
+ .await;
172
202
  let _ = crate::HTTP_CLIENT
173
203
  .clone()
174
204
  .post("https://slack.com/api/chat.postMessage")
@@ -180,20 +210,89 @@ async fn slack_loop() -> Result<(), String> {
180
210
  Ok(())
181
211
  }
182
212
 
183
- async fn answer(text: &str, system_instruction: &str) -> String {
213
+ pub fn is_action_intent(text: &str) -> bool {
214
+ let lower = text.to_lowercase();
215
+ let action_keywords = [
216
+ "แก้",
217
+ "สร้าง",
218
+ "ลบ",
219
+ "รัน",
220
+ "ตรวจ",
221
+ "ค้นหา",
222
+ "เช็ค",
223
+ "ดูไฟล์",
224
+ "อ่านไฟล์",
225
+ "วิเคราะห์ไฟล์",
226
+ "fix",
227
+ "create",
228
+ "build",
229
+ "run",
230
+ "delete",
231
+ "edit",
232
+ "update",
233
+ "check",
234
+ "find",
235
+ "search",
236
+ "read file",
237
+ "git",
238
+ "patch",
239
+ "test",
240
+ "analyze file",
241
+ ];
242
+ action_keywords
243
+ .iter()
244
+ .any(|&keyword| lower.contains(keyword))
245
+ }
246
+
247
+ pub async fn answer_channel(
248
+ text: &str,
249
+ system_instruction: &str,
250
+ chat_id: Option<String>,
251
+ ) -> String {
184
252
  let Ok(config) = load_config() else {
185
253
  return "Mint config error".into();
186
254
  };
255
+ let workspace = config.active_workspace_path();
256
+ let workspace_str = workspace.as_ref().map(|p| p.to_string_lossy().to_string());
257
+
258
+ if is_action_intent(text)
259
+ && let Some(ref root_path) = workspace
260
+ {
261
+ if root_path.exists() {
262
+ let agent_result = crate::orchestrate_agent_loop(
263
+ &config,
264
+ text,
265
+ root_path,
266
+ None,
267
+ None,
268
+ chat_id.as_deref(),
269
+ None,
270
+ true,
271
+ |_approval| Ok(crate::ApprovalOutcome::Approved),
272
+ |_progress| {},
273
+ |_chunk| {},
274
+ )
275
+ .await;
276
+
277
+ if let Ok(res) = agent_result {
278
+ if !res.summary.trim().is_empty() {
279
+ return res.summary;
280
+ }
281
+ }
282
+ }
283
+ }
284
+
187
285
  orchestrate_chat(
188
286
  &config,
189
287
  &ChatRequest {
190
288
  message: text.into(),
191
289
  system_instruction: system_instruction.into(),
192
- chat_id: None,
290
+ chat_id,
193
291
  image_data_uri: None,
194
292
  audio_data_uri: None,
293
+ video_data_uri: None,
195
294
  document_attachment: None,
196
- workspace_path: None,
295
+ workspace_path: workspace_str,
197
296
  agent_id: None,
198
297
  },
199
298
  )
@@ -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)
@@ -96,6 +96,8 @@ pub struct ChatRequest {
96
96
  #[serde(default)]
97
97
  pub audio_data_uri: Option<String>,
98
98
  #[serde(default)]
99
+ pub video_data_uri: Option<String>,
100
+ #[serde(default)]
99
101
  pub document_attachment: Option<DocumentAttachment>,
100
102
  #[serde(default)]
101
103
  pub workspace_path: Option<String>,
@@ -230,6 +232,7 @@ pub async fn send_chat(
230
232
  "ollama" => call_ollama(&client, config, request).await?,
231
233
  "anthropic" => call_anthropic(&client, config, request).await?,
232
234
  "huggingface" => call_huggingface(&client, config, request).await?,
235
+ p if p.starts_with("custom:") => call_custom_provider(&client, config, request).await?,
233
236
  other => return Err(ChatError::UnsupportedProvider(other.into())),
234
237
  };
235
238
  Ok(ChatResponse {
@@ -259,6 +262,9 @@ where
259
262
  "ollama" => stream_ollama(&client, config, request, &mut on_chunk).await?,
260
263
  "anthropic" => stream_anthropic(&client, config, request, &mut on_chunk).await?,
261
264
  "huggingface" => stream_huggingface(&client, config, request, &mut on_chunk).await?,
265
+ p if p.starts_with("custom:") => {
266
+ stream_custom_provider(&client, config, request, &mut on_chunk).await?
267
+ }
262
268
  other => return Err(ChatError::UnsupportedProvider(other.into())),
263
269
  };
264
270
  Ok(ChatResponse {
@@ -280,10 +286,12 @@ async fn call_gemini(
280
286
  let url =
281
287
  format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent");
282
288
 
289
+ let payload = gemini_chat_payload(config, request)?;
290
+
283
291
  let response: Value = client
284
292
  .post(url)
285
293
  .header("x-goog-api-key", api_key)
286
- .json(&gemini_chat_payload(config, request)?)
294
+ .json(&payload)
287
295
  .send()
288
296
  .await?
289
297
  .error_for_status()?
@@ -708,6 +716,109 @@ fn required_key(provider: &str, key: &str) -> Result<(), ChatError> {
708
716
  }
709
717
  }
710
718
 
719
+ /// Resolve which model to use for the current custom provider.
720
+ ///
721
+ /// Checks `config.extra["customModelSelections"]` first (set by the UI when the
722
+ /// user picks a specific model from the multi-model dropdown), then falls back to
723
+ /// the first model defined in the `CustomProvider`.
724
+ fn resolve_custom_model(config: &crate::MintConfig) -> String {
725
+ let provider_id = config
726
+ .ai_provider
727
+ .strip_prefix("custom:")
728
+ .unwrap_or(&config.ai_provider);
729
+
730
+ // Check if a per-provider model selection was persisted in extra config.
731
+ if let Some(selections) = config
732
+ .extra
733
+ .get("customModelSelections")
734
+ .and_then(|v| v.as_object())
735
+ && let Some(model) = selections.get(provider_id).and_then(|v| v.as_str())
736
+ && !model.is_empty()
737
+ {
738
+ return model.to_owned();
739
+ }
740
+
741
+ // Fall back to the first model in the provider definition.
742
+ config
743
+ .resolve_custom_provider(&config.ai_provider)
744
+ .and_then(|cp| cp.models.first())
745
+ .map(|m| m.model_id.clone())
746
+ .unwrap_or_default()
747
+ }
748
+
749
+ async fn call_custom_provider(
750
+ client: &Client,
751
+ config: &crate::MintConfig,
752
+ request: &ChatRequest,
753
+ ) -> Result<(String, String), ChatError> {
754
+ let provider = config
755
+ .resolve_custom_provider(&config.ai_provider)
756
+ .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
757
+
758
+ let base_url = provider.base_url.trim_end_matches('/');
759
+ let model = resolve_custom_model(config);
760
+ let payload = openai_chat_payload(&model, request, false);
761
+
762
+ let mut builder = client
763
+ .post(format!("{base_url}/chat/completions"))
764
+ .json(&payload);
765
+
766
+ if !provider.api_key.is_empty() {
767
+ builder = builder.bearer_auth(&provider.api_key);
768
+ }
769
+ for h in &provider.headers {
770
+ if !h.name.is_empty() {
771
+ builder = builder.header(h.name.as_str(), h.value.as_str());
772
+ }
773
+ }
774
+
775
+ let response: serde_json::Value = builder.send().await?.error_for_status()?.json().await?;
776
+
777
+ Ok((
778
+ model,
779
+ response["choices"][0]["message"]["content"]
780
+ .as_str()
781
+ .ok_or(ChatError::MissingResponseText)?
782
+ .into(),
783
+ ))
784
+ }
785
+
786
+ async fn stream_custom_provider<F>(
787
+ client: &Client,
788
+ config: &crate::MintConfig,
789
+ request: &ChatRequest,
790
+ on_chunk: &mut F,
791
+ ) -> Result<(String, String), ChatError>
792
+ where
793
+ F: FnMut(String),
794
+ {
795
+ let provider = config
796
+ .resolve_custom_provider(&config.ai_provider)
797
+ .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
798
+
799
+ let base_url = provider.base_url.trim_end_matches('/');
800
+ let model = resolve_custom_model(config);
801
+ let payload = openai_chat_payload(&model, request, true);
802
+
803
+ let mut builder = client
804
+ .post(format!("{base_url}/chat/completions"))
805
+ .json(&payload);
806
+
807
+ if !provider.api_key.is_empty() {
808
+ builder = builder.bearer_auth(&provider.api_key);
809
+ }
810
+ for h in &provider.headers {
811
+ if !h.name.is_empty() {
812
+ builder = builder.header(h.name.as_str(), h.value.as_str());
813
+ }
814
+ }
815
+
816
+ let response = builder.send().await?.error_for_status()?;
817
+ collect_stream(response, StreamFormat::OpenAi, on_chunk)
818
+ .await
819
+ .map(|text| (model, text))
820
+ }
821
+
711
822
  fn provider_key(configured: &str, environment_variable: &str) -> String {
712
823
  if configured.trim().is_empty() {
713
824
  std::env::var(environment_variable).unwrap_or_default()
@@ -720,17 +831,33 @@ fn require_supported_attachments(provider: &str, request: &ChatRequest) -> Resul
720
831
  let has_image = request
721
832
  .image_data_uri
722
833
  .as_ref()
723
- .map_or(false, |s| !s.trim().is_empty());
834
+ .is_some_and(|s| !s.trim().is_empty());
724
835
  let has_audio = request
725
836
  .audio_data_uri
726
837
  .as_ref()
727
- .map_or(false, |s| !s.trim().is_empty());
838
+ .is_some_and(|s| !s.trim().is_empty());
839
+ let has_video = request
840
+ .video_data_uri
841
+ .as_ref()
842
+ .is_some_and(|s| !s.trim().is_empty());
728
843
 
729
844
  match provider {
730
845
  "gemini" => Ok(()),
731
- "ollama" if has_audio => Err(ChatError::UnsupportedAttachments(provider.into())),
846
+ "ollama" if has_audio || has_video => {
847
+ Err(ChatError::UnsupportedAttachments(provider.into()))
848
+ }
732
849
  "ollama" => Ok(()),
733
- _ if has_image || has_audio => Err(ChatError::UnsupportedAttachments(provider.into())),
850
+ // Custom providers are text-only for now; reject any multimodal content.
851
+ p if p.starts_with("custom:") => {
852
+ if has_image || has_audio || has_video {
853
+ Err(ChatError::UnsupportedAttachments(provider.into()))
854
+ } else {
855
+ Ok(())
856
+ }
857
+ }
858
+ _ if has_image || has_audio || has_video => {
859
+ Err(ChatError::UnsupportedAttachments(provider.into()))
860
+ }
734
861
  _ => Ok(()),
735
862
  }
736
863
  }
@@ -920,6 +1047,21 @@ fn gemini_parts(request: &ChatRequest) -> Result<Vec<Value>, ChatError> {
920
1047
  }));
921
1048
  }
922
1049
  }
1050
+ if let Some(ref video_data) = request.video_data_uri {
1051
+ for vid in video_data.split_whitespace() {
1052
+ let payload = vid
1053
+ .strip_prefix("data:")
1054
+ .and_then(|payload| payload.split_once(";base64,"))
1055
+ .filter(|(mime_type, data)| mime_type.starts_with("video/") && !data.is_empty())
1056
+ .ok_or(ChatError::InvalidAttachment)?;
1057
+ parts.push(json!({
1058
+ "inlineData": {
1059
+ "mimeType": payload.0,
1060
+ "data": payload.1
1061
+ }
1062
+ }));
1063
+ }
1064
+ }
923
1065
  Ok(parts)
924
1066
  }
925
1067
 
@@ -933,6 +1075,26 @@ mod tests {
933
1075
  assert!(matches!(error, ChatError::MissingApiKey(provider) if provider == "gemini"));
934
1076
  }
935
1077
 
1078
+ #[test]
1079
+ fn rejects_video_attachments_for_non_gemini_providers() {
1080
+ let request = ChatRequest {
1081
+ message: "describe this video".into(),
1082
+ system_instruction: String::new(),
1083
+ chat_id: None,
1084
+ image_data_uri: None,
1085
+ audio_data_uri: None,
1086
+ video_data_uri: Some("data:video/mp4;base64,AAAA".into()),
1087
+ document_attachment: None,
1088
+ workspace_path: None,
1089
+ agent_id: None,
1090
+ };
1091
+
1092
+ let error = require_supported_attachments("openai", &request).unwrap_err();
1093
+ assert!(
1094
+ matches!(error, ChatError::UnsupportedAttachments(provider) if provider == "openai")
1095
+ );
1096
+ }
1097
+
936
1098
  #[test]
937
1099
  fn parses_stream_provider_formats() {
938
1100
  assert_eq!(
@@ -965,6 +1127,7 @@ mod tests {
965
1127
  chat_id: None,
966
1128
  image_data_uri: Some("data:image/png;base64,aGk= data:image/jpeg;base64,Ynl5".into()),
967
1129
  audio_data_uri: None,
1130
+ video_data_uri: None,
968
1131
  document_attachment: None,
969
1132
  workspace_path: None,
970
1133
  agent_id: None,
@@ -6,6 +6,7 @@ use std::{
6
6
 
7
7
  use serde::{Deserialize, Serialize};
8
8
  use sha2::{Digest, Sha256};
9
+ use similar::{ChangeTag, TextDiff};
9
10
  use thiserror::Error;
10
11
 
11
12
  use crate::{Capability, MintConfig, SafetyError, assert_path_capability};
@@ -401,17 +402,34 @@ fn sha256(content: &str) -> String {
401
402
 
402
403
  fn full_file_diff(path: &Path, previous: &str, next: &str) -> String {
403
404
  let label = path.display();
404
- let mut lines = vec![
405
- format!("--- a/{label}"),
406
- format!("+++ b/{label}"),
407
- format!(
408
- "@@ -1,{} +1,{} @@",
409
- previous.lines().count(),
410
- next.lines().count()
411
- ),
412
- ];
413
- lines.extend(previous.lines().map(|line| format!("-{line}")));
414
- lines.extend(next.lines().map(|line| format!("+{line}")));
405
+ let diff = TextDiff::from_lines(previous, next);
406
+ let mut lines = vec![format!("--- a/{label}"), format!("+++ b/{label}")];
407
+
408
+ for group in diff.grouped_ops(3) {
409
+ let mut first = true;
410
+ for op in group {
411
+ if first {
412
+ let old_start = op.old_range().start + 1;
413
+ let old_len = op.old_range().len();
414
+ let new_start = op.new_range().start + 1;
415
+ let new_len = op.new_range().len();
416
+ lines.push(format!(
417
+ "@@ -{old_start},{old_len} +{new_start},{new_len} @@"
418
+ ));
419
+ first = false;
420
+ }
421
+ for change in diff.iter_changes(&op) {
422
+ let sign = match change.tag() {
423
+ ChangeTag::Delete => "-",
424
+ ChangeTag::Insert => "+",
425
+ ChangeTag::Equal => " ",
426
+ };
427
+ let content = change.value().trim_end_matches(&['\r', '\n'][..]);
428
+ lines.push(format!("{sign}{content}"));
429
+ }
430
+ }
431
+ }
432
+
415
433
  lines.join("\n")
416
434
  }
417
435
 
@@ -536,18 +554,17 @@ pub async fn fetch_github_repo_summary(owner: &str, repo: &str) -> Result<String
536
554
  .await
537
555
  .map_err(|e| e.to_string())?;
538
556
  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
- }
557
+ if contents_resp.status().is_success()
558
+ && let Ok(contents_info) = contents_resp.json::<serde_json::Value>().await
559
+ && let Some(arr) = contents_info.as_array()
560
+ {
561
+ let mut files = Vec::new();
562
+ for item in arr {
563
+ let name = item["name"].as_str().unwrap_or("");
564
+ let r#type = item["type"].as_str().unwrap_or("");
565
+ files.push(format!("- {} ({})", name, r#type));
550
566
  }
567
+ file_tree = files.join("\n");
551
568
  }
552
569
 
553
570
  // 3. Fetch README.md
@@ -558,15 +575,14 @@ pub async fn fetch_github_repo_summary(owner: &str, repo: &str) -> Result<String
558
575
  .await
559
576
  .map_err(|e| e.to_string())?;
560
577
  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
- }
578
+ if readme_resp.status().is_success()
579
+ && let Ok(readme_info) = readme_resp.json::<serde_json::Value>().await
580
+ && let Some(content_b64) = readme_info["content"].as_str()
581
+ {
582
+ let cleaned_b64 = content_b64.replace(['\n', '\r'], "");
583
+ use base64::{Engine as _, engine::general_purpose::STANDARD};
584
+ if let Ok(decoded_bytes) = STANDARD.decode(cleaned_b64) {
585
+ readme_text = String::from_utf8_lossy(&decoded_bytes).to_string();
570
586
  }
571
587
  }
572
588