@pheem49/mint 1.7.0 → 1.8.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 (55) hide show
  1. package/Cargo.lock +3 -3
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/crates/mint-cli/src/agent.rs +1 -1
  5. package/crates/mint-cli/src/main.rs +47 -24
  6. package/crates/mint-cli/src/setup.rs +1 -1
  7. package/crates/mint-core/src/api_server.rs +64 -10
  8. package/crates/mint-core/src/chat.rs +79 -0
  9. package/crates/mint-core/src/lib.rs +1 -1
  10. package/crates/mint-core/src/plugins.rs +4 -1
  11. package/crates/mint-core/src/workflows.rs +8 -0
  12. package/package.json +13 -2
  13. package/src/renderer/src/App.tsx +1 -1
  14. package/src/renderer/src/components/ChatPanel.tsx +27 -14
  15. package/src/renderer/src/components/DashboardSidebar.tsx +71 -12
  16. package/src/renderer/src/components/ImageStudioPanel.tsx +7 -3
  17. package/src/renderer/src/components/MintDashboard.tsx +199 -5
  18. package/src/renderer/src/components/PicturesLibrary.tsx +1 -3
  19. package/src/renderer/src/components/Settings/AudioTab.tsx +97 -0
  20. package/src/renderer/src/components/Settings/AutomationTab.tsx +195 -0
  21. package/src/renderer/src/components/Settings/GeneralTab.tsx +950 -0
  22. package/src/renderer/src/components/Settings/MemoryTab.tsx +60 -0
  23. package/src/renderer/src/components/Settings/PluginsTab.tsx +452 -0
  24. package/src/renderer/src/components/Settings/ShortcutsTab.tsx +30 -0
  25. package/src/renderer/src/components/Settings/ThemeTab.tsx +129 -0
  26. package/src/renderer/src/components/SettingsWindow.tsx +175 -962
  27. package/src/renderer/src/components/WorkflowBuilderPanel.tsx +536 -0
  28. package/src/renderer/src/components/WorkflowsWindow.tsx +46 -0
  29. package/src/renderer/src/css/image-studio.css +9 -4
  30. package/src/renderer/src/css/settings.css +75 -6
  31. package/src/renderer/src/css/styles.css +355 -38
  32. package/src/renderer/src/css/workflows.css +542 -0
  33. package/src/renderer/src/env.d.ts +2 -0
  34. package/src/renderer/src/index.css +1 -0
  35. package/src/renderer/src/tauri.ts +97 -0
  36. package/src/renderer/src-web/components/ChatPanel.tsx +27 -14
  37. package/src/renderer/src-web/components/DashboardSidebar.tsx +17 -1
  38. package/src/renderer/src-web/components/ImageStudioPanel.tsx +7 -3
  39. package/src/renderer/src-web/components/MintDashboard.tsx +189 -0
  40. package/src/renderer/src-web/components/PicturesLibrary.tsx +1 -3
  41. package/src/renderer/src-web/components/Settings/AudioTab.tsx +97 -0
  42. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +196 -0
  43. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +948 -0
  44. package/src/renderer/src-web/components/Settings/MemoryTab.tsx +60 -0
  45. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +452 -0
  46. package/src/renderer/src-web/components/Settings/ShortcutsTab.tsx +30 -0
  47. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +129 -0
  48. package/src/renderer/src-web/components/SettingsWindow.tsx +187 -968
  49. package/src/renderer/src-web/css/image-studio.css +9 -4
  50. package/src/renderer/src-web/css/settings.css +75 -6
  51. package/src/renderer/src-web/css/styles.css +294 -38
  52. package/src/renderer/src-web/env.d.ts +2 -0
  53. package/src/renderer/src-web/tauri.ts +36 -0
  54. package/src-tauri/src/lib.rs +84 -92
  55. package/src-tauri/tauri.conf.json +1 -1
package/Cargo.lock CHANGED
@@ -2095,7 +2095,7 @@ dependencies = [
2095
2095
 
2096
2096
  [[package]]
2097
2097
  name = "mint-cli"
2098
- version = "1.6.4"
2098
+ version = "1.8.0"
2099
2099
  dependencies = [
2100
2100
  "anyhow",
2101
2101
  "base64 0.22.1",
@@ -2113,7 +2113,7 @@ dependencies = [
2113
2113
 
2114
2114
  [[package]]
2115
2115
  name = "mint-core"
2116
- version = "1.6.4"
2116
+ version = "1.8.0"
2117
2117
  dependencies = [
2118
2118
  "base64 0.22.1",
2119
2119
  "chrono",
@@ -2134,7 +2134,7 @@ dependencies = [
2134
2134
 
2135
2135
  [[package]]
2136
2136
  name = "mint-desktop"
2137
- version = "1.6.4"
2137
+ version = "1.8.0"
2138
2138
  dependencies = [
2139
2139
  "base64 0.22.1",
2140
2140
  "dirs",
package/Cargo.toml CHANGED
@@ -7,7 +7,7 @@ members = [
7
7
  resolver = "2"
8
8
 
9
9
  [workspace.package]
10
- version = "1.7.0"
10
+ version = "1.8.0"
11
11
  edition = "2024"
12
12
  license = "AGPL-3.0-only"
13
13
 
package/README.md CHANGED
@@ -95,6 +95,15 @@ Mint is a local-first AI assistant running on your machine, capable of handling
95
95
  - Telegram, Discord Gateway, Discord RPC, Slack Socket Mode, LINE, and WhatsApp
96
96
  Cloud API integrations.
97
97
  - Signed Tauri update checks with an explicit approval step before installation.
98
+ - Dynamic local Ollama model fetching in the Settings Window to query and display the actual models installed on your machine.
99
+ - Pill-styled clean horizontal system event dividers for provider and model change notifications in the chat panel.
100
+
101
+ ## What's New in v1.8.0
102
+
103
+ - **Integrated Web Search Settings**: Easily toggle between **Brave Search API** and **Google Custom Search API** directly from the General settings tab.
104
+ - **Image Generation Providers**: Choose and configure credentials for Stability AI, Ideogram, Replicate, OpenAI DALL-E, and Google NanoBanana (Gemini Images) inside dedicated inline settings cards.
105
+ - **Inline Productivity Settings**: Set up Client IDs, secrets, and API keys for native Gmail, Google Calendar, and Notion integrations inline inside the General settings tab.
106
+ - **Revamped Plugins Layout**: Moved **Learned AI Skills** (equivalent to `mint learn` in CLI) to the top of the Plugins tab, followed by MCP Servers (External tools), and Built-in Plugins (Spotify and Discord RPC) at the bottom.
98
107
 
99
108
  ## <img src="assets/setup.svg" width="24" height="24" valign="middle" /> Prerequisites
100
109
 
@@ -804,7 +804,7 @@ fn format_markdown_bold(text: &str) -> String {
804
804
  let mut leading_spaces = 0;
805
805
  let mut is_list_item = false;
806
806
  let mut marker_char = '-';
807
-
807
+
808
808
  for (idx, c) in line.char_indices() {
809
809
  if c.is_whitespace() {
810
810
  leading_spaces += c.len_utf8();
@@ -1109,7 +1109,9 @@ fn process_inline_bold(s: &str) -> String {
1109
1109
  fn format_line(line: &str) -> String {
1110
1110
  let mut formatted = line.to_string();
1111
1111
  let trimmed = line.trim_start();
1112
- if (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ")) && trimmed.len() >= 2 {
1112
+ if (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
1113
+ && trimmed.len() >= 2
1114
+ {
1113
1115
  let leading_len = line.len() - trimmed.len();
1114
1116
  let leading_spaces = &line[..leading_len];
1115
1117
  formatted = format!("{}•{}", leading_spaces, &trimmed[1..]);
@@ -1169,7 +1171,11 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1169
1171
  let mut has_separator = false;
1170
1172
  let mut separator_idx = None;
1171
1173
  for (i, row) in rows.iter().enumerate() {
1172
- if row.iter().all(|col| col.chars().all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')) && !row.is_empty() {
1174
+ if row.iter().all(|col| {
1175
+ col.chars()
1176
+ .all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
1177
+ }) && !row.is_empty()
1178
+ {
1173
1179
  has_separator = true;
1174
1180
  separator_idx = Some(i);
1175
1181
  break;
@@ -1202,7 +1208,7 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1202
1208
 
1203
1209
  let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
1204
1210
  let mut col_widths = vec![0; num_cols];
1205
-
1211
+
1206
1212
  if let Some(ref header) = header_row {
1207
1213
  for (i, col) in header.iter().enumerate() {
1208
1214
  if i < num_cols {
@@ -1220,18 +1226,19 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1220
1226
 
1221
1227
  let mut rendered = String::new();
1222
1228
 
1223
- let draw_border = |left: &str, mid: &str, right: &str, fill: &str, widths: &[usize]| -> String {
1224
- let mut s = left.to_string();
1225
- for (i, &w) in widths.iter().enumerate() {
1226
- s.push_str(&fill.repeat(w + 2));
1227
- if i < widths.len() - 1 {
1228
- s.push_str(mid);
1229
+ let draw_border =
1230
+ |left: &str, mid: &str, right: &str, fill: &str, widths: &[usize]| -> String {
1231
+ let mut s = left.to_string();
1232
+ for (i, &w) in widths.iter().enumerate() {
1233
+ s.push_str(&fill.repeat(w + 2));
1234
+ if i < widths.len() - 1 {
1235
+ s.push_str(mid);
1236
+ }
1229
1237
  }
1230
- }
1231
- s.push_str(right);
1232
- s.push('\n');
1233
- s
1234
- };
1238
+ s.push_str(right);
1239
+ s.push('\n');
1240
+ s
1241
+ };
1235
1242
 
1236
1243
  rendered.push_str(&draw_border("┌", "┬", "┐", "─", &col_widths));
1237
1244
 
@@ -1240,7 +1247,11 @@ fn render_markdown_table(table_lines: &[String]) -> String {
1240
1247
  for (i, col) in header.iter().enumerate() {
1241
1248
  let width = unicode_width(col);
1242
1249
  let padding = col_widths[i] - width;
1243
- rendered.push_str(&format!(" \x1b[1;36m{}\x1b[0m{}", col, " ".repeat(padding + 1)));
1250
+ rendered.push_str(&format!(
1251
+ " \x1b[1;36m{}\x1b[0m{}",
1252
+ col,
1253
+ " ".repeat(padding + 1)
1254
+ ));
1244
1255
  rendered.push_str("│");
1245
1256
  }
1246
1257
  rendered.push('\n');
@@ -1571,7 +1582,10 @@ async fn handle_slash_command(
1571
1582
  ("/paste [prompt]", "Attach image from clipboard"),
1572
1583
  ("Ctrl+V", "Paste clipboard image as [Image #1]"),
1573
1584
  ("/learn <path>", "Import a persistent .md or .txt skill"),
1574
- ("/plugins <name> [prompt]", "Generate a skill.md file for a plugin/skill"),
1585
+ (
1586
+ "/plugins <name> [prompt]",
1587
+ "Generate a skill.md file for a plugin/skill",
1588
+ ),
1575
1589
  ("/memory list", "Show recent interactions"),
1576
1590
  ("/memory clear", "Clear all interactions"),
1577
1591
  ("/memory get <key>", "Read a profile value"),
@@ -1965,23 +1979,25 @@ async fn handle_slash_command(
1965
1979
  println!("\n{BLUE}────────────────────────────────────────────{RESET}");
1966
1980
  println!("{MINT} Available Plugins & Skills{RESET}");
1967
1981
  println!("{BLUE}────────────────────────────────────────────{RESET}");
1968
-
1982
+
1969
1983
  println!("{MINT}Native Plugins:{RESET}");
1970
1984
  for p in native_plugins() {
1971
1985
  println!(" - {BLUE}{:<20}{RESET} {}", p.name, p.description);
1972
1986
  }
1973
-
1987
+
1974
1988
  println!("\n{MINT}Custom Skills/Plugins (Active):{RESET}");
1975
1989
  let mut active_skills = std::collections::HashSet::new();
1976
1990
  if let Ok(memory) = MemoryStore::open_default() {
1977
1991
  match memory.learned_skills(100) {
1978
1992
  Ok(skills) => {
1979
1993
  if skills.is_empty() {
1980
- println!(" {DIM}(No custom skills/plugins learned yet. Use '/learn <path>' to add one.){RESET}");
1994
+ println!(
1995
+ " {DIM}(No custom skills/plugins learned yet. Use '/learn <path>' to add one.){RESET}"
1996
+ );
1981
1997
  } else {
1982
1998
  for s in &skills {
1983
1999
  println!(" - {BLUE}{:<20}{RESET} {}", s.name, s.source_path);
1984
-
2000
+
1985
2001
  // Try to store canonicalized active path
1986
2002
  if let Ok(canon) = Path::new(&s.source_path).canonicalize() {
1987
2003
  active_skills.insert(canon);
@@ -2005,7 +2021,9 @@ async fn handle_slash_command(
2005
2021
  if path.is_file() {
2006
2022
  if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2007
2023
  if matches!(ext.to_ascii_lowercase().as_str(), "md" | "txt") {
2008
- if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
2024
+ if let Some(name) =
2025
+ path.file_name().and_then(|n| n.to_str())
2026
+ {
2009
2027
  if let Ok(canon_path) = path.canonicalize() {
2010
2028
  local_files.push((name.to_owned(), canon_path));
2011
2029
  } else {
@@ -2016,7 +2034,7 @@ async fn handle_slash_command(
2016
2034
  }
2017
2035
  }
2018
2036
  }
2019
-
2037
+
2020
2038
  let inactive_files: Vec<_> = local_files
2021
2039
  .into_iter()
2022
2040
  .filter(|(_, full_path)| !active_skills.contains(full_path))
@@ -2024,9 +2042,14 @@ async fn handle_slash_command(
2024
2042
  .collect();
2025
2043
 
2026
2044
  if !inactive_files.is_empty() {
2027
- println!("\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}");
2045
+ println!(
2046
+ "\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}"
2047
+ );
2028
2048
  for file in inactive_files {
2029
- println!(" - {WARN}{:<20}{RESET} skills/{} {DIM}(Type '/learn skills/{}' to activate){RESET}", file, file, file);
2049
+ println!(
2050
+ " - {WARN}{:<20}{RESET} skills/{} {DIM}(Type '/learn skills/{}' to activate){RESET}",
2051
+ file, file, file
2052
+ );
2030
2053
  }
2031
2054
  }
2032
2055
  }
@@ -1,7 +1,7 @@
1
1
  use anyhow::Result;
2
2
  use crossterm::event::{self, Event, KeyCode};
3
3
  use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
4
- use mint_core::{load_config, save_config, native_plugins};
4
+ use mint_core::{load_config, native_plugins, save_config};
5
5
  use std::io::{self, Write};
6
6
 
7
7
  struct ToolOption {
@@ -272,6 +272,58 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
272
272
  .await;
273
273
  }
274
274
  }
275
+ ("POST", "/api/interactions") => {
276
+ #[derive(Deserialize)]
277
+ #[serde(rename_all = "camelCase")]
278
+ struct ApiSaveInteraction {
279
+ chat_id: String,
280
+ user_text: String,
281
+ provider: String,
282
+ model: String,
283
+ }
284
+
285
+ if let Ok(req) = serde_json::from_str::<ApiSaveInteraction>(body) {
286
+ if let Ok(memory) = MemoryStore::open_default() {
287
+ match memory.add_interaction_for_chat(
288
+ &req.chat_id,
289
+ &req.user_text,
290
+ "",
291
+ &req.provider,
292
+ &req.model,
293
+ ) {
294
+ Ok(row_id) => {
295
+ let res_json = json!({ "success": true, "id": row_id });
296
+ send_json_response(socket, "200 OK", &res_json.to_string())
297
+ .await;
298
+ }
299
+ Err(error) => {
300
+ let err_json =
301
+ json!({ "success": false, "message": error.to_string() });
302
+ send_json_response(
303
+ socket,
304
+ "500 Internal Server Error",
305
+ &err_json.to_string(),
306
+ )
307
+ .await;
308
+ }
309
+ }
310
+ } else {
311
+ send_json_response(
312
+ socket,
313
+ "500 Internal Server Error",
314
+ "{\"success\":false,\"message\":\"db error\"}",
315
+ )
316
+ .await;
317
+ }
318
+ } else {
319
+ send_json_response(
320
+ socket,
321
+ "400 Bad Request",
322
+ "{\"success\":false,\"message\":\"invalid body\"}",
323
+ )
324
+ .await;
325
+ }
326
+ }
275
327
  ("POST", "/api/interactions/agent-activity") => {
276
328
  #[derive(Deserialize)]
277
329
  #[serde(rename_all = "camelCase")]
@@ -291,12 +343,8 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
291
343
  )
292
344
  .is_ok()
293
345
  {
294
- send_json_response(
295
- socket,
296
- "200 OK",
297
- "{\"status\":\"ok\"}",
298
- )
299
- .await;
346
+ send_json_response(socket, "200 OK", "{\"status\":\"ok\"}")
347
+ .await;
300
348
  return;
301
349
  }
302
350
  }
@@ -418,19 +466,22 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
418
466
  chat_id: Option<String>,
419
467
  image_data_uri: Option<String>,
420
468
  audio_data_uri: Option<String>,
469
+ document_attachment: Option<crate::chat::DocumentAttachment>,
421
470
  }
422
471
 
423
472
  if let Ok(req) = serde_json::from_str::<ApiChatRequest>(body) {
424
473
  let config = load_config().unwrap_or_default();
425
- let mut chat_req = ChatRequest {
474
+ let chat_req = ChatRequest {
426
475
  message: req.message,
427
476
  system_instruction: req.system_instruction.unwrap_or_default(),
428
477
  chat_id: req.chat_id,
429
478
  image_data_uri: req.image_data_uri,
430
479
  audio_data_uri: req.audio_data_uri,
431
- document_attachment: None,
480
+ document_attachment: req.document_attachment,
432
481
  workspace_path: None,
433
482
  };
483
+ let mut chat_req =
484
+ chat_req.with_document_context(&config).unwrap_or(chat_req);
434
485
  let sent_image = chat_req.image_data_uri.clone();
435
486
  let sent_message = chat_req.message.clone();
436
487
 
@@ -499,19 +550,22 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
499
550
  chat_id: Option<String>,
500
551
  image_data_uri: Option<String>,
501
552
  audio_data_uri: Option<String>,
553
+ document_attachment: Option<crate::chat::DocumentAttachment>,
502
554
  }
503
555
 
504
556
  if let Ok(req) = serde_json::from_str::<ApiChatRequest>(body) {
505
557
  let config = load_config().unwrap_or_default();
506
- let mut chat_req = ChatRequest {
558
+ let chat_req = ChatRequest {
507
559
  message: req.message,
508
560
  system_instruction: req.system_instruction.unwrap_or_default(),
509
561
  chat_id: req.chat_id,
510
562
  image_data_uri: req.image_data_uri,
511
563
  audio_data_uri: req.audio_data_uri,
512
- document_attachment: None,
564
+ document_attachment: req.document_attachment,
513
565
  workspace_path: None,
514
566
  };
567
+ let mut chat_req =
568
+ chat_req.with_document_context(&config).unwrap_or(chat_req);
515
569
  let sent_image = chat_req.image_data_uri.clone();
516
570
  let sent_message = chat_req.message.clone();
517
571
 
@@ -108,6 +108,85 @@ pub struct DocumentAttachment {
108
108
  pub data_uri: String,
109
109
  }
110
110
 
111
+ const MAX_DOCUMENT_BYTES: usize = 10 * 1024 * 1024;
112
+ const MAX_DOCUMENT_CONTEXT_CHARS: usize = 400_000;
113
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
114
+
115
+ impl ChatRequest {
116
+ pub fn with_document_context(&self, config: &crate::MintConfig) -> Result<ChatRequest, String> {
117
+ let Some(document) = &self.document_attachment else {
118
+ return Ok(self.clone());
119
+ };
120
+
121
+ let (mime_type, encoded) = document
122
+ .data_uri
123
+ .strip_prefix("data:")
124
+ .and_then(|payload| payload.split_once(";base64,"))
125
+ .ok_or_else(|| "invalid PDF attachment data URI".to_string())?;
126
+
127
+ if !mime_type.eq_ignore_ascii_case("application/pdf") {
128
+ return Err("only PDF document attachments are supported".into());
129
+ }
130
+
131
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
132
+ let bytes = BASE64_STANDARD
133
+ .decode(encoded)
134
+ .map_err(|error| format!("invalid PDF attachment encoding: {error}"))?;
135
+ if bytes.len() > MAX_DOCUMENT_BYTES {
136
+ return Err("PDF attachment is too large (> 10 MiB)".into());
137
+ }
138
+
139
+ let directory = crate::config::config_path()
140
+ .map_err(|error| error.to_string())?
141
+ .with_file_name("Attachments");
142
+ std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
143
+
144
+ let safe_name: String = document
145
+ .filename
146
+ .chars()
147
+ .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '.' | '-' | '_'))
148
+ .take(80)
149
+ .collect();
150
+ let safe_name = if safe_name.is_empty() {
151
+ "document".into()
152
+ } else {
153
+ safe_name
154
+ };
155
+
156
+ let path = directory.join(format!(
157
+ "mint-pdf-{}-{}.pdf",
158
+ COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
159
+ safe_name
160
+ ));
161
+ std::fs::write(&path, bytes).map_err(|error| error.to_string())?;
162
+
163
+ let extracted = crate::knowledge::extract_document_text(&path, config)
164
+ .map_err(|error| error.to_string());
165
+ let _ = std::fs::remove_file(&path);
166
+ let extracted = extracted?;
167
+
168
+ let mut context: String = extracted.chars().take(MAX_DOCUMENT_CONTEXT_CHARS).collect();
169
+ if extracted.chars().count() > MAX_DOCUMENT_CONTEXT_CHARS {
170
+ context.push_str("\n\n[PDF text truncated because it is long.]");
171
+ }
172
+
173
+ let filename = document.filename.trim();
174
+ let filename = if filename.is_empty() {
175
+ "attached.pdf"
176
+ } else {
177
+ filename
178
+ };
179
+
180
+ let mut enriched = self.clone();
181
+ enriched.document_attachment = None;
182
+ enriched.message = format!(
183
+ "{}\n\nAttached PDF: {filename}\nUse the extracted PDF text below when answering. If the user asks for a summary, summarize this document.\n\n--- Extracted PDF text ---\n{context}\n--- End extracted PDF text ---",
184
+ self.message
185
+ );
186
+ Ok(enriched)
187
+ }
188
+ }
189
+
111
190
  #[derive(Debug, Clone, Serialize)]
112
191
  #[serde(rename_all = "camelCase")]
113
192
  pub struct ChatResponse {
@@ -78,7 +78,7 @@ pub use symbols::{CodeSymbol, SymbolError, SymbolIndex, build_symbol_index};
78
78
  pub use tasks::{Task, TaskError, TaskStore, tasks_path};
79
79
  pub use tts::{TtsUrl, google_tts_urls};
80
80
  pub use weather::{WeatherError, WeatherReport, weather};
81
- pub use workflows::{WorkflowError, load_workflows, workflows_path};
81
+ pub use workflows::{WorkflowError, load_workflows, save_workflows, workflows_path};
82
82
  pub mod api_server;
83
83
  pub use api_server::start_api_server;
84
84
  pub mod channels;
@@ -242,7 +242,10 @@ fn github(instruction: &str) -> Result<String, PluginError> {
242
242
  if id.chars().all(|c| c.is_ascii_digit()) {
243
243
  run("gh", &["issue", "view", id])
244
244
  } else {
245
- invalid("github", "view_issue/issue view requires a numeric issue ID")
245
+ invalid(
246
+ "github",
247
+ "view_issue/issue view requires a numeric issue ID",
248
+ )
246
249
  }
247
250
  }
248
251
  ["list_prs"] | ["pr", "list"] => run("gh", &["pr", "list", "--limit", "10"]),
@@ -79,3 +79,11 @@ fn save_default_workflows(path: &PathBuf) -> Result<(), WorkflowError> {
79
79
  source,
80
80
  })
81
81
  }
82
+
83
+ pub fn save_workflows(workflows: &[Value]) -> Result<(), WorkflowError> {
84
+ let path = workflows_path()?;
85
+ let directory = path.parent().ok_or(WorkflowError::MissingDirectory)?;
86
+ fs::create_dir_all(directory).map_err(WorkflowError::CreateDirectory)?;
87
+ let raw = serde_json::to_string_pretty(workflows).map_err(WorkflowError::Serialize)?;
88
+ fs::write(&path, format!("{raw}\n")).map_err(|source| WorkflowError::Write { path, source })
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pheem49/mint",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "A native Tauri desktop AI assistant with a Rust backend and React UI.",
5
5
  "scripts": {
6
6
  "dev:web": "vite --config vite.config.web.ts",
@@ -27,7 +27,17 @@
27
27
  "cli": "cargo run -p mint-cli --",
28
28
  "postinstall": "cargo build -p mint-cli --release && mkdir -p bin && cp target/release/mint bin/mint"
29
29
  },
30
- "keywords": [],
30
+ "keywords": [
31
+ "mint",
32
+ "tauri",
33
+ "ai",
34
+ "assistant",
35
+ "desktop",
36
+ "cli",
37
+ "rust",
38
+ "react",
39
+ "agent"
40
+ ],
31
41
  "repository": {
32
42
  "type": "git",
33
43
  "url": "git+https://github.com/Pheem49/Mint.git"
@@ -48,6 +58,7 @@
48
58
  "dependencies": {
49
59
  "@tauri-apps/api": "^2.0.0",
50
60
  "@vitejs/plugin-react": "^6.0.1",
61
+ "@xyflow/react": "^12.11.1",
51
62
  "material-icon-theme": "5.34.0",
52
63
  "pixi-live2d-display": "^0.4.0",
53
64
  "pixi.js": "^6.5.10",
@@ -6,7 +6,6 @@ const WidgetWindow = lazy(() => import('./components/WidgetWindow'))
6
6
  const ProactiveGlow = lazy(() => import('./components/ProactiveGlow'))
7
7
  const ScreenPicker = lazy(() => import('./components/ScreenPicker'))
8
8
  const MintDashboard = lazy(() => import('./components/MintDashboard'))
9
-
10
9
  export default function App() {
11
10
  const [hash, setHash] = useState(window.location.hash || '#/')
12
11
 
@@ -29,5 +28,6 @@ export default function App() {
29
28
  if (route.startsWith('/proactive-glow')) content = <ProactiveGlow />
30
29
  if (route.startsWith('/screen-picker')) content = <ScreenPicker />
31
30
 
31
+
32
32
  return <Suspense fallback={null}>{content}</Suspense>
33
33
  }
@@ -1702,21 +1702,33 @@ export default function ChatPanel({
1702
1702
  return (
1703
1703
  <section className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}>
1704
1704
  <div className="chat-container">
1705
- {interactions.map((interaction) => (
1706
- <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1707
- {interaction.isSystemEvent ? (
1708
- <div className="system-event" style={{ background: 'rgba(16, 185, 129, 0.06)', border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', padding: '10px 14px', color: '#a7f3d0', fontSize: '0.82rem', lineHeight: '1.45', alignSelf: 'stretch' }}>
1709
- {interaction.userText}
1710
- </div>
1711
- ) : interaction.userText && (
1712
- <div className="message user-message">
1713
- <div className="bubble-wrapper">
1714
- <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1715
- <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1705
+ {interactions.map((interaction) => {
1706
+ const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
1707
+ if (isSystemEvent) {
1708
+ return (
1709
+ <div key={interaction.id} className="system-event-divider">
1710
+ <div className="system-event-line" />
1711
+ <div className="system-event-pill">
1712
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
1713
+ <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
1714
+ </svg>
1715
+ <span>{interaction.userText}</span>
1716
1716
  </div>
1717
+ <div className="system-event-line" />
1717
1718
  </div>
1718
- )}
1719
- <div className="message ai-message">
1719
+ );
1720
+ }
1721
+ return (
1722
+ <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1723
+ {interaction.userText && (
1724
+ <div className="message user-message">
1725
+ <div className="bubble-wrapper">
1726
+ <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1727
+ <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1728
+ </div>
1729
+ </div>
1730
+ )}
1731
+ <div className="message ai-message">
1720
1732
  <div className="bubble-wrapper">
1721
1733
  {renderCompletedActivity(interaction)}
1722
1734
  {renderFileChanges(interaction)}
@@ -1750,7 +1762,8 @@ export default function ChatPanel({
1750
1762
  </div>
1751
1763
  </div>
1752
1764
  </div>
1753
- ))}
1765
+ );
1766
+ })}
1754
1767
 
1755
1768
  {sending && (
1756
1769
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>