From 1a9763a1e47f5f62d7f83164afcccd8bcf050839 Mon Sep 17 00:00:00 2001 From: tilhammer <223678661+tilhammer@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:08:58 +0200 Subject: [PATCH 1/2] fix: stop Ollama word-duplication by using its HTTP API instead of the CLI Fixes #143. Scratch's ollama integration shelled out to `ollama run `, piping the prompt via stdin and capturing stdout as plain text. Ollama's CLI re-enables its terminal word-wrap renderer by default even when piped (a flag-precedence quirk in cmd/cmd.go: the --nowordwrap flag default unconditionally overrides the correct non-interactive detection). That renderer erases a partially-printed word with ANSI cursor-back + erase-in-line, then reprints it whole on the next line - real terminals render this as clean wrapping, but our regex-based ANSI stripping only deleted the escape codes, not the characters already printed before them, leaving literal duplicated text ("founda" immediately followed by "foundational"). Switch ai_execute_ollama to POST http://localhost:11434/api/generate directly (stream: false, think: true) instead of spawning a subprocess. Ollama returns "response" and "thinking" as separate JSON fields, so enabling thinking no longer risks it leaking into note content - only "response" is ever written to the file. This also drops the separate `ollama show` pre-check subprocess (a 404 from /api/generate now drives the "model not found" message directly) and the CLI-stderr string matching in favor of real HTTP status codes. Co-Authored-By: Claude Sonnet 5 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 164 +++++++++++++++++++++++-------------------- 3 files changed, 88 insertions(+), 78 deletions(-) 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..0050261e 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,105 @@ 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, - } - }) + let client = reqwest::Client::new(); + 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(), + ), }); } - } - - 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()), - }); - } - } - } - - // 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() { + 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 reach Ollama: {}", e)), }); } - tokio::fs::write(&canonical, edited_content.as_bytes()) + }; + + let status = response.status(); + if !status.is_success() { + #[derive(Deserialize)] + struct OllamaError { + error: String, + } + + let message = response + .json::() .await - .map_err(|e| format!("Failed to write edited file: {}", e))?; + .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 + }; - Ok(AiExecutionResult { - success: true, - output: "Note edited successfully with Ollama.".to_string(), - error: None, - }) - } else { - Ok(result) + return Ok(AiExecutionResult { + success: false, + output: String::new(), + error: Some(error), + }); + } + + #[derive(Deserialize)] + struct OllamaGenerateResponse { + response: String, + #[serde(default)] + thinking: String, } + + let body: OllamaGenerateResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + + if !body.thinking.is_empty() { + eprintln!("Ollama thinking output ({} chars, discarded)", body.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. From 67719469da8fc17716587b95224d297d5d4f09cf Mon Sep 17 00:00:00 2001 From: tilhammer <223678661+tilhammer@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:19:43 +0200 Subject: [PATCH 2/2] fix: add request timeout and tolerate null thinking in Ollama response Addresses CodeRabbit review on #195: - reqwest::Client::new() applied no timeout, so a stalled Ollama request (e.g. slow model load) would hang the Tauri command forever with no way to recover. Build the client with a 5-minute timeout, matching the timeout the other AI providers already use via execute_ai_cli, and report timeouts with a distinct message. - OllamaGenerateResponse.thinking was a plain String with #[serde(default)], which only covers a missing key - an explicit `"thinking": null` (which some models can send) would still fail to deserialize and reject the whole Tauri invoke instead of surfacing a normal AiExecutionResult failure. Made it Option and handled the response-parse failure the same way every other failure in this function is handled (Ok(AiExecutionResult { success: false, .. })). Not applied: the suggestion to drop `think: true` entirely. Ollama silently no-ops the think parameter for models that don't support it (confirmed via upstream issue reports linked in the review) and an empty "thinking" string was already handled gracefully before this commit - the only real gap was the null case above, which is now fixed directly. Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/lib.rs | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0050261e..b7339239 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3404,7 +3404,13 @@ async fn ai_execute_ollama( trimmed.to_string() }; - let client = reqwest::Client::new(); + // 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!({ @@ -3429,6 +3435,16 @@ async fn ai_execute_ollama( ), }); } + 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, @@ -3472,17 +3488,27 @@ async fn ai_execute_ollama( #[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: String, + thinking: Option, } - let body: OllamaGenerateResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + let body: OllamaGenerateResponse = match response.json().await { + Ok(body) => body, + Err(e) => { + return Ok(AiExecutionResult { + success: false, + output: String::new(), + error: Some(format!("Failed to parse Ollama response: {}", e)), + }); + } + }; - if !body.thinking.is_empty() { - eprintln!("Ollama thinking output ({} chars, discarded)", body.thinking.len()); + 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();