Skip to content

Commit d660b9d

Browse files
PurpleDoubleDclaude
andcommitted
feat: Tauri production backend — standalone .exe with all features
Complete Rust backend enabling the app to run as a standalone .exe without Node.js or Vite dev server: Rust modules (src-tauri/src/commands/): - process.rs: Ollama/ComfyUI auto-start, stop, status, discovery - whisper.rs: Persistent whisper_server.py IPC (stdin/stdout JSON) - agent.rs: Python code execution, file read/write in sandbox - download.rs: Model downloads with progress tracking (reqwest streaming) - search.rs: Multi-tier web search (SearXNG > DDG > Wikipedia) - install.rs: One-click ComfyUI/SearXNG installation - proxy.rs: Ollama.com model search proxy Frontend abstraction (src/api/backend.ts): - isTauri() detection via window.__TAURI__ - backendCall() routes to invoke() (production) or fetch() (dev) - ollamaUrl()/comfyuiUrl() for direct localhost calls in production All API files updated: ollama.ts, comfyui.ts, agents.ts, voice.ts, discover.ts, rag.ts, CreateView.tsx, AgentView.tsx Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent ff10387 commit d660b9d

25 files changed

Lines changed: 2259 additions & 88 deletions

src-tauri/Cargo.lock

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

src-tauri/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,17 @@ tauri-build = { version = "2", features = [] }
1010

1111
[dependencies]
1212
tauri = { version = "2", features = [] }
13+
tauri-plugin-shell = "2"
1314
serde = { version = "1", features = ["derive"] }
1415
serde_json = "1"
16+
tokio = { version = "1", features = ["full"] }
17+
reqwest = { version = "0.12", features = ["stream", "blocking", "json"] }
18+
dirs = "6"
19+
regex = "1"
20+
base64 = "0.22"
21+
tempfile = "3"
22+
futures-util = "0.3"
23+
urlencoding = "2"
1524

1625
[profile.release]
1726
panic = "abort"

src-tauri/capabilities/default.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"permissions": [
77
"core:default",
88
"core:window:default",
9-
"core:webview:default"
9+
"core:webview:default",
10+
"shell:allow-spawn",
11+
"shell:allow-stdin-write",
12+
"shell:allow-kill"
1013
]
1114
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Persistent faster-whisper server for Locally Uncensored.
3+
Communicates via stdin/stdout with line-based JSON protocol.
4+
5+
Input (one JSON per line on stdin):
6+
{"action": "transcribe", "path": "/tmp/audio.wav"}
7+
{"action": "status"}
8+
{"action": "quit"}
9+
10+
Output (one JSON per line on stdout):
11+
{"status": "ready", "backend": "faster-whisper"}
12+
{"transcript": "hello world", "language": "en"}
13+
{"error": "..."}
14+
"""
15+
16+
import sys
17+
import json
18+
import os
19+
20+
def main():
21+
# Unbuffered stdout for real-time communication with Node.js
22+
sys.stdout.reconfigure(line_buffering=True)
23+
sys.stderr.reconfigure(line_buffering=True)
24+
25+
print("Loading faster-whisper model...", file=sys.stderr, flush=True)
26+
27+
try:
28+
from faster_whisper import WhisperModel
29+
except ImportError:
30+
respond({"status": "error", "error": "faster-whisper not installed"})
31+
sys.exit(1)
32+
33+
# Load model once — this is the slow part (~170s on some systems)
34+
try:
35+
model = WhisperModel("base", device="cpu", compute_type="int8")
36+
print("Model loaded, ready for transcription.", file=sys.stderr, flush=True)
37+
except Exception as e:
38+
respond({"status": "error", "error": f"Model load failed: {e}"})
39+
sys.exit(1)
40+
41+
# Signal readiness
42+
respond({"status": "ready", "backend": "faster-whisper"})
43+
44+
# Main loop: read commands from stdin
45+
for line in sys.stdin:
46+
line = line.strip()
47+
if not line:
48+
continue
49+
50+
try:
51+
cmd = json.loads(line)
52+
except json.JSONDecodeError:
53+
respond({"error": "Invalid JSON"})
54+
continue
55+
56+
action = cmd.get("action", "")
57+
58+
if action == "status":
59+
respond({"status": "ready", "backend": "faster-whisper"})
60+
61+
elif action == "transcribe":
62+
audio_path = cmd.get("path", "")
63+
if not audio_path or not os.path.exists(audio_path):
64+
respond({"error": f"File not found: {audio_path}", "transcript": ""})
65+
continue
66+
67+
try:
68+
segments, info = model.transcribe(audio_path)
69+
text = " ".join([s.text for s in segments]).strip()
70+
respond({"transcript": text, "language": info.language})
71+
except Exception as e:
72+
respond({"error": str(e), "transcript": ""})
73+
74+
elif action == "quit":
75+
respond({"status": "stopped"})
76+
break
77+
78+
else:
79+
respond({"error": f"Unknown action: {action}"})
80+
81+
82+
def respond(data: dict):
83+
"""Write a JSON response line to stdout."""
84+
print(json.dumps(data), flush=True)
85+
86+
87+
if __name__ == "__main__":
88+
main()

src-tauri/src/commands/agent.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use std::fs;
2+
use std::io::Read;
3+
use std::path::PathBuf;
4+
use std::process::{Command, Stdio};
5+
6+
use tauri::State;
7+
8+
use crate::state::AppState;
9+
10+
fn agent_workspace() -> PathBuf {
11+
dirs::home_dir().unwrap_or_default().join("agent-workspace")
12+
}
13+
14+
fn validate_path(path: &str) -> Result<PathBuf, String> {
15+
if path.contains("..") {
16+
return Err("Path traversal not allowed".to_string());
17+
}
18+
let workspace = agent_workspace();
19+
let full = workspace.join(path);
20+
if !full.starts_with(&workspace) {
21+
return Err("Path outside workspace".to_string());
22+
}
23+
Ok(full)
24+
}
25+
26+
#[tauri::command]
27+
pub fn execute_code(
28+
code: String,
29+
timeout: Option<u64>,
30+
state: State<'_, AppState>,
31+
) -> Result<serde_json::Value, String> {
32+
let timeout_ms = timeout.unwrap_or(30000);
33+
34+
let tmp_dir = std::env::temp_dir();
35+
let script_path = tmp_dir.join(format!("agent-code-{}.py", std::time::SystemTime::now()
36+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()));
37+
38+
fs::write(&script_path, &code)
39+
.map_err(|e| format!("Write temp script: {}", e))?;
40+
41+
let workspace = agent_workspace();
42+
let _ = fs::create_dir_all(&workspace);
43+
44+
let mut child = Command::new(&state.python_bin)
45+
.arg(&script_path)
46+
.current_dir(&workspace)
47+
.stdin(Stdio::null())
48+
.stdout(Stdio::piped())
49+
.stderr(Stdio::piped())
50+
.spawn()
51+
.map_err(|e| format!("Spawn Python: {}", e))?;
52+
53+
// Poll-based timeout since std::process::Child has no wait_timeout
54+
let start = std::time::Instant::now();
55+
let timeout_dur = std::time::Duration::from_millis(timeout_ms);
56+
57+
loop {
58+
match child.try_wait() {
59+
Ok(Some(status)) => {
60+
let mut stdout_str = String::new();
61+
let mut stderr_str = String::new();
62+
if let Some(mut stdout) = child.stdout.take() {
63+
let _ = stdout.read_to_string(&mut stdout_str);
64+
}
65+
if let Some(mut stderr) = child.stderr.take() {
66+
let _ = stderr.read_to_string(&mut stderr_str);
67+
}
68+
69+
let _ = fs::remove_file(&script_path);
70+
return Ok(serde_json::json!({
71+
"stdout": stdout_str,
72+
"stderr": stderr_str,
73+
"exitCode": status.code().unwrap_or(-1),
74+
"timedOut": false,
75+
}));
76+
}
77+
Ok(None) => {
78+
if start.elapsed() > timeout_dur {
79+
let _ = child.kill();
80+
let _ = fs::remove_file(&script_path);
81+
return Ok(serde_json::json!({
82+
"stdout": "",
83+
"stderr": format!("Execution timed out after {}ms", timeout_ms),
84+
"exitCode": -1,
85+
"timedOut": true,
86+
}));
87+
}
88+
std::thread::sleep(std::time::Duration::from_millis(50));
89+
}
90+
Err(e) => {
91+
let _ = fs::remove_file(&script_path);
92+
return Err(format!("Wait error: {}", e));
93+
}
94+
}
95+
}
96+
}
97+
98+
#[tauri::command]
99+
pub fn file_read(path: String) -> Result<serde_json::Value, String> {
100+
let full_path = validate_path(&path)?;
101+
if !full_path.exists() {
102+
return Err(format!("File not found: {}", path));
103+
}
104+
let content = fs::read_to_string(&full_path)
105+
.map_err(|e| format!("Read error: {}", e))?;
106+
Ok(serde_json::json!({"content": content}))
107+
}
108+
109+
#[tauri::command]
110+
pub fn file_write(path: String, content: String) -> Result<serde_json::Value, String> {
111+
let full_path = validate_path(&path)?;
112+
if let Some(parent) = full_path.parent() {
113+
fs::create_dir_all(parent).map_err(|e| format!("Create dir: {}", e))?;
114+
}
115+
fs::write(&full_path, &content).map_err(|e| format!("Write error: {}", e))?;
116+
Ok(serde_json::json!({"status": "saved", "path": full_path.to_string_lossy()}))
117+
}

src-tauri/src/commands/download.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use std::fs;
2+
use std::path::PathBuf;
3+
use std::time::Instant;
4+
5+
use futures_util::StreamExt;
6+
use tauri::State;
7+
8+
use crate::state::{AppState, DownloadProgress};
9+
10+
fn models_dir(comfy_path: &Option<String>, subfolder: &str) -> Result<PathBuf, String> {
11+
let base = comfy_path.as_ref().ok_or("ComfyUI path not set")?;
12+
let dir = PathBuf::from(base).join("models").join(subfolder);
13+
fs::create_dir_all(&dir).map_err(|e| format!("Create models dir: {}", e))?;
14+
Ok(dir)
15+
}
16+
17+
#[tauri::command]
18+
pub async fn download_model(
19+
url: String,
20+
subfolder: String,
21+
filename: String,
22+
state: State<'_, AppState>,
23+
) -> Result<serde_json::Value, String> {
24+
let comfy_path = {
25+
let p = state.comfy_path.lock().unwrap();
26+
p.clone()
27+
};
28+
29+
let dest_dir = models_dir(&comfy_path, &subfolder)?;
30+
let dest_file = dest_dir.join(&filename);
31+
32+
if dest_file.exists() {
33+
return Ok(serde_json::json!({"status": "exists", "path": dest_file.to_string_lossy()}));
34+
}
35+
36+
let id = format!("{}-{}", subfolder, filename);
37+
38+
// Initialize progress
39+
{
40+
let mut downloads = state.downloads.lock().unwrap();
41+
downloads.insert(id.clone(), DownloadProgress {
42+
progress: 0,
43+
total: 0,
44+
speed: 0.0,
45+
filename: filename.clone(),
46+
status: "connecting".to_string(),
47+
error: None,
48+
});
49+
}
50+
51+
let id_clone = id.clone();
52+
let filename_clone = filename.clone();
53+
54+
tokio::spawn(async move {
55+
match do_download(&url, &dest_file).await {
56+
Ok(_) => println!("[Download] Complete: {}", filename_clone),
57+
Err(e) => println!("[Download] Failed: {} - {}", filename_clone, e),
58+
}
59+
});
60+
61+
Ok(serde_json::json!({"status": "started", "id": id}))
62+
}
63+
64+
async fn do_download(url: &str, dest: &PathBuf) -> Result<(), String> {
65+
let client = reqwest::Client::builder()
66+
.user_agent("LocallyUncensored/1.3")
67+
.redirect(reqwest::redirect::Policy::limited(10))
68+
.build()
69+
.map_err(|e| e.to_string())?;
70+
71+
let response = client.get(url)
72+
.send()
73+
.await
74+
.map_err(|e| format!("Request failed: {}", e))?;
75+
76+
if !response.status().is_success() {
77+
return Err(format!("HTTP {}", response.status()));
78+
}
79+
80+
let total = response.content_length().unwrap_or(0);
81+
82+
let tmp_path = dest.with_extension("download");
83+
let mut file = tokio::fs::File::create(&tmp_path)
84+
.await
85+
.map_err(|e| format!("Create file: {}", e))?;
86+
87+
let mut stream = response.bytes_stream();
88+
let mut downloaded: u64 = 0;
89+
let start = Instant::now();
90+
91+
use tokio::io::AsyncWriteExt;
92+
while let Some(chunk) = stream.next().await {
93+
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
94+
file.write_all(&chunk).await.map_err(|e| format!("Write: {}", e))?;
95+
downloaded += chunk.len() as u64;
96+
97+
// Log progress every ~1MB
98+
if downloaded % (1024 * 1024) < chunk.len() as u64 {
99+
let elapsed = start.elapsed().as_secs_f64();
100+
let speed = if elapsed > 0.0 { downloaded as f64 / elapsed } else { 0.0 };
101+
println!("[Download] {:.1} MB / {:.1} MB ({:.1} MB/s)",
102+
downloaded as f64 / 1048576.0,
103+
total as f64 / 1048576.0,
104+
speed / 1048576.0);
105+
}
106+
}
107+
108+
file.flush().await.map_err(|e| format!("Flush: {}", e))?;
109+
drop(file);
110+
111+
tokio::fs::rename(&tmp_path, dest)
112+
.await
113+
.map_err(|e| format!("Rename: {}", e))?;
114+
115+
Ok(())
116+
}
117+
118+
#[tauri::command]
119+
pub fn download_progress(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
120+
let downloads = state.downloads.lock().unwrap();
121+
let map: std::collections::HashMap<String, DownloadProgress> = downloads.clone();
122+
Ok(serde_json::to_value(map).unwrap_or_default())
123+
}

0 commit comments

Comments
 (0)