mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 02:42:16 +00:00
- Tauri commands: save_cloud_key writes keys to ~/.openjarvis/cloud-keys.env (chmod 600), get_cloud_key_status reports which providers are configured. - Server spawn: reads cloud-keys.env and injects keys as env vars into the jarvis serve process, so CloudEngine picks them up automatically. - Frontend: Cloud Models tab saves keys through Tauri invoke (desktop) in addition to localStorage (web), so keys persist across restarts. - Flow: user enters key in Cloud tab → saved to disk → next server start picks it up → cloud models appear in the model list.
1040 lines
34 KiB
Rust
1040 lines
34 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tauri::Manager;
|
|
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
|
use tauri::tray::TrayIconBuilder;
|
|
use tauri_plugin_autostart::MacosLauncher;
|
|
use tokio::sync::Mutex;
|
|
|
|
const OLLAMA_PORT: u16 = 11434;
|
|
const JARVIS_PORT: u16 = 8222;
|
|
|
|
/// Small, fast model pulled at startup so the app opens quickly.
|
|
const STARTUP_MODEL: &str = "qwen3.5:2b";
|
|
|
|
/// Tiny fallback model if even the startup model can't be pulled.
|
|
const FALLBACK_MODEL: &str = "qwen3:0.6b";
|
|
|
|
/// Qwen3.5 model variants, ordered smallest to largest.
|
|
/// Each entry is (ollama_tag, approximate_download_size_gb, min_ram_gb).
|
|
const QWEN35_MODELS: &[(&str, f64, f64)] = &[
|
|
("qwen3.5:0.8b", 1.0, 4.0),
|
|
("qwen3.5:2b", 2.7, 6.0),
|
|
("qwen3.5:4b", 3.4, 8.0),
|
|
("qwen3.5:9b", 6.6, 12.0),
|
|
("qwen3.5:27b", 17.0, 24.0),
|
|
("qwen3.5:35b", 24.0, 32.0),
|
|
("qwen3.5:122b", 81.0, 96.0),
|
|
];
|
|
|
|
/// Get total system RAM in GB.
|
|
fn total_ram_gb() -> f64 {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use std::process::Command;
|
|
if let Ok(output) = Command::new("sysctl").args(["-n", "hw.memsize"]).output() {
|
|
if let Ok(s) = String::from_utf8(output.stdout) {
|
|
if let Ok(bytes) = s.trim().parse::<u64>() {
|
|
return bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Ok(contents) = std::fs::read_to_string("/proc/meminfo") {
|
|
for line in contents.lines() {
|
|
if line.starts_with("MemTotal:") {
|
|
if let Some(kb_str) = line.split_whitespace().nth(1) {
|
|
if let Ok(kb) = kb_str.parse::<u64>() {
|
|
return kb as f64 / (1024.0 * 1024.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
8.0
|
|
}
|
|
|
|
/// Return the list of Qwen3.5 models that fit on this machine, smallest first.
|
|
fn models_that_fit() -> Vec<&'static str> {
|
|
let ram = total_ram_gb();
|
|
QWEN35_MODELS
|
|
.iter()
|
|
.filter(|(_, _, min_ram)| ram >= *min_ram)
|
|
.map(|(tag, _, _)| *tag)
|
|
.collect()
|
|
}
|
|
|
|
/// Pick the third-largest Qwen3.5 model that fits on this machine.
|
|
/// This leaves comfortable headroom for the OS / other apps while
|
|
/// still providing a capable model. Falls back gracefully when
|
|
/// fewer models fit.
|
|
fn preferred_model() -> &'static str {
|
|
let fitting = models_that_fit();
|
|
match fitting.len() {
|
|
0 => FALLBACK_MODEL,
|
|
1 => fitting[0],
|
|
2 => fitting[0],
|
|
n => fitting[n - 3], // third-largest
|
|
}
|
|
}
|
|
|
|
/// Resolve full path to a binary by checking common locations.
|
|
/// macOS .app bundles don't inherit the shell PATH, so we probe manually.
|
|
fn resolve_bin(name: &str) -> String {
|
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
let candidates = [
|
|
format!("/opt/homebrew/bin/{name}"),
|
|
format!("{home}/.local/bin/{name}"),
|
|
format!("{home}/.cargo/bin/{name}"),
|
|
format!("/usr/local/bin/{name}"),
|
|
format!("/usr/bin/{name}"),
|
|
];
|
|
for path in &candidates {
|
|
if std::path::Path::new(path).exists() {
|
|
return path.clone();
|
|
}
|
|
}
|
|
name.to_string()
|
|
}
|
|
|
|
/// Find the OpenJarvis project root (contains pyproject.toml).
|
|
/// Checks OPENJARVIS_ROOT env var, walks up from the executable, then
|
|
/// probes common clone locations.
|
|
fn find_project_root() -> Option<std::path::PathBuf> {
|
|
// 1. Explicit env var override
|
|
if let Ok(root) = std::env::var("OPENJARVIS_ROOT") {
|
|
let path = std::path::PathBuf::from(&root);
|
|
if path.join("pyproject.toml").exists() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
|
|
// 2. Walk up from the running executable (works in dev and .app bundle)
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
let mut dir = exe.parent().map(|p| p.to_path_buf());
|
|
for _ in 0..8 {
|
|
if let Some(ref d) = dir {
|
|
if d.join("pyproject.toml").exists() {
|
|
return Some(d.clone());
|
|
}
|
|
dir = d.parent().map(|p| p.to_path_buf());
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: well-known direct paths
|
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
let direct = [
|
|
format!("{home}/OpenJarvis"),
|
|
format!("{home}/projects/hazy/OpenJarvis"),
|
|
format!("{home}/projects/OpenJarvis"),
|
|
format!("{home}/src/OpenJarvis"),
|
|
format!("{home}/Documents/OpenJarvis"),
|
|
format!("{home}/Desktop/OpenJarvis"),
|
|
format!("{home}/Developer/OpenJarvis"),
|
|
format!("{home}/dev/OpenJarvis"),
|
|
format!("{home}/Code/OpenJarvis"),
|
|
format!("{home}/code/OpenJarvis"),
|
|
format!("{home}/repos/OpenJarvis"),
|
|
format!("{home}/github/OpenJarvis"),
|
|
];
|
|
for p in &direct {
|
|
let path = std::path::PathBuf::from(p);
|
|
if path.join("pyproject.toml").exists() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
|
|
// 4. Shallow scan: look for OpenJarvis one level inside common parent dirs.
|
|
// This catches clones like ~/Documents/my-stuff/OpenJarvis without
|
|
// needing to enumerate every possible intermediate folder.
|
|
let scan_parents = [
|
|
format!("{home}/Documents"),
|
|
format!("{home}/Desktop"),
|
|
format!("{home}/Developer"),
|
|
format!("{home}/projects"),
|
|
format!("{home}/repos"),
|
|
format!("{home}/src"),
|
|
format!("{home}/Code"),
|
|
format!("{home}/code"),
|
|
format!("{home}/dev"),
|
|
format!("{home}/github"),
|
|
];
|
|
for parent in &scan_parents {
|
|
let parent_path = std::path::PathBuf::from(parent);
|
|
if let Ok(entries) = std::fs::read_dir(&parent_path) {
|
|
for entry in entries.flatten() {
|
|
let candidate = entry.path().join("OpenJarvis");
|
|
if candidate.join("pyproject.toml").exists() {
|
|
return Some(candidate);
|
|
}
|
|
// Also check if the entry itself is OpenJarvis (case-insensitive match)
|
|
if let Some(name) = entry.file_name().to_str() {
|
|
if name.eq_ignore_ascii_case("openjarvis")
|
|
&& entry.path().join("pyproject.toml").exists()
|
|
{
|
|
return Some(entry.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BackendManager — owns the Ollama + Jarvis server child processes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct ChildHandle {
|
|
child: tokio::process::Child,
|
|
}
|
|
|
|
impl ChildHandle {
|
|
async fn kill(&mut self) {
|
|
let _ = self.child.kill().await;
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct BackendManager {
|
|
ollama: Option<ChildHandle>,
|
|
jarvis: Option<ChildHandle>,
|
|
}
|
|
|
|
impl BackendManager {
|
|
async fn stop_all(&mut self) {
|
|
if let Some(ref mut h) = self.jarvis {
|
|
h.kill().await;
|
|
}
|
|
self.jarvis = None;
|
|
if let Some(ref mut h) = self.ollama {
|
|
h.kill().await;
|
|
}
|
|
self.ollama = None;
|
|
}
|
|
}
|
|
|
|
type SharedBackend = Arc<Mutex<BackendManager>>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup status (reported to frontend)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(serde::Serialize, Clone)]
|
|
struct SetupStatus {
|
|
phase: String,
|
|
detail: String,
|
|
ollama_ready: bool,
|
|
server_ready: bool,
|
|
model_ready: bool,
|
|
error: Option<String>,
|
|
}
|
|
|
|
impl Default for SetupStatus {
|
|
fn default() -> Self {
|
|
Self {
|
|
phase: "starting".into(),
|
|
detail: "Initializing...".into(),
|
|
ollama_ready: false,
|
|
server_ready: false,
|
|
model_ready: false,
|
|
error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
type SharedStatus = Arc<Mutex<SetupStatus>>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health-check helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn wait_for_url(url: &str, timeout: Duration) -> bool {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.unwrap();
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
while tokio::time::Instant::now() < deadline {
|
|
if let Ok(resp) = client.get(url).send().await {
|
|
if resp.status().is_success() {
|
|
return true;
|
|
}
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn ollama_has_model(model: &str) -> bool {
|
|
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap();
|
|
if let Ok(resp) = client.get(&url).send().await {
|
|
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
|
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
|
|
return models.iter().any(|m| {
|
|
m.get("name")
|
|
.and_then(|n| n.as_str())
|
|
.map(|n| {
|
|
n == model
|
|
|| n.strip_suffix(":latest") == Some(model)
|
|
|| model.strip_suffix(":latest") == Some(n)
|
|
})
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn pull_model(model: &str) -> Result<(), String> {
|
|
let url = format!("http://127.0.0.1:{}/api/pull", OLLAMA_PORT);
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(600))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
let resp = client
|
|
.post(&url)
|
|
.json(&serde_json::json!({"name": model, "stream": false}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Pull request failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Pull returned status {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backend boot sequence (runs in background after app launch)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
|
// Phase 1: Start Ollama
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.phase = "ollama".into();
|
|
s.detail = "Starting inference engine...".into();
|
|
}
|
|
|
|
// Try the bundled sidecar first, fall back to system ollama
|
|
let ollama_child = {
|
|
let ollama_bin = resolve_bin("ollama");
|
|
let sidecar = tokio::process::Command::new(&ollama_bin)
|
|
.arg("serve")
|
|
.env("OLLAMA_HOST", format!("127.0.0.1:{}", OLLAMA_PORT))
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn();
|
|
match sidecar {
|
|
Ok(child) => Some(child),
|
|
Err(_) => None,
|
|
}
|
|
};
|
|
|
|
if let Some(child) = ollama_child {
|
|
backend.lock().await.ollama = Some(ChildHandle { child });
|
|
}
|
|
|
|
let ollama_url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
|
let ollama_ok = wait_for_url(&ollama_url, Duration::from_secs(30)).await;
|
|
|
|
if !ollama_ok {
|
|
let mut s = status.lock().await;
|
|
s.error = Some("Could not start Ollama. Install it from https://ollama.com".into());
|
|
return;
|
|
}
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.ollama_ready = true;
|
|
s.detail = "Inference engine ready.".into();
|
|
}
|
|
|
|
// Phase 2: Pull one small model (qwen3.5:2b) so the app can open fast.
|
|
// Remaining models are pulled in the background after the server starts.
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.phase = "model".into();
|
|
s.detail = format!("Checking for {}...", STARTUP_MODEL);
|
|
}
|
|
|
|
if !ollama_has_model(STARTUP_MODEL).await {
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = format!("Downloading {}... (this may take a minute)", STARTUP_MODEL);
|
|
}
|
|
if let Err(e) = pull_model(STARTUP_MODEL).await {
|
|
// If the startup model fails, try the tiny fallback
|
|
eprintln!("Warning: failed to pull {}: {}", STARTUP_MODEL, e);
|
|
if !ollama_has_model(FALLBACK_MODEL).await {
|
|
let mut s = status.lock().await;
|
|
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
|
drop(s);
|
|
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!("Failed to download model: {}", e2));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.model_ready = true;
|
|
s.detail = "Model ready.".into();
|
|
}
|
|
|
|
// Phase 3: Start jarvis serve
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.phase = "server".into();
|
|
s.detail = "Starting API server...".into();
|
|
}
|
|
|
|
let uv_bin = resolve_bin("uv");
|
|
|
|
// Verify uv is actually installed
|
|
if !std::path::Path::new(&uv_bin).exists() && uv_bin == "uv" {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(
|
|
"Could not find 'uv' (Python package manager). \
|
|
Install it from https://astral.sh/uv then relaunch."
|
|
.into(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let project_root = find_project_root();
|
|
|
|
if project_root.is_none() {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(
|
|
"Could not find the OpenJarvis project directory. \
|
|
Clone it with: git clone https://github.com/open-jarvis/OpenJarvis.git ~/OpenJarvis \
|
|
then relaunch."
|
|
.into(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Kill any leftover server on our port from a previous run
|
|
{
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.unwrap();
|
|
if client
|
|
.get(format!("http://127.0.0.1:{}/health", JARVIS_PORT))
|
|
.send()
|
|
.await
|
|
.is_ok()
|
|
{
|
|
// Something is already listening — try to kill it
|
|
#[cfg(unix)]
|
|
{
|
|
let _ = tokio::process::Command::new("fuser")
|
|
.args(["-k", &format!("{}/tcp", JARVIS_PORT)])
|
|
.output()
|
|
.await;
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start with STARTUP_MODEL (just pulled) or preferred if already available.
|
|
let pref = preferred_model();
|
|
let startup_model = if ollama_has_model(pref).await {
|
|
pref
|
|
} else if ollama_has_model(STARTUP_MODEL).await {
|
|
STARTUP_MODEL
|
|
} else {
|
|
FALLBACK_MODEL
|
|
};
|
|
|
|
let root = project_root.as_ref().unwrap();
|
|
|
|
// Install dependencies automatically (handles fresh clones)
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = "Installing dependencies...".into();
|
|
}
|
|
let _ = tokio::process::Command::new(&uv_bin)
|
|
.args(["sync", "--extra", "server"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.current_dir(root)
|
|
.status()
|
|
.await;
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = format!(
|
|
"Starting server with {} from {}...",
|
|
startup_model,
|
|
root.display(),
|
|
);
|
|
}
|
|
|
|
let mut cmd = tokio::process::Command::new(&uv_bin);
|
|
cmd.args([
|
|
"run", "jarvis", "serve",
|
|
"--port", &JARVIS_PORT.to_string(),
|
|
"--model", startup_model,
|
|
"--agent", "simple",
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.current_dir(root);
|
|
|
|
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
|
|
for (key, value) in read_cloud_keys() {
|
|
cmd.env(&key, &value);
|
|
}
|
|
let jarvis_child = cmd.spawn();
|
|
|
|
match jarvis_child {
|
|
Ok(child) => {
|
|
backend.lock().await.jarvis = Some(ChildHandle { child });
|
|
}
|
|
Err(e) => {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!(
|
|
"Could not start jarvis server: {}. \
|
|
Make sure uv is installed (https://astral.sh/uv) and the OpenJarvis repo is cloned at {}",
|
|
e,
|
|
root.display(),
|
|
));
|
|
return;
|
|
}
|
|
}
|
|
|
|
let server_url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
|
|
let server_ok = wait_for_url(&server_url, Duration::from_secs(600)).await;
|
|
|
|
if !server_ok {
|
|
// Try to read stderr from the failed process for a useful error
|
|
let mut stderr_msg = String::new();
|
|
{
|
|
let mut mgr = backend.lock().await;
|
|
if let Some(ref mut h) = mgr.jarvis {
|
|
if let Some(ref mut stderr) = h.child.stderr.take() {
|
|
use tokio::io::AsyncReadExt;
|
|
let mut buf = vec![0u8; 4096];
|
|
if let Ok(n) = stderr.read(&mut buf).await {
|
|
stderr_msg = String::from_utf8_lossy(&buf[..n]).to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let detail = if stderr_msg.is_empty() {
|
|
format!(
|
|
"Jarvis server did not start. Check that:\n\
|
|
1. uv is installed ({})\n\
|
|
2. The OpenJarvis repo is at {}\n\
|
|
3. Run 'uv sync' in that directory",
|
|
uv_bin,
|
|
root.display(),
|
|
)
|
|
} else {
|
|
format!("Server failed to start: {}", stderr_msg.trim())
|
|
};
|
|
let mut s = status.lock().await;
|
|
s.error = Some(detail);
|
|
return;
|
|
}
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.server_ready = true;
|
|
s.phase = "ready".into();
|
|
s.detail = "All systems ready.".into();
|
|
}
|
|
|
|
// Phase 4: Pull remaining Qwen3.5 models in the background.
|
|
// The app is already usable with qwen3.5:2b; as each model finishes
|
|
// it appears in the model list automatically.
|
|
let fitting = models_that_fit();
|
|
tokio::spawn(async move {
|
|
for model in fitting {
|
|
if model != STARTUP_MODEL && model != FALLBACK_MODEL {
|
|
if !ollama_has_model(model).await {
|
|
let _ = pull_model(model).await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tauri commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn api_base() -> String {
|
|
format!("http://127.0.0.1:{}", JARVIS_PORT)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_setup_status(
|
|
state: tauri::State<'_, SharedStatus>,
|
|
) -> Result<SetupStatus, String> {
|
|
Ok(state.lock().await.clone())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_api_base() -> String {
|
|
api_base()
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn start_backend(
|
|
backend: tauri::State<'_, SharedBackend>,
|
|
status: tauri::State<'_, SharedStatus>,
|
|
) -> Result<(), String> {
|
|
let b = backend.inner().clone();
|
|
let s = status.inner().clone();
|
|
tauri::async_runtime::spawn(boot_backend(b, s));
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn stop_backend(
|
|
backend: tauri::State<'_, SharedBackend>,
|
|
) -> Result<(), String> {
|
|
backend.lock().await.stop_all().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn check_health(api_url: String) -> Result<serde_json::Value, String> {
|
|
let url = format!("{}/health", if api_url.is_empty() { api_base() } else { api_url });
|
|
let resp = reqwest::get(&url)
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_energy(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/telemetry/energy", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_telemetry(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/telemetry/stats", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_traces(api_url: String, limit: u32) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/traces?limit={}", base, limit))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_trace(api_url: String, trace_id: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/traces/{}", base, trace_id))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_learning_stats(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/learning/stats", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_learning_policy(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/learning/policy", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_memory_stats(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/memory/stats", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn search_memory(
|
|
api_url: String,
|
|
query: String,
|
|
top_k: u32,
|
|
) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.post(format!("{}/v1/memory/search", base))
|
|
.json(&serde_json::json!({"query": query, "top_k": top_k}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_agents(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/agents", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/models", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
|
|
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
|
|
cmd_args.extend(args);
|
|
let uv_bin = resolve_bin("uv");
|
|
let output = tokio::process::Command::new(&uv_bin)
|
|
.args(&cmd_args)
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
|
|
|
|
if output.status.success() {
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
} else {
|
|
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_savings(api_url: String) -> Result<serde_json::Value, String> {
|
|
let base = if api_url.is_empty() { api_base() } else { api_url };
|
|
let resp = reqwest::get(format!("{}/v1/savings", base))
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
|
}
|
|
|
|
/// Transcribe audio via the speech API endpoint.
|
|
#[tauri::command]
|
|
async fn transcribe_audio(
|
|
api_url: String,
|
|
audio_data: Vec<u8>,
|
|
filename: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let url = format!("{}/v1/speech/transcribe", api_url);
|
|
let client = reqwest::Client::new();
|
|
|
|
let part = reqwest::multipart::Part::bytes(audio_data)
|
|
.file_name(filename)
|
|
.mime_str("audio/webm")
|
|
.map_err(|e| format!("Failed to create multipart: {}", e))?;
|
|
|
|
let form = reqwest::multipart::Form::new().part("file", part);
|
|
|
|
let resp = client
|
|
.post(&url)
|
|
.multipart(form)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Invalid response: {}", e))?;
|
|
Ok(body)
|
|
}
|
|
|
|
/// Submit savings to Supabase leaderboard.
|
|
#[tauri::command]
|
|
async fn submit_savings(
|
|
supabase_url: String,
|
|
supabase_key: String,
|
|
payload: serde_json::Value,
|
|
) -> Result<bool, String> {
|
|
if supabase_url.is_empty() || supabase_key.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.post(format!("{}/rest/v1/savings_entries?on_conflict=anon_id", supabase_url))
|
|
.header("Content-Type", "application/json")
|
|
.header("apikey", &supabase_key)
|
|
.header("Authorization", format!("Bearer {}", supabase_key))
|
|
.header("Prefer", "resolution=merge-duplicates")
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Supabase POST failed: {}", e))?;
|
|
Ok(resp.status().is_success())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cloud API key management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
|
|
fn cloud_keys_path() -> std::path::PathBuf {
|
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
std::path::PathBuf::from(home)
|
|
.join(".openjarvis")
|
|
.join("cloud-keys.env")
|
|
}
|
|
|
|
/// Read cloud keys from disk and return as key=value pairs.
|
|
fn read_cloud_keys() -> Vec<(String, String)> {
|
|
let path = cloud_keys_path();
|
|
let mut keys = Vec::new();
|
|
if let Ok(contents) = std::fs::read_to_string(&path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
keys.push((k.trim().to_string(), v.trim().to_string()));
|
|
}
|
|
}
|
|
}
|
|
keys
|
|
}
|
|
|
|
/// Save a single cloud API key to the keys file.
|
|
#[tauri::command]
|
|
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
|
let path = cloud_keys_path();
|
|
// Ensure directory exists
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
// Read existing keys, update/add the one being saved
|
|
let mut keys: Vec<(String, String)> = read_cloud_keys()
|
|
.into_iter()
|
|
.filter(|(k, _)| k != &key_name)
|
|
.collect();
|
|
if !key_value.is_empty() {
|
|
keys.push((key_name, key_value));
|
|
}
|
|
|
|
// Write back
|
|
let content: String = keys
|
|
.iter()
|
|
.map(|(k, v)| format!("{}={}", k, v))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
std::fs::write(&path, content + "\n")
|
|
.map_err(|e| format!("Failed to save key: {}", e))?;
|
|
|
|
// Set permissions to owner-only (chmod 600)
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get which cloud providers have keys configured (without exposing values).
|
|
#[tauri::command]
|
|
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
|
|
let keys = read_cloud_keys();
|
|
let status: Vec<serde_json::Value> = keys
|
|
.iter()
|
|
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
|
|
.collect();
|
|
Ok(serde_json::json!(status))
|
|
}
|
|
|
|
/// Pull a model via Ollama (called from frontend download button).
|
|
#[tauri::command]
|
|
async fn pull_ollama_model(model_name: String) -> Result<serde_json::Value, String> {
|
|
pull_model(&model_name)
|
|
.await
|
|
.map_err(|e| format!("Failed to pull {}: {}", model_name, e))?;
|
|
Ok(serde_json::json!({"status": "ok", "model": model_name}))
|
|
}
|
|
|
|
/// Delete a model from Ollama.
|
|
#[tauri::command]
|
|
async fn delete_ollama_model(model_name: String) -> Result<serde_json::Value, String> {
|
|
let url = format!("http://127.0.0.1:{}/api/delete", OLLAMA_PORT);
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
let resp = client
|
|
.delete(&url)
|
|
.json(&serde_json::json!({"name": model_name}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Delete failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Delete returned status {}", resp.status()));
|
|
}
|
|
Ok(serde_json::json!({"status": "deleted", "model": model_name}))
|
|
}
|
|
|
|
/// Check speech backend health.
|
|
#[tauri::command]
|
|
async fn speech_health(api_url: String) -> Result<serde_json::Value, String> {
|
|
let url = format!("{}/v1/speech/health", api_url);
|
|
let resp = reqwest::get(&url)
|
|
.await
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Invalid response: {}", e))?;
|
|
Ok(body)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let backend: SharedBackend = Arc::new(Mutex::new(BackendManager::default()));
|
|
let status: SharedStatus = Arc::new(Mutex::new(SetupStatus::default()));
|
|
|
|
let boot_backend_ref = backend.clone();
|
|
let boot_status_ref = status.clone();
|
|
|
|
tauri::Builder::default()
|
|
.manage(backend.clone())
|
|
.manage(status.clone())
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_shell::init())
|
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
|
.plugin(tauri_plugin_autostart::init(
|
|
MacosLauncher::LaunchAgent,
|
|
Some(vec!["--hidden"]),
|
|
))
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(tauri_plugin_process::init())
|
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.set_focus();
|
|
}
|
|
}))
|
|
.setup(move |app| {
|
|
// System tray
|
|
let show = MenuItemBuilder::with_id("show", "Show / Hide")
|
|
.build(app)?;
|
|
let health = MenuItemBuilder::with_id("health", "Health: starting...")
|
|
.enabled(false)
|
|
.build(app)?;
|
|
let quit = MenuItemBuilder::with_id("quit", "Quit OpenJarvis")
|
|
.build(app)?;
|
|
|
|
let menu = MenuBuilder::new(app)
|
|
.item(&show)
|
|
.separator()
|
|
.item(&health)
|
|
.separator()
|
|
.item(&quit)
|
|
.build()?;
|
|
|
|
let _tray = TrayIconBuilder::with_id("main")
|
|
.icon(app.default_window_icon().unwrap().clone())
|
|
.tooltip("OpenJarvis")
|
|
.menu(&menu)
|
|
.on_menu_event(move |app, event| {
|
|
match event.id().as_ref() {
|
|
"show" => {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
if window.is_visible().unwrap_or(false) {
|
|
let _ = window.hide();
|
|
} else {
|
|
let _ = window.show();
|
|
let _ = window.set_focus();
|
|
}
|
|
}
|
|
}
|
|
"quit" => {
|
|
app.exit(0);
|
|
}
|
|
_ => {}
|
|
}
|
|
})
|
|
.build(app)?;
|
|
|
|
// Auto-start backend services on launch
|
|
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
|
|
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
get_setup_status,
|
|
get_api_base,
|
|
start_backend,
|
|
stop_backend,
|
|
check_health,
|
|
fetch_energy,
|
|
fetch_telemetry,
|
|
fetch_traces,
|
|
fetch_trace,
|
|
fetch_learning_stats,
|
|
fetch_learning_policy,
|
|
fetch_memory_stats,
|
|
search_memory,
|
|
fetch_agents,
|
|
fetch_models,
|
|
run_jarvis_command,
|
|
fetch_savings,
|
|
submit_savings,
|
|
transcribe_audio,
|
|
speech_health,
|
|
pull_ollama_model,
|
|
delete_ollama_model,
|
|
save_cloud_key,
|
|
get_cloud_key_status,
|
|
])
|
|
.build(tauri::generate_context!())
|
|
.expect("error while building OpenJarvis Desktop")
|
|
.run(move |_app, event| {
|
|
if let tauri::RunEvent::ExitRequested { .. } = event {
|
|
let b = backend.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
b.lock().await.stop_all().await;
|
|
});
|
|
}
|
|
});
|
|
}
|