Skip to content

Commit b9a112e

Browse files
committed
Feature: Add local AI for folder name suggestions
- Optional 4.6GB download (Falcon-H1R-7B via llama-server) - Notification-based install flow with progress & resume support - AI suggestions appear in New Folder dialog (F7) - Dev mode returns mock suggestions, no download required - Attribution added to About window
1 parent e73c144 commit b9a112e

19 files changed

Lines changed: 2431 additions & 7 deletions

apps/desktop/src-tauri/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ alphanumeric-sort = "1.5"
4848
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
4949
env_logger = "0.11.8"
5050
log = "0.4"
51-
# HTTP client for license server validation
52-
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
51+
# HTTP client for license server validation and AI downloads
52+
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
53+
# AI model download: extracting llama-server from tar.gz
54+
tar = "0.4"
55+
flate2 = "1.1"
5356
# MCP server
5457
axum = "0.8"
5558
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "macros"] }
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//! HTTP client for the local llama-server (OpenAI-compatible API).
2+
3+
use serde::{Deserialize, Serialize};
4+
use std::time::Duration;
5+
6+
/// Error types for AI client operations.
7+
#[derive(Debug, Clone)]
8+
pub enum AiError {
9+
/// Server is not running or not reachable
10+
Unavailable,
11+
/// Request timed out
12+
Timeout,
13+
/// Server returned an error
14+
ServerError(String),
15+
/// Failed to parse the response
16+
ParseError(String),
17+
}
18+
19+
impl std::fmt::Display for AiError {
20+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21+
match self {
22+
Self::Unavailable => write!(f, "AI server unavailable"),
23+
Self::Timeout => write!(f, "AI request timed out"),
24+
Self::ServerError(msg) => write!(f, "AI server error: {msg}"),
25+
Self::ParseError(msg) => write!(f, "AI response parse error: {msg}"),
26+
}
27+
}
28+
}
29+
30+
#[derive(Serialize)]
31+
struct ChatMessage {
32+
role: String,
33+
content: String,
34+
}
35+
36+
#[derive(Serialize)]
37+
struct ChatCompletionRequest {
38+
model: String,
39+
messages: Vec<ChatMessage>,
40+
temperature: f32,
41+
top_p: f32,
42+
max_tokens: u32,
43+
stream: bool,
44+
}
45+
46+
#[derive(Deserialize)]
47+
struct ChatCompletionResponse {
48+
choices: Vec<ChatChoice>,
49+
}
50+
51+
#[derive(Deserialize)]
52+
struct ChatChoice {
53+
message: ChatChoiceMessage,
54+
}
55+
56+
#[derive(Deserialize)]
57+
struct ChatChoiceMessage {
58+
content: String,
59+
}
60+
61+
/// Sends a chat completion request to the local llama-server.
62+
///
63+
/// Returns the assistant's response text, or an error.
64+
/// Times out after 10 seconds.
65+
pub async fn chat_completion(port: u16, prompt: &str) -> Result<String, AiError> {
66+
let url = format!("http://127.0.0.1:{port}/v1/chat/completions");
67+
68+
let request_body = ChatCompletionRequest {
69+
model: String::from("falcon-h1r-7b"),
70+
messages: vec![ChatMessage {
71+
role: String::from("user"),
72+
content: prompt.to_string(),
73+
}],
74+
temperature: 0.6,
75+
top_p: 0.95,
76+
max_tokens: 100,
77+
stream: false,
78+
};
79+
80+
let client = reqwest::Client::builder()
81+
.timeout(Duration::from_secs(10))
82+
.build()
83+
.map_err(|e| AiError::ServerError(e.to_string()))?;
84+
85+
let response = client.post(&url).json(&request_body).send().await.map_err(|e| {
86+
if e.is_timeout() {
87+
AiError::Timeout
88+
} else if e.is_connect() {
89+
AiError::Unavailable
90+
} else {
91+
AiError::ServerError(e.to_string())
92+
}
93+
})?;
94+
95+
if !response.status().is_success() {
96+
let status = response.status();
97+
let body = response.text().await.unwrap_or_default();
98+
return Err(AiError::ServerError(format!("HTTP {status}: {body}")));
99+
}
100+
101+
let parsed: ChatCompletionResponse = response.json().await.map_err(|e| AiError::ParseError(e.to_string()))?;
102+
103+
parsed
104+
.choices
105+
.first()
106+
.map(|c| c.message.content.clone())
107+
.ok_or_else(|| AiError::ParseError(String::from("No choices in response")))
108+
}
109+
110+
/// Checks if the llama-server is healthy.
111+
pub async fn health_check(port: u16) -> bool {
112+
let url = format!("http://127.0.0.1:{port}/health");
113+
114+
let client = match reqwest::Client::builder().timeout(Duration::from_secs(2)).build() {
115+
Ok(c) => c,
116+
Err(_) => return false,
117+
};
118+
119+
match client.get(&url).send().await {
120+
Ok(response) => response.status().is_success(),
121+
Err(_) => false,
122+
}
123+
}
124+
125+
#[cfg(test)]
126+
mod tests {
127+
use super::*;
128+
129+
#[test]
130+
fn test_ai_error_display() {
131+
assert_eq!(AiError::Unavailable.to_string(), "AI server unavailable");
132+
assert_eq!(AiError::Timeout.to_string(), "AI request timed out");
133+
assert_eq!(
134+
AiError::ServerError(String::from("bad")).to_string(),
135+
"AI server error: bad"
136+
);
137+
assert_eq!(
138+
AiError::ParseError(String::from("oops")).to_string(),
139+
"AI response parse error: oops"
140+
);
141+
}
142+
}

0 commit comments

Comments
 (0)