@pheem49/mint 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +1 -1
- package/Cargo.lock +34 -3
- package/Cargo.toml +2 -2
- package/README.md +40 -0
- package/Release_Note.md +95 -0
- package/assets/Mint_CLI.png +0 -0
- package/crates/mint-cli/Cargo.toml +1 -0
- package/crates/mint-cli/src/agent.rs +142 -48
- package/crates/mint-cli/src/main.rs +231 -22
- package/crates/mint-core/src/api_server.rs +71 -4
- package/crates/mint-core/src/browser.rs +401 -0
- package/crates/mint-core/src/chat.rs +79 -0
- package/crates/mint-core/src/config.rs +69 -1
- package/crates/mint-core/src/lib.rs +6 -1
- package/crates/mint-core/src/orchestration.rs +137 -1
- package/crates/mint-core/src/skills.rs +113 -4
- package/crates/mint-core/src/workflows.rs +9 -22
- package/package.json +2 -1
- package/src/renderer/src/App.tsx +1 -1
- package/src/renderer/src/components/ChatPanel.tsx +197 -47
- package/src/renderer/src/components/DashboardSidebar.tsx +71 -12
- package/src/renderer/src/components/ImageStudioPanel.tsx +18 -4
- package/src/renderer/src/components/MintDashboard.tsx +239 -7
- package/src/renderer/src/components/PicturesLibrary.tsx +1 -3
- package/src/renderer/src/components/Settings/AudioTab.tsx +97 -0
- package/src/renderer/src/components/Settings/AutomationTab.tsx +195 -0
- package/src/renderer/src/components/Settings/GeneralTab.tsx +950 -0
- package/src/renderer/src/components/Settings/MemoryTab.tsx +60 -0
- package/src/renderer/src/components/Settings/PluginsTab.tsx +632 -0
- package/src/renderer/src/components/Settings/ShortcutsTab.tsx +30 -0
- package/src/renderer/src/components/Settings/ThemeTab.tsx +129 -0
- package/src/renderer/src/components/SettingsWindow.tsx +175 -970
- package/src/renderer/src/components/WorkflowBuilderPanel.tsx +536 -0
- package/src/renderer/src/components/WorkflowsWindow.tsx +46 -0
- package/src/renderer/src/components/WorkspacePanel.tsx +66 -9
- package/src/renderer/src/css/image-studio.css +15 -4
- package/src/renderer/src/css/settings.css +75 -6
- package/src/renderer/src/css/styles.css +419 -49
- package/src/renderer/src/css/workflows.css +542 -0
- package/src/renderer/src/env.d.ts +2 -0
- package/src/renderer/src/index.css +13 -1
- package/src/renderer/src/tauri.ts +140 -2
- package/src/renderer/src-web/components/ChatPanel.tsx +190 -46
- package/src/renderer/src-web/components/DashboardSidebar.tsx +17 -1
- package/src/renderer/src-web/components/ImageStudioPanel.tsx +18 -4
- package/src/renderer/src-web/components/MintDashboard.tsx +197 -3
- package/src/renderer/src-web/components/PicturesLibrary.tsx +1 -3
- package/src/renderer/src-web/components/Settings/AudioTab.tsx +97 -0
- package/src/renderer/src-web/components/Settings/AutomationTab.tsx +196 -0
- package/src/renderer/src-web/components/Settings/GeneralTab.tsx +948 -0
- package/src/renderer/src-web/components/Settings/MemoryTab.tsx +60 -0
- package/src/renderer/src-web/components/Settings/PluginsTab.tsx +632 -0
- package/src/renderer/src-web/components/Settings/ShortcutsTab.tsx +30 -0
- package/src/renderer/src-web/components/Settings/ThemeTab.tsx +129 -0
- package/src/renderer/src-web/components/SettingsWindow.tsx +187 -976
- package/src/renderer/src-web/css/image-studio.css +15 -4
- package/src/renderer/src-web/css/settings.css +75 -6
- package/src/renderer/src-web/css/styles.css +394 -50
- package/src/renderer/src-web/env.d.ts +2 -0
- package/src/renderer/src-web/index.css +12 -1
- package/src/renderer/src-web/tauri.ts +60 -2
- package/src-tauri/src/browser.rs +1 -141
- package/src-tauri/src/headless.rs +3 -2
- package/src-tauri/src/lib.rs +195 -111
- package/src-tauri/src/workflows.rs +56 -4
- package/src-tauri/tauri.conf.json +1 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
use crate::MintConfig;
|
|
2
|
+
use futures_util::{SinkExt, StreamExt};
|
|
3
|
+
use serde::Serialize;
|
|
4
|
+
use serde_json::{Value, json};
|
|
5
|
+
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|
6
|
+
|
|
7
|
+
fn log_action(action: &str, details: &str) {
|
|
8
|
+
if let Some(config_dir) = dirs::config_dir() {
|
|
9
|
+
let log_dir = config_dir.join("mint");
|
|
10
|
+
let log_file = log_dir.join("browser-automation.log");
|
|
11
|
+
let _ = std::fs::create_dir_all(&log_dir);
|
|
12
|
+
if let Ok(mut file) = std::fs::OpenOptions::new()
|
|
13
|
+
.create(true)
|
|
14
|
+
.append(true)
|
|
15
|
+
.open(&log_file)
|
|
16
|
+
{
|
|
17
|
+
use std::io::Write;
|
|
18
|
+
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
|
19
|
+
let _ = writeln!(file, "[{}] [{}] {}", now, action, details);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Debug, Clone, Serialize)]
|
|
25
|
+
#[serde(rename_all = "camelCase")]
|
|
26
|
+
pub struct BrowserTab {
|
|
27
|
+
pub id: String,
|
|
28
|
+
pub title: String,
|
|
29
|
+
pub url: String,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub async fn list_tabs(config: &MintConfig) -> Result<Vec<BrowserTab>, String> {
|
|
33
|
+
if !is_browser_running(config).await {
|
|
34
|
+
return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
|
|
35
|
+
}
|
|
36
|
+
Ok(fetch_pages(config)
|
|
37
|
+
.await?
|
|
38
|
+
.into_iter()
|
|
39
|
+
.filter_map(|page| {
|
|
40
|
+
Some(BrowserTab {
|
|
41
|
+
id: page["id"].as_str()?.to_owned(),
|
|
42
|
+
title: page["title"].as_str().unwrap_or_default().to_owned(),
|
|
43
|
+
url: page["url"].as_str().unwrap_or_default().to_owned(),
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
.collect())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pub async fn navigate(config: &MintConfig, url: &str) -> Result<String, String> {
|
|
50
|
+
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
|
51
|
+
log_action(
|
|
52
|
+
"NAVIGATE_ERROR",
|
|
53
|
+
"Browser navigation only supports http and https URLs",
|
|
54
|
+
);
|
|
55
|
+
return Err("browser navigation only supports http and https URLs".into());
|
|
56
|
+
}
|
|
57
|
+
log_action("NAVIGATE", &format!("Navigating to {url}"));
|
|
58
|
+
ensure_page_open(config).await?;
|
|
59
|
+
match cdp_call(config, "Page.navigate", json!({ "url": url })).await {
|
|
60
|
+
Ok(response) => {
|
|
61
|
+
if response["result"]["frameId"].as_str().is_some() {
|
|
62
|
+
log_action(
|
|
63
|
+
"NAVIGATE_SUCCESS",
|
|
64
|
+
&format!("Successfully navigated to {url}"),
|
|
65
|
+
);
|
|
66
|
+
Ok(format!("navigating to {url}"))
|
|
67
|
+
} else {
|
|
68
|
+
let err = response_error(&response);
|
|
69
|
+
log_action("NAVIGATE_ERROR", &format!("Failed: {err}"));
|
|
70
|
+
Err(err)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
Err(e) => {
|
|
74
|
+
log_action("NAVIGATE_ERROR", &format!("Websocket error: {e}"));
|
|
75
|
+
Err(e)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pub async fn read_page_text(config: &MintConfig) -> Result<String, String> {
|
|
81
|
+
log_action("READ", "Reading page text content");
|
|
82
|
+
ensure_page_open(config).await?;
|
|
83
|
+
match cdp_call(
|
|
84
|
+
config,
|
|
85
|
+
"Runtime.evaluate",
|
|
86
|
+
json!({
|
|
87
|
+
"expression": "document.body ? document.body.innerText.substring(0, 12000) : ''",
|
|
88
|
+
"returnByValue": true
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
.await
|
|
92
|
+
{
|
|
93
|
+
Ok(response) => {
|
|
94
|
+
if let Some(val) = response["result"]["result"]["value"].as_str() {
|
|
95
|
+
log_action(
|
|
96
|
+
"READ_SUCCESS",
|
|
97
|
+
&format!("Successfully read {} characters", val.len()),
|
|
98
|
+
);
|
|
99
|
+
Ok(val.to_owned())
|
|
100
|
+
} else {
|
|
101
|
+
let err = response_error(&response);
|
|
102
|
+
log_action("READ_ERROR", &format!("Failed: {err}"));
|
|
103
|
+
Err(err)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
Err(e) => {
|
|
107
|
+
log_action("READ_ERROR", &format!("Websocket error: {e}"));
|
|
108
|
+
Err(e)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String> {
|
|
114
|
+
let selector = selector.trim();
|
|
115
|
+
if selector.is_empty() || selector.len() > 500 {
|
|
116
|
+
log_action(
|
|
117
|
+
"CLICK_ERROR",
|
|
118
|
+
"Browser selector must contain between 1 and 500 characters",
|
|
119
|
+
);
|
|
120
|
+
return Err("browser selector must contain between 1 and 500 characters".into());
|
|
121
|
+
}
|
|
122
|
+
log_action("CLICK", &format!("Clicking element '{selector}'"));
|
|
123
|
+
ensure_page_open(config).await?;
|
|
124
|
+
let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
|
|
125
|
+
match cdp_call(
|
|
126
|
+
config,
|
|
127
|
+
"Runtime.evaluate",
|
|
128
|
+
json!({
|
|
129
|
+
"expression": format!(
|
|
130
|
+
"(() => {{ const element = document.querySelector({selector_json}); if (!element) return 'not-found'; element.click(); return 'clicked'; }})()"
|
|
131
|
+
),
|
|
132
|
+
"returnByValue": true
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
.await {
|
|
136
|
+
Ok(response) => {
|
|
137
|
+
match response["result"]["result"]["value"].as_str() {
|
|
138
|
+
Some("clicked") => {
|
|
139
|
+
log_action("CLICK_SUCCESS", &format!("Successfully clicked element '{selector}'"));
|
|
140
|
+
Ok("clicked".into())
|
|
141
|
+
}
|
|
142
|
+
Some("not-found") => {
|
|
143
|
+
let err = format!("browser selector not found: {selector}");
|
|
144
|
+
log_action("CLICK_ERROR", &err);
|
|
145
|
+
Err(err)
|
|
146
|
+
}
|
|
147
|
+
_ => {
|
|
148
|
+
let err = response_error(&response);
|
|
149
|
+
log_action("CLICK_ERROR", &err);
|
|
150
|
+
Err(err)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
Err(e) => {
|
|
155
|
+
log_action("CLICK_ERROR", &format!("Websocket error: {e}"));
|
|
156
|
+
Err(e)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pub async fn type_text(config: &MintConfig, selector: &str, text: &str) -> Result<String, String> {
|
|
162
|
+
let selector = selector.trim();
|
|
163
|
+
if selector.is_empty() || selector.len() > 500 {
|
|
164
|
+
log_action(
|
|
165
|
+
"TYPE_ERROR",
|
|
166
|
+
"Browser selector must contain between 1 and 500 characters",
|
|
167
|
+
);
|
|
168
|
+
return Err("browser selector must contain between 1 and 500 characters".into());
|
|
169
|
+
}
|
|
170
|
+
log_action("TYPE", &format!("Typing text into element '{selector}'"));
|
|
171
|
+
ensure_page_open(config).await?;
|
|
172
|
+
let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
|
|
173
|
+
let text_json = serde_json::to_string(text).map_err(|error| error.to_string())?;
|
|
174
|
+
match cdp_call(
|
|
175
|
+
config,
|
|
176
|
+
"Runtime.evaluate",
|
|
177
|
+
json!({
|
|
178
|
+
"expression": format!(
|
|
179
|
+
"(() => {{ \
|
|
180
|
+
const element = document.querySelector({selector_json}); \
|
|
181
|
+
if (!element) return 'not-found'; \
|
|
182
|
+
element.value = {text_json}; \
|
|
183
|
+
element.dispatchEvent(new Event('input', {{ bubbles: true }})); \
|
|
184
|
+
element.dispatchEvent(new Event('change', {{ bubbles: true }})); \
|
|
185
|
+
return 'typed'; \
|
|
186
|
+
}})()"
|
|
187
|
+
),
|
|
188
|
+
"returnByValue": true
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
.await
|
|
192
|
+
{
|
|
193
|
+
Ok(response) => match response["result"]["result"]["value"].as_str() {
|
|
194
|
+
Some("typed") => {
|
|
195
|
+
log_action(
|
|
196
|
+
"TYPE_SUCCESS",
|
|
197
|
+
&format!("Successfully typed into element '{selector}'"),
|
|
198
|
+
);
|
|
199
|
+
Ok("typed".into())
|
|
200
|
+
}
|
|
201
|
+
Some("not-found") => {
|
|
202
|
+
let err = format!("browser selector not found: {selector}");
|
|
203
|
+
log_action("TYPE_ERROR", &err);
|
|
204
|
+
Err(err)
|
|
205
|
+
}
|
|
206
|
+
_ => {
|
|
207
|
+
let err = response_error(&response);
|
|
208
|
+
log_action("TYPE_ERROR", &err);
|
|
209
|
+
Err(err)
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
Err(e) => {
|
|
213
|
+
log_action("TYPE_ERROR", &format!("Websocket error: {e}"));
|
|
214
|
+
Err(e)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
pub async fn is_browser_running(config: &MintConfig) -> bool {
|
|
220
|
+
let endpoint = config
|
|
221
|
+
.extra
|
|
222
|
+
.get("browserDebugUrl")
|
|
223
|
+
.and_then(Value::as_str)
|
|
224
|
+
.unwrap_or("http://127.0.0.1:9222/json/list");
|
|
225
|
+
fetch_pages_endpoint(endpoint).await.is_ok()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
pub async fn spawn_automation_browser(config: &MintConfig) -> Result<(), String> {
|
|
229
|
+
let endpoint = config
|
|
230
|
+
.extra
|
|
231
|
+
.get("browserDebugUrl")
|
|
232
|
+
.and_then(Value::as_str)
|
|
233
|
+
.unwrap_or("http://127.0.0.1:9222/json/list");
|
|
234
|
+
|
|
235
|
+
if fetch_pages_endpoint(endpoint).await.is_ok() {
|
|
236
|
+
return Ok(());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let browser_name = config
|
|
240
|
+
.extra
|
|
241
|
+
.get("automationBrowser")
|
|
242
|
+
.and_then(Value::as_str)
|
|
243
|
+
.unwrap_or("chromium");
|
|
244
|
+
|
|
245
|
+
let profile_dir = dirs::config_dir()
|
|
246
|
+
.unwrap_or_else(|| std::env::temp_dir())
|
|
247
|
+
.join("mint")
|
|
248
|
+
.join("automation-profile");
|
|
249
|
+
let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
|
|
250
|
+
|
|
251
|
+
let args = [
|
|
252
|
+
"--remote-debugging-port=9222".to_owned(),
|
|
253
|
+
"--no-first-run".to_owned(),
|
|
254
|
+
"--no-default-browser-check".to_owned(),
|
|
255
|
+
profile_arg,
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
let mut spawned = false;
|
|
259
|
+
let executables = if cfg!(target_os = "windows") {
|
|
260
|
+
vec!["chrome.exe", "chromium.exe", "msedge.exe"]
|
|
261
|
+
} else if cfg!(target_os = "macos") {
|
|
262
|
+
vec![
|
|
263
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
264
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
265
|
+
"chromium",
|
|
266
|
+
"google-chrome",
|
|
267
|
+
]
|
|
268
|
+
} else {
|
|
269
|
+
vec![
|
|
270
|
+
"chromium",
|
|
271
|
+
"google-chrome-stable",
|
|
272
|
+
"google-chrome",
|
|
273
|
+
"chrome",
|
|
274
|
+
"chromium-browser",
|
|
275
|
+
]
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
for exe in executables {
|
|
279
|
+
if std::process::Command::new(exe)
|
|
280
|
+
.args(&args)
|
|
281
|
+
.stdout(std::process::Stdio::null())
|
|
282
|
+
.stderr(std::process::Stdio::null())
|
|
283
|
+
.spawn()
|
|
284
|
+
.is_ok()
|
|
285
|
+
{
|
|
286
|
+
spawned = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if !spawned {
|
|
292
|
+
return Err(format!(
|
|
293
|
+
"Could not find or spawn browser '{browser_name}' with remote debugging. Please verify it is installed."
|
|
294
|
+
));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for _ in 0..20 {
|
|
298
|
+
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
|
299
|
+
if fetch_pages_endpoint(endpoint).await.is_ok() {
|
|
300
|
+
return Ok(());
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
Err("Browser spawned but remote debugging port 9222 did not become available.".to_string())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
pub async fn ensure_page_open(config: &MintConfig) -> Result<(), String> {
|
|
308
|
+
if !is_browser_running(config).await {
|
|
309
|
+
return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
|
|
310
|
+
}
|
|
311
|
+
let endpoint = config
|
|
312
|
+
.extra
|
|
313
|
+
.get("browserDebugUrl")
|
|
314
|
+
.and_then(Value::as_str)
|
|
315
|
+
.unwrap_or("http://127.0.0.1:9222/json/list");
|
|
316
|
+
|
|
317
|
+
let pages = fetch_pages_endpoint(endpoint).await?;
|
|
318
|
+
let has_page = pages.iter().any(|p| p["type"] == "page");
|
|
319
|
+
if !has_page {
|
|
320
|
+
let base_url = endpoint.replace("/json/list", "/json/new");
|
|
321
|
+
let client = crate::HTTP_CLIENT.clone();
|
|
322
|
+
let _ = client
|
|
323
|
+
.put(&base_url)
|
|
324
|
+
.send()
|
|
325
|
+
.await
|
|
326
|
+
.map_err(|e| format!("failed to open new tab: {e}"))?;
|
|
327
|
+
}
|
|
328
|
+
Ok(())
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Value, String> {
|
|
332
|
+
let page = fetch_pages(config)
|
|
333
|
+
.await?
|
|
334
|
+
.into_iter()
|
|
335
|
+
.find(|page| page["type"] == "page")
|
|
336
|
+
.ok_or("Chrome DevTools did not report an open browser page")?;
|
|
337
|
+
let socket_url = page["webSocketDebuggerUrl"]
|
|
338
|
+
.as_str()
|
|
339
|
+
.ok_or("Chrome DevTools page does not expose a websocket URL")?;
|
|
340
|
+
let (mut socket, _) = connect_async(socket_url)
|
|
341
|
+
.await
|
|
342
|
+
.map_err(|error| format!("unable to connect to Chrome DevTools websocket: {error}"))?;
|
|
343
|
+
socket
|
|
344
|
+
.send(Message::Text(
|
|
345
|
+
json!({ "id": 1, "method": method, "params": params })
|
|
346
|
+
.to_string()
|
|
347
|
+
.into(),
|
|
348
|
+
))
|
|
349
|
+
.await
|
|
350
|
+
.map_err(|error| error.to_string())?;
|
|
351
|
+
while let Some(message) = socket.next().await {
|
|
352
|
+
let message = message.map_err(|error| error.to_string())?;
|
|
353
|
+
let Message::Text(raw) = message else {
|
|
354
|
+
continue;
|
|
355
|
+
};
|
|
356
|
+
let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
|
|
357
|
+
if value["id"] == 1 {
|
|
358
|
+
return Ok(value);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
Err("Chrome DevTools websocket closed before returning a response".into())
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async fn fetch_pages(config: &MintConfig) -> Result<Vec<Value>, String> {
|
|
365
|
+
let endpoint = config
|
|
366
|
+
.extra
|
|
367
|
+
.get("browserDebugUrl")
|
|
368
|
+
.and_then(Value::as_str)
|
|
369
|
+
.unwrap_or("http://127.0.0.1:9222/json/list");
|
|
370
|
+
fetch_pages_endpoint(endpoint).await
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async fn fetch_pages_endpoint(endpoint: &str) -> Result<Vec<Value>, String> {
|
|
374
|
+
let client = reqwest::Client::builder()
|
|
375
|
+
.timeout(std::time::Duration::from_millis(1000))
|
|
376
|
+
.connect_timeout(std::time::Duration::from_millis(500))
|
|
377
|
+
.build()
|
|
378
|
+
.unwrap_or_else(|_| crate::HTTP_CLIENT.clone());
|
|
379
|
+
|
|
380
|
+
let value: Value = client
|
|
381
|
+
.get(endpoint)
|
|
382
|
+
.send()
|
|
383
|
+
.await
|
|
384
|
+
.map_err(|error| format!("unable to reach Chrome DevTools at {endpoint}: {error}"))?
|
|
385
|
+
.error_for_status()
|
|
386
|
+
.map_err(|error| error.to_string())?
|
|
387
|
+
.json()
|
|
388
|
+
.await
|
|
389
|
+
.map_err(|error| error.to_string())?;
|
|
390
|
+
value
|
|
391
|
+
.as_array()
|
|
392
|
+
.cloned()
|
|
393
|
+
.ok_or_else(|| "Chrome DevTools response was not a page list".into())
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fn response_error(response: &Value) -> String {
|
|
397
|
+
response["error"]["message"]
|
|
398
|
+
.as_str()
|
|
399
|
+
.unwrap_or("Chrome DevTools returned an unexpected response")
|
|
400
|
+
.to_owned()
|
|
401
|
+
}
|
|
@@ -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 {
|
|
@@ -208,7 +208,20 @@ pub fn set_config_value(key: &str, value: Value) -> Result<MintConfig, ConfigErr
|
|
|
208
208
|
|
|
209
209
|
fn load_config_from(path: &Path) -> Result<MintConfig, ConfigError> {
|
|
210
210
|
if !path.exists() {
|
|
211
|
-
let config = MintConfig::default();
|
|
211
|
+
let mut config = MintConfig::default();
|
|
212
|
+
if which("gk") {
|
|
213
|
+
let mut servers = BTreeMap::new();
|
|
214
|
+
servers.insert(
|
|
215
|
+
"gitkraken".to_string(),
|
|
216
|
+
serde_json::json!({
|
|
217
|
+
"command": "gk",
|
|
218
|
+
"args": ["mcp"]
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
config
|
|
222
|
+
.extra
|
|
223
|
+
.insert("mcpServers".into(), serde_json::to_value(servers).unwrap());
|
|
224
|
+
}
|
|
212
225
|
save_config_to(path, &config)?;
|
|
213
226
|
return Ok(config);
|
|
214
227
|
}
|
|
@@ -225,6 +238,32 @@ fn load_config_from(path: &Path) -> Result<MintConfig, ConfigError> {
|
|
|
225
238
|
for (key, value) in runtime_extra_defaults() {
|
|
226
239
|
config.extra.entry(key).or_insert(value);
|
|
227
240
|
}
|
|
241
|
+
|
|
242
|
+
let has_gitkraken = config
|
|
243
|
+
.extra
|
|
244
|
+
.get("mcpServers")
|
|
245
|
+
.and_then(|v| v.get("gitkraken"))
|
|
246
|
+
.is_some();
|
|
247
|
+
if !has_gitkraken && which("gk") {
|
|
248
|
+
let mut servers: BTreeMap<String, Value> = config
|
|
249
|
+
.extra
|
|
250
|
+
.get("mcpServers")
|
|
251
|
+
.cloned()
|
|
252
|
+
.and_then(|v| serde_json::from_value(v).ok())
|
|
253
|
+
.unwrap_or_default();
|
|
254
|
+
servers.insert(
|
|
255
|
+
"gitkraken".to_string(),
|
|
256
|
+
serde_json::json!({
|
|
257
|
+
"command": "gk",
|
|
258
|
+
"args": ["mcp"]
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
config
|
|
262
|
+
.extra
|
|
263
|
+
.insert("mcpServers".into(), serde_json::to_value(servers).unwrap());
|
|
264
|
+
let _ = save_config_to(path, &config);
|
|
265
|
+
}
|
|
266
|
+
|
|
228
267
|
Ok(config)
|
|
229
268
|
}
|
|
230
269
|
|
|
@@ -317,6 +356,21 @@ fn runtime_extra_defaults() -> BTreeMap<String, Value> {
|
|
|
317
356
|
.expect("runtime config defaults must be a JSON object")
|
|
318
357
|
}
|
|
319
358
|
|
|
359
|
+
pub fn which(cmd: &str) -> bool {
|
|
360
|
+
#[cfg(windows)]
|
|
361
|
+
let check_cmd = "where";
|
|
362
|
+
#[cfg(not(windows))]
|
|
363
|
+
let check_cmd = "which";
|
|
364
|
+
|
|
365
|
+
std::process::Command::new(check_cmd)
|
|
366
|
+
.arg(cmd)
|
|
367
|
+
.stdout(std::process::Stdio::null())
|
|
368
|
+
.stderr(std::process::Stdio::null())
|
|
369
|
+
.status()
|
|
370
|
+
.map(|status| status.success())
|
|
371
|
+
.unwrap_or(false)
|
|
372
|
+
}
|
|
373
|
+
|
|
320
374
|
#[cfg(test)]
|
|
321
375
|
mod tests {
|
|
322
376
|
use super::*;
|
|
@@ -384,4 +438,18 @@ mod tests {
|
|
|
384
438
|
);
|
|
385
439
|
assert_eq!(config.extra["allowedMcpTools"], serde_json::json!({}));
|
|
386
440
|
}
|
|
441
|
+
|
|
442
|
+
#[test]
|
|
443
|
+
fn test_gitkraken_auto_detection() {
|
|
444
|
+
let path = std::env::temp_dir().join("mint-test-config.json");
|
|
445
|
+
let _ = std::fs::remove_file(&path);
|
|
446
|
+
|
|
447
|
+
let initial_config = MintConfig::default();
|
|
448
|
+
save_config_to(&path, &initial_config).unwrap();
|
|
449
|
+
|
|
450
|
+
let loaded = load_config_from(&path).unwrap();
|
|
451
|
+
assert!(loaded.theme.len() > 0);
|
|
452
|
+
|
|
453
|
+
let _ = std::fs::remove_file(&path);
|
|
454
|
+
}
|
|
387
455
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#![recursion_limit = "256"]
|
|
2
2
|
|
|
3
3
|
pub mod agent_loop;
|
|
4
|
+
pub mod browser;
|
|
4
5
|
pub mod chat;
|
|
5
6
|
pub mod code_tools;
|
|
6
7
|
pub mod config;
|
|
@@ -29,6 +30,10 @@ pub use chat::{
|
|
|
29
30
|
stream_chat_with_fallback,
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
pub use browser::{
|
|
34
|
+
BrowserTab, click, is_browser_running, list_tabs, navigate, read_page_text,
|
|
35
|
+
spawn_automation_browser, type_text,
|
|
36
|
+
};
|
|
32
37
|
pub use code_tools::{
|
|
33
38
|
AppliedCodeEdit, CodeEdit, CodeEditPreview, CodeEditProposal, CodeFile, CodeInspectionError,
|
|
34
39
|
CodePatchHunk, CodePlan, CodeSearchHit, RepositorySummary, apply_code_edits, build_code_patch,
|
|
@@ -78,7 +83,7 @@ pub use symbols::{CodeSymbol, SymbolError, SymbolIndex, build_symbol_index};
|
|
|
78
83
|
pub use tasks::{Task, TaskError, TaskStore, tasks_path};
|
|
79
84
|
pub use tts::{TtsUrl, google_tts_urls};
|
|
80
85
|
pub use weather::{WeatherError, WeatherReport, weather};
|
|
81
|
-
pub use workflows::{WorkflowError, load_workflows, workflows_path};
|
|
86
|
+
pub use workflows::{WorkflowError, load_workflows, save_workflows, workflows_path};
|
|
82
87
|
pub mod api_server;
|
|
83
88
|
pub use api_server::start_api_server;
|
|
84
89
|
pub mod channels;
|