@pheem49/mint 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.github/workflows/ci.yml +14 -0
  2. package/Cargo.lock +3 -3
  3. package/Cargo.toml +1 -1
  4. package/README.md +82 -21
  5. package/assets/Mint_CLI.png +0 -0
  6. package/assets/Mint_Desktop.png +0 -0
  7. package/assets/Mint_Web.png +0 -0
  8. package/assets/Mint_WorkSpace.png +0 -0
  9. package/assets/Setting.png +0 -0
  10. package/assets/bridges.svg +4 -0
  11. package/assets/chat.svg +3 -0
  12. package/assets/code.svg +4 -0
  13. package/assets/features.svg +3 -0
  14. package/assets/imagegen.svg +5 -0
  15. package/assets/live2d.svg +6 -0
  16. package/assets/memory.svg +5 -0
  17. package/assets/screencapture.svg +4 -0
  18. package/assets/setup.svg +3 -0
  19. package/assets/tools.svg +4 -0
  20. package/bin/mint +0 -0
  21. package/crates/mint-cli/src/agent.rs +6 -2
  22. package/crates/mint-cli/src/main.rs +145 -5
  23. package/crates/mint-cli/src/onboard.rs +134 -1
  24. package/crates/mint-core/src/api_server.rs +108 -3
  25. package/crates/mint-core/src/config.rs +20 -1
  26. package/crates/mint-core/src/image_gen.rs +895 -0
  27. package/crates/mint-core/src/lib.rs +4 -0
  28. package/package.json +3 -3
  29. package/src/bin/index.js +0 -0
  30. package/src/renderer/index-web.html +1 -1
  31. package/src/renderer/index.html +1 -1
  32. package/src/renderer/src/components/ChatPanel.tsx +252 -26
  33. package/src/renderer/src/components/DashboardSidebar.tsx +11 -3
  34. package/src/renderer/src/components/ImageStudioPanel.tsx +638 -0
  35. package/src/renderer/src/components/MintDashboard.tsx +14 -5
  36. package/src/renderer/src/components/SettingsWindow.tsx +29 -20
  37. package/src/renderer/src/css/image-studio.css +722 -0
  38. package/src/renderer/src/css/settings.css +13 -3
  39. package/src/renderer/src/css/styles.css +50 -26
  40. package/src/renderer/src/index.css +106 -0
  41. package/src/renderer/src/tauri.ts +123 -0
  42. package/src/renderer/src-web/components/ChatPanel.tsx +273 -25
  43. package/src/renderer/src-web/components/DashboardSidebar.tsx +11 -3
  44. package/src/renderer/src-web/components/ImageStudioPanel.tsx +638 -0
  45. package/src/renderer/src-web/components/MintDashboard.tsx +13 -4
  46. package/src/renderer/src-web/components/SettingsWindow.tsx +29 -20
  47. package/src/renderer/src-web/css/image-studio.css +722 -0
  48. package/src/renderer/src-web/css/settings.css +14 -4
  49. package/src/renderer/src-web/css/styles.css +50 -26
  50. package/src/renderer/src-web/index.css +106 -0
  51. package/src/renderer/src-web/tauri.ts +124 -0
  52. package/src-tauri/Cargo.toml +29 -0
  53. package/src-tauri/build.rs +3 -0
  54. package/src-tauri/capabilities/main.json +7 -0
  55. package/src-tauri/gen/schemas/acl-manifests.json +1 -0
  56. package/src-tauri/gen/schemas/capabilities.json +1 -0
  57. package/src-tauri/gen/schemas/desktop-schema.json +2412 -0
  58. package/src-tauri/gen/schemas/linux-schema.json +2412 -0
  59. package/src-tauri/src/browser.rs +141 -0
  60. package/src-tauri/src/desktop.rs +392 -0
  61. package/src-tauri/src/discord_rpc.rs +99 -0
  62. package/src-tauri/src/events.rs +70 -0
  63. package/src-tauri/src/headless.rs +222 -0
  64. package/src-tauri/src/integrations.rs +126 -0
  65. package/src-tauri/src/lib.rs +1076 -0
  66. package/src-tauri/src/main.rs +3 -0
  67. package/src-tauri/src/plugins.rs +16 -0
  68. package/src-tauri/src/proactive.rs +254 -0
  69. package/src-tauri/src/system.rs +250 -0
  70. package/src-tauri/src/updater.rs +148 -0
  71. package/src-tauri/src/webhooks.rs +255 -0
  72. package/src-tauri/src/workflows.rs +70 -0
  73. package/src-tauri/tauri.conf.json +48 -0
  74. package/tsconfig.json +1 -1
@@ -0,0 +1,1076 @@
1
+ mod browser;
2
+ mod desktop;
3
+ mod discord_rpc;
4
+ mod events;
5
+ mod headless;
6
+ mod integrations;
7
+ mod plugins;
8
+ mod proactive;
9
+ mod system;
10
+ mod updater;
11
+ mod webhooks;
12
+ mod workflows;
13
+
14
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
15
+ use browser::{
16
+ BrowserTab, click as browser_click, list_tabs as browser_list_tabs,
17
+ navigate as browser_navigate, read_page_text,
18
+ };
19
+
20
+ use desktop::{
21
+ ActionResult, CaptureRect, DesktopAction, capture_screen, close_window, emit_to_main,
22
+ execute_action, hide_window, integration_status, open_desktop_window, position_widget,
23
+ resize_window, translate_screen_region,
24
+ };
25
+ use events::start_system_events;
26
+ use headless::{run_next_task, start_headless_queue};
27
+ use std::collections::HashMap;
28
+ use std::sync::Mutex;
29
+ use std::sync::atomic::{AtomicU64, Ordering};
30
+ use tokio::sync::oneshot;
31
+
32
+ use integrations::{channel_inventory, list_plugins};
33
+ use mint_core::{
34
+ AgentApproval, AgentProgress, AppliedCodeEdit, ApprovalOutcome, ChatRequest, ChatResponse,
35
+ ChatSession, CodeEdit, CodeEditProposal, ImageGenRequest, InteractionMemory, MemoryStore,
36
+ MintConfig, PictureEntry, TtsUrl, WeatherReport, apply_code_edits, classify_shell_command,
37
+ config_path, extract_document_text, google_tts_urls, list_saved_pictures, load_config,
38
+ load_workflows, orchestrate_agent_loop, orchestrate_chat_stream_with_fallback,
39
+ orchestrate_chat_with_fallback, propose_code_edits, save_chat_images, save_config,
40
+ start_channels, weather, workflows_path,
41
+ };
42
+ use plugins::execute_plugin;
43
+
44
+ pub struct ApprovalsState {
45
+ pub pending: Mutex<HashMap<String, oneshot::Sender<bool>>>,
46
+ }
47
+
48
+ static COUNTER: AtomicU64 = AtomicU64::new(1);
49
+ use proactive::{
50
+ record_behavior, set_enabled as set_proactive_enabled, start_loop as start_proactive_loop,
51
+ };
52
+ use serde::Serialize;
53
+ use serde_json::Value;
54
+ use std::fs;
55
+ use std::path::{Path, PathBuf};
56
+ use std::process::{Command, Stdio};
57
+ use system::{SmartContext, smart_context};
58
+ use tauri::{
59
+ AppHandle, Emitter, Manager,
60
+ ipc::Channel,
61
+ menu::{Menu, MenuItem},
62
+ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
63
+ };
64
+ use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
65
+ use updater::{
66
+ AvailableUpdate, UpdateChannelStatus, check as check_update, install as install_update,
67
+ status as updater_status,
68
+ };
69
+ use webhooks::start_webhooks;
70
+ use workflows::start_monitor;
71
+
72
+ #[derive(Debug, Serialize)]
73
+ #[serde(rename_all = "camelCase")]
74
+ struct RuntimeStatus {
75
+ backend: &'static str,
76
+ config_path: String,
77
+ active_provider: String,
78
+ available_providers: Vec<String>,
79
+ integrations: Value,
80
+ }
81
+
82
+ #[derive(Debug, Serialize)]
83
+ #[serde(rename_all = "camelCase")]
84
+ struct WorkspaceTreeEntry {
85
+ name: String,
86
+ path: String,
87
+ kind: &'static str,
88
+ children: Vec<WorkspaceTreeEntry>,
89
+ }
90
+
91
+ #[derive(Debug, Serialize)]
92
+ #[serde(tag = "type", rename_all = "camelCase")]
93
+ enum DesktopStreamEvent {
94
+ Chunk { chunk: String },
95
+ Progress { progress: AgentProgress },
96
+ }
97
+
98
+ const MAX_DOCUMENT_BYTES: usize = 10 * 1024 * 1024;
99
+ const MAX_DOCUMENT_CONTEXT_CHARS: usize = 30_000;
100
+ const WORKSPACE_TREE_MAX_DEPTH: usize = 9;
101
+ const WORKSPACE_TREE_MAX_CHILDREN: usize = 400;
102
+ const WORKSPACE_TREE_COLLAPSED_DIRS: &[&str] = &[
103
+ ".antigravitycli",
104
+ ".cargo_home",
105
+ ".git",
106
+ ".rustup",
107
+ ".rustup_copy",
108
+ ".rustup_home",
109
+ "build",
110
+ "coverage",
111
+ "dist",
112
+ "node_modules",
113
+ "out",
114
+ "target",
115
+ ];
116
+
117
+ fn request_with_document_context(
118
+ config: &MintConfig,
119
+ request: &ChatRequest,
120
+ ) -> Result<ChatRequest, String> {
121
+ let Some(document) = &request.document_attachment else {
122
+ return Ok(request.clone());
123
+ };
124
+
125
+ let (mime_type, encoded) = document
126
+ .data_uri
127
+ .strip_prefix("data:")
128
+ .and_then(|payload| payload.split_once(";base64,"))
129
+ .ok_or_else(|| "invalid PDF attachment data URI".to_string())?;
130
+
131
+ if !mime_type.eq_ignore_ascii_case("application/pdf") {
132
+ return Err("only PDF document attachments are supported".into());
133
+ }
134
+
135
+ let bytes = BASE64_STANDARD
136
+ .decode(encoded)
137
+ .map_err(|error| format!("invalid PDF attachment encoding: {error}"))?;
138
+ if bytes.len() > MAX_DOCUMENT_BYTES {
139
+ return Err("PDF attachment is too large (> 10 MiB)".into());
140
+ }
141
+
142
+ let directory = config_path()
143
+ .map_err(|error| error.to_string())?
144
+ .with_file_name("Attachments");
145
+ fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
146
+ let path = directory.join(format!(
147
+ "mint-pdf-{}-{}.pdf",
148
+ COUNTER.fetch_add(1, Ordering::SeqCst),
149
+ sanitize_attachment_name(&document.filename)
150
+ ));
151
+ fs::write(&path, bytes).map_err(|error| error.to_string())?;
152
+
153
+ let extracted = extract_document_text(&path, config).map_err(|error| error.to_string());
154
+ let _ = fs::remove_file(&path);
155
+ let extracted = extracted?;
156
+ let context = truncate_document_context(&extracted);
157
+ let filename = document.filename.trim();
158
+ let filename = if filename.is_empty() {
159
+ "attached.pdf"
160
+ } else {
161
+ filename
162
+ };
163
+
164
+ let mut enriched = request.clone();
165
+ enriched.document_attachment = None;
166
+ enriched.message = format!(
167
+ "{}\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 ---",
168
+ request.message
169
+ );
170
+ Ok(enriched)
171
+ }
172
+
173
+ fn truncate_document_context(text: &str) -> String {
174
+ let mut output: String = text.chars().take(MAX_DOCUMENT_CONTEXT_CHARS).collect();
175
+ if text.chars().count() > MAX_DOCUMENT_CONTEXT_CHARS {
176
+ output.push_str("\n\n[PDF text truncated because it is long.]");
177
+ }
178
+ output
179
+ }
180
+
181
+ fn sanitize_attachment_name(filename: &str) -> String {
182
+ let sanitized: String = filename
183
+ .chars()
184
+ .filter(|character| {
185
+ character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
186
+ })
187
+ .take(80)
188
+ .collect();
189
+ if sanitized.is_empty() {
190
+ "document".into()
191
+ } else {
192
+ sanitized
193
+ }
194
+ }
195
+
196
+ #[tauri::command]
197
+ fn get_runtime_status() -> Result<RuntimeStatus, String> {
198
+ let config = load_config().map_err(|error| error.to_string())?;
199
+ Ok(RuntimeStatus {
200
+ backend: "rust",
201
+ config_path: config_path()
202
+ .map_err(|error| error.to_string())?
203
+ .display()
204
+ .to_string(),
205
+ active_provider: config.ai_provider.clone(),
206
+ available_providers: config
207
+ .available_providers()
208
+ .into_iter()
209
+ .map(str::to_owned)
210
+ .collect(),
211
+ integrations: integration_status(&config),
212
+ })
213
+ }
214
+
215
+ #[tauri::command]
216
+ async fn get_workspace_tree(path: Option<String>) -> Result<WorkspaceTreeEntry, String> {
217
+ tokio::task::spawn_blocking(move || build_workspace_tree(path))
218
+ .await
219
+ .map_err(|error| format!("workspace tree task failed: {error}"))?
220
+ }
221
+
222
+ fn build_workspace_tree(path: Option<String>) -> Result<WorkspaceTreeEntry, String> {
223
+ let root = workspace_root(path.as_deref())?;
224
+ let name = root
225
+ .file_name()
226
+ .map(|name| name.to_string_lossy().to_string())
227
+ .unwrap_or_else(|| root.display().to_string());
228
+ Ok(WorkspaceTreeEntry {
229
+ name,
230
+ path: root.display().to_string(),
231
+ kind: "directory",
232
+ children: workspace_children(&root, &root, 0)?,
233
+ })
234
+ }
235
+
236
+ #[tauri::command]
237
+ async fn select_workspace_directory() -> Result<Option<String>, String> {
238
+ tokio::task::spawn_blocking(select_workspace_directory_blocking)
239
+ .await
240
+ .map_err(|error| format!("workspace picker task failed: {error}"))?
241
+ }
242
+
243
+ fn select_workspace_directory_blocking() -> Result<Option<String>, String> {
244
+ for (program, args) in [
245
+ (
246
+ "zenity",
247
+ vec!["--file-selection", "--directory", "--title=Select Project"],
248
+ ),
249
+ ("kdialog", vec!["--getexistingdirectory", "."]),
250
+ ] {
251
+ let Ok(output) = Command::new(program)
252
+ .args(args)
253
+ .stdout(Stdio::piped())
254
+ .stderr(Stdio::null())
255
+ .output()
256
+ else {
257
+ continue;
258
+ };
259
+ if !output.status.success() {
260
+ return Ok(None);
261
+ }
262
+ let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
263
+ if selected.is_empty() {
264
+ return Ok(None);
265
+ }
266
+ return Ok(Some(workspace_root(Some(&selected))?.display().to_string()));
267
+ }
268
+ Ok(None)
269
+ }
270
+
271
+ fn workspace_root(path: Option<&str>) -> Result<PathBuf, String> {
272
+ let root = match path.map(str::trim).filter(|path| !path.is_empty()) {
273
+ Some(path) => PathBuf::from(path),
274
+ None => std::env::current_dir().map_err(|error| error.to_string())?,
275
+ };
276
+ let root = root.canonicalize().map_err(|error| error.to_string())?;
277
+ if !root.is_dir() {
278
+ return Err(format!("workspace is not a directory: {}", root.display()));
279
+ }
280
+ Ok(root)
281
+ }
282
+
283
+ fn workspace_children(
284
+ root: &Path,
285
+ directory: &Path,
286
+ depth: usize,
287
+ ) -> Result<Vec<WorkspaceTreeEntry>, String> {
288
+ if depth >= WORKSPACE_TREE_MAX_DEPTH {
289
+ return Ok(Vec::new());
290
+ }
291
+
292
+ let mut entries = fs::read_dir(directory)
293
+ .map_err(|error| error.to_string())?
294
+ .flatten()
295
+ .filter_map(|entry| {
296
+ let file_type = entry.file_type().ok()?;
297
+ if file_type.is_symlink() {
298
+ return None;
299
+ }
300
+ let name = entry.file_name().to_string_lossy().to_string();
301
+ Some((name, entry.path(), file_type.is_dir()))
302
+ })
303
+ .collect::<Vec<_>>();
304
+
305
+ entries.sort_by(|left, right| right.2.cmp(&left.2).then_with(|| left.0.cmp(&right.0)));
306
+ entries.truncate(WORKSPACE_TREE_MAX_CHILDREN);
307
+
308
+ entries
309
+ .into_iter()
310
+ .map(|(name, path, is_dir)| {
311
+ let relative = path
312
+ .strip_prefix(root)
313
+ .unwrap_or(&path)
314
+ .to_string_lossy()
315
+ .to_string();
316
+ let children = if is_dir && !WORKSPACE_TREE_COLLAPSED_DIRS.contains(&name.as_str()) {
317
+ workspace_children(root, &path, depth + 1)?
318
+ } else {
319
+ Vec::new()
320
+ };
321
+ Ok(WorkspaceTreeEntry {
322
+ name,
323
+ path: relative,
324
+ kind: if is_dir { "directory" } else { "file" },
325
+ children,
326
+ })
327
+ })
328
+ .collect()
329
+ }
330
+
331
+ #[tauri::command]
332
+ fn get_config() -> Result<MintConfig, String> {
333
+ load_config().map_err(|error| error.to_string())
334
+ }
335
+
336
+ #[tauri::command]
337
+ fn get_updater_status() -> Result<UpdateChannelStatus, String> {
338
+ Ok(updater_status(
339
+ &load_config().map_err(|error| error.to_string())?,
340
+ ))
341
+ }
342
+
343
+ #[tauri::command]
344
+ async fn check_for_updates(app: AppHandle) -> Result<AvailableUpdate, String> {
345
+ check_update(&app, &load_config().map_err(|error| error.to_string())?).await
346
+ }
347
+
348
+ #[tauri::command]
349
+ async fn install_available_update(app: AppHandle, approved: bool) -> Result<String, String> {
350
+ install_update(
351
+ &app,
352
+ &load_config().map_err(|error| error.to_string())?,
353
+ approved,
354
+ )
355
+ .await
356
+ }
357
+
358
+ #[tauri::command]
359
+ fn update_config(app: AppHandle, config: MintConfig) -> Result<(), String> {
360
+ save_config(&config).map_err(|error| error.to_string())?;
361
+ let _ = app.emit("settings-changed", &config);
362
+ if config.show_desktop_widget {
363
+ let _ = open_desktop_window(&app, "widget");
364
+ position_widget(&app);
365
+ } else if app.get_webview_window("widget").is_some() {
366
+ let _ = close_window(&app, "widget");
367
+ }
368
+ Ok(())
369
+ }
370
+
371
+ #[tauri::command]
372
+ fn inspect_shell_command(command: String) -> mint_core::ShellClassification {
373
+ classify_shell_command(&command)
374
+ }
375
+
376
+ #[tauri::command]
377
+ async fn send_chat_message(app: AppHandle, request: ChatRequest) -> Result<ChatResponse, String> {
378
+ let config = load_config().map_err(|error| error.to_string())?;
379
+ let request = request_with_document_context(&config, &request)?;
380
+
381
+ if request.message.starts_with("/chat ") {
382
+ let mut clean_request = request.clone();
383
+ clean_request.message = request.message.strip_prefix("/chat ").unwrap().to_owned();
384
+
385
+ let (response, _) = orchestrate_chat_with_fallback(&config, &clean_request)
386
+ .await
387
+ .map_err(|error| error.to_string())?;
388
+ return Ok(response);
389
+ }
390
+
391
+ let root = workspace_root(request.workspace_path.as_deref())?;
392
+ let fast_mode = config
393
+ .extra
394
+ .get("enableFastMode")
395
+ .and_then(Value::as_bool)
396
+ .unwrap_or(false);
397
+
398
+ let app_clone = app.clone();
399
+ let approve_cb = move |approval: &AgentApproval| -> Result<ApprovalOutcome, String> {
400
+ let (tx, rx) = oneshot::channel();
401
+ let token = format!("tok-{}", COUNTER.fetch_add(1, Ordering::SeqCst));
402
+
403
+ let state = app_clone.state::<ApprovalsState>();
404
+ state.pending.lock().unwrap().insert(token.clone(), tx);
405
+
406
+ app_clone
407
+ .emit(
408
+ "tool-approval-requested",
409
+ serde_json::json!({
410
+ "token": token,
411
+ "approval": approval
412
+ }),
413
+ )
414
+ .map_err(|e| e.to_string())?;
415
+
416
+ let approved =
417
+ tokio::task::block_in_place(move || tokio::runtime::Handle::current().block_on(rx))
418
+ .unwrap_or(false);
419
+
420
+ if approved {
421
+ Ok(ApprovalOutcome::Approved)
422
+ } else {
423
+ Ok(ApprovalOutcome::Denied)
424
+ }
425
+ };
426
+
427
+ let progress_cb = |_| {};
428
+ let on_chunk = |_| {};
429
+
430
+ let res = orchestrate_agent_loop(
431
+ &config,
432
+ &request.message,
433
+ &root,
434
+ request.image_data_uri.clone(),
435
+ request.chat_id.as_deref(),
436
+ fast_mode,
437
+ approve_cb,
438
+ progress_cb,
439
+ on_chunk,
440
+ )
441
+ .await
442
+ .map_err(|e| e.to_string())?;
443
+
444
+ Ok(ChatResponse {
445
+ provider: res.provider,
446
+ model: res.model,
447
+ text: res.summary,
448
+ fallback_provider: res.fallback,
449
+ })
450
+ }
451
+
452
+ #[tauri::command]
453
+ async fn stream_chat_message(
454
+ app: AppHandle,
455
+ request: ChatRequest,
456
+ on_event: Channel<DesktopStreamEvent>,
457
+ ) -> Result<ChatResponse, String> {
458
+ let config = load_config().map_err(|error| error.to_string())?;
459
+ let request = request_with_document_context(&config, &request)?;
460
+
461
+ if request.message.starts_with("/chat ") {
462
+ let mut clean_request = request.clone();
463
+ clean_request.message = request.message.strip_prefix("/chat ").unwrap().to_owned();
464
+
465
+ let (response, _) =
466
+ orchestrate_chat_stream_with_fallback(&config, &clean_request, |chunk| {
467
+ let _ = on_event.send(DesktopStreamEvent::Chunk { chunk });
468
+ })
469
+ .await
470
+ .map_err(|error| error.to_string())?;
471
+ return Ok(response);
472
+ }
473
+
474
+ let root = workspace_root(request.workspace_path.as_deref())?;
475
+ let fast_mode = config
476
+ .extra
477
+ .get("enableFastMode")
478
+ .and_then(Value::as_bool)
479
+ .unwrap_or(false);
480
+
481
+ let app_clone = app.clone();
482
+ let approve_cb = move |approval: &AgentApproval| -> Result<ApprovalOutcome, String> {
483
+ let (tx, rx) = oneshot::channel();
484
+ let token = format!("tok-{}", COUNTER.fetch_add(1, Ordering::SeqCst));
485
+
486
+ let state = app_clone.state::<ApprovalsState>();
487
+ state.pending.lock().unwrap().insert(token.clone(), tx);
488
+
489
+ app_clone
490
+ .emit(
491
+ "tool-approval-requested",
492
+ serde_json::json!({
493
+ "token": token,
494
+ "approval": approval
495
+ }),
496
+ )
497
+ .map_err(|e| e.to_string())?;
498
+
499
+ let approved =
500
+ tokio::task::block_in_place(move || tokio::runtime::Handle::current().block_on(rx))
501
+ .unwrap_or(false);
502
+
503
+ if approved {
504
+ Ok(ApprovalOutcome::Approved)
505
+ } else {
506
+ Ok(ApprovalOutcome::Denied)
507
+ }
508
+ };
509
+
510
+ let on_progress_event = on_event.clone();
511
+ let progress_cb = move |progress| {
512
+ let _ = on_progress_event.send(DesktopStreamEvent::Progress { progress });
513
+ };
514
+
515
+ let on_event_clone = on_event.clone();
516
+ let on_chunk = move |summary: String| {
517
+ let chars: Vec<char> = summary.chars().collect();
518
+ let mut i = 0;
519
+ while i < chars.len() {
520
+ let end = (i + 4).min(chars.len());
521
+ let chunk: String = chars[i..end].iter().collect();
522
+ let _ = on_event_clone.send(DesktopStreamEvent::Chunk { chunk });
523
+ i = end;
524
+ std::thread::sleep(std::time::Duration::from_millis(15));
525
+ }
526
+ };
527
+
528
+ let res = orchestrate_agent_loop(
529
+ &config,
530
+ &request.message,
531
+ &root,
532
+ request.image_data_uri.clone(),
533
+ request.chat_id.as_deref(),
534
+ fast_mode,
535
+ approve_cb,
536
+ progress_cb,
537
+ on_chunk,
538
+ )
539
+ .await
540
+ .map_err(|e| e.to_string())?;
541
+
542
+ Ok(ChatResponse {
543
+ provider: res.provider,
544
+ model: res.model,
545
+ text: res.summary,
546
+ fallback_provider: res.fallback,
547
+ })
548
+ }
549
+
550
+ #[tauri::command]
551
+ fn get_recent_interactions(
552
+ limit: Option<usize>,
553
+ chat_id: Option<String>,
554
+ ) -> Result<Vec<InteractionMemory>, String> {
555
+ MemoryStore::open_default()
556
+ .and_then(|memory| {
557
+ memory.recent_interactions_for_chat(
558
+ chat_id
559
+ .as_deref()
560
+ .unwrap_or(mint_core::DEFAULT_CONVERSATION_ID),
561
+ limit.unwrap_or(5),
562
+ )
563
+ })
564
+ .map_err(|error| error.to_string())
565
+ }
566
+
567
+ #[tauri::command]
568
+ fn list_chat_sessions() -> Result<Vec<ChatSession>, String> {
569
+ MemoryStore::open_default()
570
+ .and_then(|memory| memory.list_chat_sessions())
571
+ .map_err(|error| error.to_string())
572
+ }
573
+
574
+ #[tauri::command]
575
+ fn delete_chat_session(chat_id: String) -> Result<usize, String> {
576
+ MemoryStore::open_default()
577
+ .and_then(|memory| memory.delete_chat_session(&chat_id))
578
+ .map_err(|error| error.to_string())
579
+ }
580
+
581
+ #[tauri::command]
582
+ fn rename_chat_session(chat_id: String, new_title: String) -> Result<usize, String> {
583
+ MemoryStore::open_default()
584
+ .and_then(|memory| memory.rename_chat_session(&chat_id, &new_title))
585
+ .map_err(|error| error.to_string())
586
+ }
587
+
588
+ #[tauri::command]
589
+ fn get_profile_value(key: String) -> Result<Option<String>, String> {
590
+ MemoryStore::open_default()
591
+ .and_then(|memory| memory.get_profile(&key))
592
+ .map_err(|error| error.to_string())
593
+ }
594
+
595
+ #[tauri::command]
596
+ fn set_profile_value(key: String, value: String) -> Result<(), String> {
597
+ MemoryStore::open_default()
598
+ .and_then(|memory| memory.set_profile(&key, &value))
599
+ .map_err(|error| error.to_string())
600
+ }
601
+
602
+ #[tauri::command]
603
+ fn clear_chat_history(chat_id: Option<String>) -> Result<usize, String> {
604
+ MemoryStore::open_default()
605
+ .and_then(|memory| {
606
+ memory.clear_interactions_for_chat(
607
+ chat_id
608
+ .as_deref()
609
+ .unwrap_or(mint_core::DEFAULT_CONVERSATION_ID),
610
+ )
611
+ })
612
+ .map_err(|error| error.to_string())
613
+ }
614
+
615
+ #[tauri::command]
616
+ async fn submit_tool_approval(
617
+ state: tauri::State<'_, ApprovalsState>,
618
+ token: String,
619
+ approved: bool,
620
+ ) -> Result<(), String> {
621
+ let mut pending = state.pending.lock().map_err(|error| error.to_string())?;
622
+ if let Some(tx) = pending.remove(&token) {
623
+ let _ = tx.send(approved);
624
+ Ok(())
625
+ } else {
626
+ Err("No pending approval found for this token".into())
627
+ }
628
+ }
629
+
630
+ #[derive(serde::Serialize)]
631
+ #[serde(rename_all = "camelCase")]
632
+ struct DesktopImageGenResponse {
633
+ images: Vec<PictureEntry>,
634
+ model: String,
635
+ provider: String,
636
+ prompt: String,
637
+ description: Option<String>,
638
+ }
639
+
640
+ #[tauri::command]
641
+ async fn generate_images(request: ImageGenRequest) -> Result<DesktopImageGenResponse, String> {
642
+ let config = load_config().map_err(|error| error.to_string())?;
643
+
644
+ // Call core generate_images logic
645
+ let result = mint_core::generate_images(&config, &request)
646
+ .await
647
+ .map_err(|error| error.to_string())?;
648
+
649
+ // Save images to Pictures library (just like api_server does)
650
+ let data_uris: Vec<String> = result
651
+ .images
652
+ .iter()
653
+ .map(|img| img.data_uri.clone())
654
+ .collect();
655
+ let saved = save_chat_images(
656
+ data_uris,
657
+ Some("nanobanana".into()),
658
+ Some(request.prompt.clone()),
659
+ )
660
+ .map_err(|error| error.to_string())?;
661
+
662
+ Ok(DesktopImageGenResponse {
663
+ images: saved,
664
+ model: result.model,
665
+ provider: result.provider,
666
+ prompt: result.prompt,
667
+ description: result.description,
668
+ })
669
+ }
670
+
671
+ #[tauri::command]
672
+ fn list_pictures() -> Result<Vec<PictureEntry>, String> {
673
+ list_saved_pictures().map_err(|error| error.to_string())
674
+ }
675
+
676
+ #[tauri::command]
677
+ fn save_pictures(
678
+ images: Vec<String>,
679
+ source: Option<String>,
680
+ message: Option<String>,
681
+ ) -> Result<Vec<PictureEntry>, String> {
682
+ save_chat_images(images, source, message).map_err(|error| error.to_string())
683
+ }
684
+
685
+ #[tauri::command]
686
+ fn open_folder(path: String) -> Result<ActionResult, String> {
687
+ let target_path = PathBuf::from(path.trim());
688
+ if target_path.as_os_str().is_empty() {
689
+ return Err("folder path is required".into());
690
+ }
691
+
692
+ let folder = if target_path.is_dir() {
693
+ target_path
694
+ } else {
695
+ target_path
696
+ .parent()
697
+ .map(PathBuf::from)
698
+ .ok_or_else(|| "could not resolve containing folder".to_string())?
699
+ };
700
+
701
+ Command::new("xdg-open")
702
+ .arg(&folder)
703
+ .spawn()
704
+ .map_err(|error| error.to_string())?;
705
+ Ok(ActionResult {
706
+ success: true,
707
+ message: format!("opened {}", folder.display()),
708
+ })
709
+ }
710
+
711
+ #[tauri::command]
712
+ fn get_tts_urls(text: String) -> Result<Vec<TtsUrl>, String> {
713
+ let language = load_config().map_err(|error| error.to_string())?.language;
714
+ Ok(google_tts_urls(&text, &language))
715
+ }
716
+
717
+ #[tauri::command]
718
+ async fn get_weather(city: String) -> Result<WeatherReport, String> {
719
+ weather(&city).await.map_err(|error| error.to_string())
720
+ }
721
+
722
+ #[tauri::command]
723
+ fn propose_desktop_code_edits(
724
+ root: String,
725
+ edits: Vec<CodeEdit>,
726
+ ) -> Result<CodeEditProposal, String> {
727
+ propose_code_edits(
728
+ std::path::Path::new(&root),
729
+ &edits,
730
+ &load_config().map_err(|error| error.to_string())?,
731
+ )
732
+ .map_err(|error| error.to_string())
733
+ }
734
+
735
+ #[tauri::command]
736
+ fn apply_desktop_code_edits(
737
+ root: String,
738
+ edits: Vec<CodeEdit>,
739
+ approval_token: String,
740
+ ) -> Result<Vec<AppliedCodeEdit>, String> {
741
+ apply_code_edits(
742
+ std::path::Path::new(&root),
743
+ &edits,
744
+ &approval_token,
745
+ &load_config().map_err(|error| error.to_string())?,
746
+ )
747
+ .map_err(|error| error.to_string())
748
+ }
749
+
750
+ #[tauri::command]
751
+ fn open_window(app: AppHandle, kind: String) -> Result<(), String> {
752
+ open_desktop_window(&app, &kind)?;
753
+ if kind == "widget" {
754
+ position_widget(&app);
755
+ }
756
+ Ok(())
757
+ }
758
+
759
+ #[tauri::command]
760
+ fn hide_desktop_window(app: AppHandle, label: String) -> Result<(), String> {
761
+ hide_window(&app, &label)
762
+ }
763
+
764
+ #[tauri::command]
765
+ fn close_desktop_window(app: AppHandle, label: String) -> Result<(), String> {
766
+ close_window(&app, &label)
767
+ }
768
+
769
+ #[tauri::command]
770
+ fn resize_desktop_window(
771
+ app: AppHandle,
772
+ label: String,
773
+ width: u32,
774
+ height: u32,
775
+ ) -> Result<(), String> {
776
+ resize_window(&app, &label, width, height)
777
+ }
778
+
779
+ #[tauri::command]
780
+ fn run_desktop_action(action: DesktopAction) -> Result<ActionResult, String> {
781
+ let config = load_config().map_err(|error| error.to_string())?;
782
+ execute_action(&config, action)
783
+ }
784
+
785
+ #[tauri::command]
786
+ fn get_integration_inventory() -> Result<Value, String> {
787
+ let config = load_config().map_err(|error| error.to_string())?;
788
+ Ok(serde_json::json!({
789
+ "mcpServers": mint_core::configured_mcp_servers(&config)
790
+ .map_err(|error| error.to_string())?
791
+ .keys()
792
+ .collect::<Vec<_>>(),
793
+ "plugins": list_plugins(&config),
794
+ "channels": channel_inventory(&config)
795
+ }))
796
+ }
797
+
798
+ #[tauri::command]
799
+ async fn run_native_plugin(name: String, instruction: String) -> Result<String, String> {
800
+ let config = load_config().map_err(|error| error.to_string())?;
801
+ execute_plugin(&config, &name, &instruction).await
802
+ }
803
+
804
+ #[tauri::command]
805
+ fn capture_silent_screen() -> Result<String, String> {
806
+ capture_screen()
807
+ }
808
+
809
+ #[tauri::command]
810
+ fn read_clipboard_image() -> Result<String, String> {
811
+ desktop::read_clipboard_image()
812
+ }
813
+
814
+ #[tauri::command]
815
+ async fn translate_capture_region(rect: CaptureRect) -> Result<String, String> {
816
+ let config = load_config().map_err(|error| error.to_string())?;
817
+ translate_screen_region(&config, rect).await
818
+ }
819
+
820
+ #[tauri::command]
821
+ async fn get_smart_context() -> SmartContext {
822
+ smart_context().await
823
+ }
824
+
825
+ #[tauri::command]
826
+ async fn get_browser_tabs() -> Result<Vec<BrowserTab>, String> {
827
+ browser_list_tabs(&load_config().map_err(|error| error.to_string())?).await
828
+ }
829
+
830
+ #[tauri::command]
831
+ async fn navigate_browser(url: String) -> Result<String, String> {
832
+ browser_navigate(&load_config().map_err(|error| error.to_string())?, &url).await
833
+ }
834
+
835
+ #[tauri::command]
836
+ async fn read_browser_page() -> Result<String, String> {
837
+ read_page_text(&load_config().map_err(|error| error.to_string())?).await
838
+ }
839
+
840
+ #[tauri::command]
841
+ async fn click_browser_selector(selector: String) -> Result<String, String> {
842
+ browser_click(
843
+ &load_config().map_err(|error| error.to_string())?,
844
+ &selector,
845
+ )
846
+ .await
847
+ }
848
+
849
+ #[tauri::command]
850
+ fn start_screen_capture(app: AppHandle) -> Result<(), String> {
851
+ open_desktop_window(&app, "screen-picker")
852
+ }
853
+
854
+ #[tauri::command]
855
+ fn submit_screen_selection(app: AppHandle, image: String) {
856
+ emit_to_main(&app, "vision-ready", image);
857
+ let _ = close_window(&app, "screen-picker");
858
+ }
859
+
860
+ #[tauri::command]
861
+ fn submit_spotlight(app: AppHandle, query: String) {
862
+ emit_to_main(&app, "spotlight-to-chat", query);
863
+ let _ = hide_window(&app, "spotlight");
864
+ }
865
+
866
+ #[tauri::command]
867
+ fn set_ai_state(app: AppHandle, state: String) {
868
+ if let Some(widget) = app.get_webview_window("widget") {
869
+ let _ = widget.emit("widget-state", state);
870
+ }
871
+ }
872
+
873
+ #[tauri::command]
874
+ fn toggle_proactive(enabled: bool) {
875
+ set_proactive_enabled(enabled);
876
+ }
877
+
878
+ #[tauri::command]
879
+ fn save_behavior_context(context: String) -> Result<(), String> {
880
+ record_behavior(&context)
881
+ }
882
+
883
+ #[tauri::command]
884
+ async fn run_next_queued_task(app: AppHandle) -> Result<Option<mint_core::Task>, String> {
885
+ run_next_task(&app).await
886
+ }
887
+
888
+ #[tauri::command]
889
+ fn exit_app(app: AppHandle) {
890
+ app.exit(0);
891
+ }
892
+
893
+ #[tauri::command]
894
+ fn open_workflows_file() -> Result<ActionResult, String> {
895
+ load_workflows().map_err(|error| error.to_string())?;
896
+ let path = workflows_path().map_err(|error| error.to_string())?;
897
+ Command::new("xdg-open")
898
+ .arg(path)
899
+ .spawn()
900
+ .map_err(|error| error.to_string())?;
901
+ Ok(ActionResult {
902
+ success: true,
903
+ message: "opened workflows file".into(),
904
+ })
905
+ }
906
+
907
+ #[tauri::command]
908
+ fn reload_custom_workflows() -> Result<Value, String> {
909
+ let workflows = load_workflows().map_err(|error| error.to_string())?;
910
+ Ok(serde_json::json!({
911
+ "success": true,
912
+ "count": workflows.len(),
913
+ "workflows": workflows
914
+ }))
915
+ }
916
+
917
+ fn install_tray(app: &AppHandle) -> tauri::Result<()> {
918
+ let show = MenuItem::with_id(app, "show", "Show Mint", true, None::<&str>)?;
919
+ let settings = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
920
+ let spotlight = MenuItem::with_id(app, "spotlight", "Spotlight", true, None::<&str>)?;
921
+ let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
922
+ let menu = Menu::with_items(app, &[&show, &settings, &spotlight, &quit])?;
923
+ let mut builder = TrayIconBuilder::new()
924
+ .tooltip("Mint AI Assistant")
925
+ .menu(&menu)
926
+ .on_menu_event(|app, event| match event.id().as_ref() {
927
+ "show" => {
928
+ let _ = app.get_webview_window("main").map(|window| window.show());
929
+ }
930
+ "settings" => {
931
+ let _ = open_desktop_window(app, "settings");
932
+ }
933
+ "spotlight" => {
934
+ let _ = open_desktop_window(app, "spotlight");
935
+ }
936
+ "quit" => app.exit(0),
937
+ _ => {}
938
+ })
939
+ .on_tray_icon_event(|tray, event| {
940
+ if matches!(
941
+ event,
942
+ TrayIconEvent::Click {
943
+ button: MouseButton::Left,
944
+ button_state: MouseButtonState::Up,
945
+ ..
946
+ }
947
+ ) {
948
+ let app = tray.app_handle();
949
+ if let Some(window) = app.get_webview_window("main") {
950
+ let _ = if window.is_visible().unwrap_or(false) {
951
+ window.hide()
952
+ } else {
953
+ window.show()
954
+ };
955
+ }
956
+ }
957
+ });
958
+ if let Some(icon) = app.default_window_icon() {
959
+ builder = builder.icon(icon.clone());
960
+ }
961
+ builder.build(app)?;
962
+ Ok(())
963
+ }
964
+
965
+ fn install_shortcuts(app: &AppHandle) -> tauri::Result<()> {
966
+ let main_shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space);
967
+ let spotlight_shortcut = Shortcut::new(Some(Modifiers::ALT), Code::Space);
968
+ let main_handler = main_shortcut.clone();
969
+ let spotlight_handler = spotlight_shortcut.clone();
970
+ app.plugin(
971
+ tauri_plugin_global_shortcut::Builder::new()
972
+ .with_handler(move |app, shortcut, event| {
973
+ if event.state() != ShortcutState::Pressed {
974
+ return;
975
+ }
976
+ if shortcut == &main_handler {
977
+ if let Some(window) = app.get_webview_window("main") {
978
+ let _ = if window.is_visible().unwrap_or(false) {
979
+ window.hide()
980
+ } else {
981
+ window.show()
982
+ };
983
+ }
984
+ } else if shortcut == &spotlight_handler {
985
+ let _ = open_desktop_window(app, "spotlight");
986
+ }
987
+ })
988
+ .build(),
989
+ )?;
990
+ let _ = app.global_shortcut().register(main_shortcut);
991
+ let _ = app.global_shortcut().register(spotlight_shortcut);
992
+ Ok(())
993
+ }
994
+
995
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
996
+ pub fn run() {
997
+ tauri::Builder::default()
998
+ .plugin(tauri_plugin_updater::Builder::new().build())
999
+ .manage(ApprovalsState {
1000
+ pending: Mutex::new(HashMap::new()),
1001
+ })
1002
+ .setup(|app| {
1003
+ install_tray(app.handle())?;
1004
+ install_shortcuts(app.handle())?;
1005
+ start_monitor(app.handle().clone());
1006
+ start_system_events(app.handle().clone());
1007
+ start_headless_queue(app.handle().clone());
1008
+ start_proactive_loop(app.handle().clone());
1009
+ start_channels();
1010
+ start_webhooks();
1011
+ if load_config()
1012
+ .map(|config| config.show_desktop_widget)
1013
+ .unwrap_or(false)
1014
+ {
1015
+ let _ = open_desktop_window(app.handle(), "widget");
1016
+ position_widget(app.handle());
1017
+ }
1018
+ Ok(())
1019
+ })
1020
+ .invoke_handler(tauri::generate_handler![
1021
+ get_runtime_status,
1022
+ get_workspace_tree,
1023
+ generate_images,
1024
+ select_workspace_directory,
1025
+ get_config,
1026
+ get_updater_status,
1027
+ check_for_updates,
1028
+ install_available_update,
1029
+ update_config,
1030
+ inspect_shell_command,
1031
+ send_chat_message,
1032
+ stream_chat_message,
1033
+ submit_tool_approval,
1034
+ get_recent_interactions,
1035
+ list_chat_sessions,
1036
+ delete_chat_session,
1037
+ rename_chat_session,
1038
+ get_profile_value,
1039
+ set_profile_value,
1040
+ clear_chat_history,
1041
+ list_pictures,
1042
+ save_pictures,
1043
+ open_folder,
1044
+ get_tts_urls,
1045
+ get_weather,
1046
+ propose_desktop_code_edits,
1047
+ apply_desktop_code_edits,
1048
+ open_window,
1049
+ hide_desktop_window,
1050
+ close_desktop_window,
1051
+ resize_desktop_window,
1052
+ run_desktop_action,
1053
+ get_integration_inventory,
1054
+ run_native_plugin,
1055
+ capture_silent_screen,
1056
+ read_clipboard_image,
1057
+ translate_capture_region,
1058
+ get_smart_context,
1059
+ get_browser_tabs,
1060
+ navigate_browser,
1061
+ read_browser_page,
1062
+ click_browser_selector,
1063
+ start_screen_capture,
1064
+ submit_screen_selection,
1065
+ submit_spotlight,
1066
+ set_ai_state,
1067
+ toggle_proactive,
1068
+ save_behavior_context,
1069
+ run_next_queued_task,
1070
+ exit_app,
1071
+ open_workflows_file,
1072
+ reload_custom_workflows
1073
+ ])
1074
+ .run(tauri::generate_context!())
1075
+ .expect("error while running Mint desktop");
1076
+ }