Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
184 changes: 109 additions & 75 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\
Expand All @@ -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,
}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.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::<OllamaError>()
.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<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() {
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());
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
Expand Down