diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2f9ea642..0acf6f8e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "objc2-foundation", "open", "regex", + "reqwest", "serde", "serde_json", "tantivy", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a099c03..0bf48d82 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ regex = "1" walkdir = "2" tauri-plugin-single-instance = "2" chrono = "0.4" +reqwest = { version = "0.13", default-features = false, features = ["json"] } [target.'cfg(target_os = "macos")'.dependencies] objc2-foundation = { version = "0.3", features = ["NSUserDefaults", "NSString"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..b7339239 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3387,7 +3387,7 @@ async fn ai_execute_ollama( .await .map_err(|e| format!("Failed to read file: {}", e))?; - let stdin_input = format!( + let full_prompt = format!( "You are a markdown editor. Edit the markdown content below according to the user's instructions.\n\ Return ONLY the complete edited markdown content.\n\ Do NOT include any explanation, commentary, or code fences around the output.\n\ @@ -3404,97 +3404,131 @@ async fn ai_execute_ollama( trimmed.to_string() }; - // Check if the model is available locally before running (skip for cloud models) - if !model_name.contains("cloud") { - let mn = model_name.clone(); - let available = tauri::async_runtime::spawn_blocking(move || { - let path = get_expanded_path(); - let mut cmd = no_window_cmd("ollama"); - cmd.env("PATH", &path); - cmd.args(["show", &mn]); - cmd.stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - match cmd.status() { - Ok(status) => status.success(), - Err(_) => false, - } - }) + // Non-streaming generation of a whole note can be slow (model load + full + // response), so this needs a generous timeout rather than none at all — + // otherwise a stalled Ollama request hangs the command forever. + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + let response = match client + .post("http://localhost:11434/api/generate") + .json(&serde_json::json!({ + "model": model_name, + "prompt": full_prompt, + // Ollama returns thinking output in a separate "thinking" field + // from "response", so enabling it doesn't leak into the note content. + "think": true, + "stream": false, + })) + .send() .await - .unwrap_or(false); - - if !available { + { + Ok(response) => response, + Err(e) if e.is_connect() => { return Ok(AiExecutionResult { success: false, output: String::new(), - error: Some(format!( - "Model '{}' is not installed. Run: ollama pull {}", - model_name, model_name - )), + error: Some( + "Could not connect to Ollama. Make sure it's running (`ollama serve`)." + .to_string(), + ), }); } - } + Err(e) if e.is_timeout() => { + return Ok(AiExecutionResult { + success: false, + output: String::new(), + error: Some( + "Ollama did not respond in time. The model may still be loading." + .to_string(), + ), + }); + } + Err(e) => { + return Ok(AiExecutionResult { + success: false, + output: String::new(), + error: Some(format!("Failed to reach Ollama: {}", e)), + }); + } + }; - let result = execute_ai_cli( - "Ollama", - "ollama".to_string(), - vec!["run".to_string(), model_name.clone()], - stdin_input, - "Ollama CLI not found. Please install it from https://ollama.com".to_string(), - None, - None, - ) - .await?; - - // Improve error messages for common Ollama failures - if !result.success { - if let Some(ref err) = result.error { - let err_lower = err.to_lowercase(); - if err_lower.contains("file does not exist") - || err_lower.contains("pull model manifest") - || err_lower.contains("model not found") - || err_lower.contains("model does not exist") - { - return Ok(AiExecutionResult { - success: false, - output: String::new(), - error: Some(format!( - "Model '{}' not found. Run `ollama pull {}` in your terminal to download it.", - model_name, model_name - )), - }); - } - if err.contains("401") || err.contains("Unauthorized") { - return Ok(AiExecutionResult { - success: false, - output: String::new(), - error: Some("Authentication required. Run `ollama login` in your terminal to sign in.".to_string()), - }); - } + let status = response.status(); + if !status.is_success() { + #[derive(Deserialize)] + struct OllamaError { + error: String, } + + let message = response + .json::() + .await + .map(|e| e.error) + .unwrap_or_else(|_| format!("Ollama request failed with status {}", status)); + + let error = if status == reqwest::StatusCode::NOT_FOUND { + format!( + "Model '{}' not found. Run `ollama pull {}` in your terminal to download it.", + model_name, model_name + ) + } else if status == reqwest::StatusCode::UNAUTHORIZED { + "Authentication required. Run `ollama login` in your terminal to sign in.".to_string() + } else { + message + }; + + return Ok(AiExecutionResult { + success: false, + output: String::new(), + error: Some(error), + }); + } + + #[derive(Deserialize)] + struct OllamaGenerateResponse { + response: String, + // Option, not String: models without thinking support can send this + // back as explicit JSON null rather than omitting it or using "". + #[serde(default)] + thinking: Option, } - // If successful, write the output back to the file - if result.success { - let edited_content = result.output.trim().to_string(); - if edited_content.is_empty() { + let body: OllamaGenerateResponse = match response.json().await { + Ok(body) => body, + Err(e) => { return Ok(AiExecutionResult { success: false, output: String::new(), - error: Some("Ollama returned empty output. Please try again.".to_string()), + error: Some(format!("Failed to parse Ollama response: {}", e)), }); } - tokio::fs::write(&canonical, edited_content.as_bytes()) - .await - .map_err(|e| format!("Failed to write edited file: {}", e))?; + }; - Ok(AiExecutionResult { - success: true, - output: "Note edited successfully with Ollama.".to_string(), - error: None, - }) - } else { - Ok(result) + if let Some(thinking) = &body.thinking { + if !thinking.is_empty() { + eprintln!("Ollama thinking output ({} chars, discarded)", thinking.len()); + } + } + + let edited_content = body.response.trim().to_string(); + if edited_content.is_empty() { + return Ok(AiExecutionResult { + success: false, + output: String::new(), + error: Some("Ollama returned empty output. Please try again.".to_string()), + }); } + + tokio::fs::write(&canonical, edited_content.as_bytes()) + .await + .map_err(|e| format!("Failed to write edited file: {}", e))?; + + Ok(AiExecutionResult { + success: true, + output: "Note edited successfully with Ollama.".to_string(), + error: None, + }) } /// Check if a markdown file is inside the configured notes folder.